feat(ui): add infoheader component, improve quest map visuals
This commit is contained in:
820
src/components/InfoHeader.tsx
Normal file
820
src/components/InfoHeader.tsx
Normal file
@ -0,0 +1,820 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Gauge, Map } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import {
|
||||
useQuestStore,
|
||||
getQuestSummary,
|
||||
getCrewRank,
|
||||
getEarnedXP,
|
||||
} from "../stores/useQuestStore";
|
||||
import type { QuestNode, QuestArc } from "../types/quest";
|
||||
import { CREW_RANKS } from "../types/quest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
||||
import { PredictedScoreCard } from "./PredictedScoreCard";
|
||||
import { ChestOpenModal } from "./ChestOpenModal";
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
|
||||
|
||||
/* ════ SHARED ANIMATION ════ */
|
||||
@keyframes hcIn {
|
||||
from { opacity:0; transform:translateY(10px) scale(0.97); }
|
||||
to { opacity:1; transform:translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ════ WHITE CARD (DEFAULT / LEVEL / QUEST_COMPACT) ════ */
|
||||
.hc-card {
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 26px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
|
||||
/* Identity */
|
||||
.hc-top {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
padding: 1.1rem 1.2rem 0.9rem;
|
||||
}
|
||||
.hc-identity { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; }
|
||||
.hc-av-wrap { position: relative; flex-shrink: 0; }
|
||||
.hc-av-pip {
|
||||
position: absolute; bottom: -3px; right: -3px;
|
||||
min-width: 18px; height: 18px; border-radius: 9px; padding: 0 4px;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
border: 2px solid white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.55rem; font-weight: 900; color: white;
|
||||
}
|
||||
.hc-nameblock { flex: 1; min-width: 0; }
|
||||
.hc-greeting {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.98rem; font-weight: 900; color: #1e1b4b;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;
|
||||
}
|
||||
.hc-greeting em { font-style: normal; color: #a855f7; }
|
||||
.hc-role {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: #9ca3af; margin-top: 0.05rem;
|
||||
}
|
||||
.hc-score-btn {
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
background: #f7ffe4; border: 2px solid #d9f99d; border-radius: 100px;
|
||||
padding: 0.42rem 0.72rem; font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.76rem; font-weight: 800; color: #65a30d;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.hc-score-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.07); }
|
||||
.hc-sep { height: 1px; margin: 0 1.2rem; background: #f3f4f6; }
|
||||
|
||||
/* XP bar */
|
||||
.hc-xp-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.2rem; }
|
||||
.hc-lvl-tag {
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
|
||||
color: #a855f7; flex-shrink: 0; background: #f3e8ff;
|
||||
border-radius: 8px; padding: 0.22rem 0.5rem; white-space: nowrap;
|
||||
}
|
||||
.hc-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 0.22rem; }
|
||||
.hc-track { height: 8px; background: #f3f4f6; border-radius: 100px; overflow: hidden; }
|
||||
.hc-fill {
|
||||
height: 100%; border-radius: 100px;
|
||||
background: linear-gradient(90deg, #a855f7, #f97316);
|
||||
transition: width 1.1s cubic-bezier(0.34,1.56,0.64,1);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.hc-fill::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: hcShimmer 2.6s ease-in-out 1s infinite;
|
||||
}
|
||||
@keyframes hcShimmer { to { transform: translateX(200%); } }
|
||||
.hc-xp-label {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.6rem; font-weight: 700; color: #9ca3af;
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
.hc-xp-label span:first-child { color: #a855f7; font-weight: 900; }
|
||||
|
||||
/* Rank row (compact) */
|
||||
.hc-rank-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.75rem 1.2rem; cursor: pointer;
|
||||
transition: background 0.15s; border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.hc-rank-row:first-child { border-top: none; }
|
||||
.hc-rank-row:hover { background: #fafafa; }
|
||||
.hc-rank-emoji { font-size: 1.15rem; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
|
||||
.hc-rank-text { flex: 1; min-width: 0; }
|
||||
.hc-rank-name {
|
||||
font-family: 'Cinzel', serif; font-size: 0.8rem; font-weight: 700; color: #1e1b4b;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.hc-rank-progress-label {
|
||||
font-family: 'Nunito Sans', sans-serif; font-size: 0.58rem; font-weight: 700;
|
||||
color: #9ca3af; margin-top: 0.08rem;
|
||||
}
|
||||
.hc-rank-right { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||
.hc-streak-pill {
|
||||
display: flex; align-items: center; gap: 0.22rem;
|
||||
background: #fff5f5; border: 1.5px solid #fecaca; border-radius: 100px;
|
||||
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.7rem; font-weight: 900; color: #ef4444;
|
||||
}
|
||||
.hc-chest-badge {
|
||||
display: flex; align-items: center; gap: 0.18rem;
|
||||
background: #fef3c7; border: 1.5px solid #fde68a; border-radius: 100px;
|
||||
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.7rem; font-weight: 900; color: #b45309;
|
||||
animation: hcPop 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hcPop { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
|
||||
.hc-chevron { color: #d1d5db; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s; }
|
||||
.hc-chevron.open { transform: rotate(180deg); color: #a855f7; }
|
||||
|
||||
/* Collapsible quest panel */
|
||||
.hc-quests-wrap {
|
||||
overflow: hidden; max-height: 0;
|
||||
transition: max-height 0.38s cubic-bezier(0.4,0,0.2,1);
|
||||
background: #fafafa; border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.hc-quests-wrap.open { max-height: 480px; }
|
||||
.hc-quest-list { display: flex; flex-direction: column; padding: 0.35rem 0; }
|
||||
.hc-quest-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.65rem 1.2rem; cursor: pointer; transition: background 0.13s; position: relative;
|
||||
}
|
||||
.hc-quest-row:hover { background: #f3f4f6; }
|
||||
.hc-quest-row::before {
|
||||
content: ''; position: absolute; left: 0; top: 20%; bottom: 20%;
|
||||
width: 3px; border-radius: 0 3px 3px 0; background: var(--ac);
|
||||
}
|
||||
.hc-q-icon {
|
||||
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1rem; background: white; border: 1.5px solid #f3f4f6;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.hc-quest-row:hover .hc-q-icon { transform: scale(1.08) rotate(-4deg); }
|
||||
.hc-q-icon.claimable { background: #fef3c7; border-color: #fde68a; animation: hcWiggle 2s ease-in-out infinite; }
|
||||
@keyframes hcWiggle { 0%,100%{transform:rotate(0);} 30%{transform:rotate(-7deg) scale(1.05);} 70%{transform:rotate(7deg) scale(1.05);} }
|
||||
.hc-q-body { flex: 1; min-width: 0; }
|
||||
.hc-q-name { font-family: 'Nunito', sans-serif; font-size: 0.8rem; font-weight: 800; color: #1e1b4b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hc-q-sub { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 600; color: #9ca3af; margin-top: 0.1rem; }
|
||||
.hc-q-claimable { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 700; color: #d97706; margin-top: 0.1rem; }
|
||||
.hc-claim-btn {
|
||||
padding: 0.28rem 0.62rem; border: none; border-radius: 100px; cursor: pointer;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||
box-shadow: 0 2px 0 #d97706; flex-shrink: 0; transition: all 0.12s;
|
||||
}
|
||||
.hc-claim-btn:hover { transform: translateY(-1px); }
|
||||
.hc-claim-btn:active { transform: translateY(1px); }
|
||||
.hc-empty { padding: 1rem 1.2rem; text-align: center; font-family: 'Nunito', sans-serif; font-size: 0.82rem; font-weight: 700; color: #9ca3af; }
|
||||
.hc-map-link {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||
padding: 0.6rem 1.2rem; border-top: 1px solid #f3f4f6;
|
||||
cursor: pointer; transition: background 0.13s;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 800; color: #a855f7;
|
||||
}
|
||||
.hc-map-link:hover { background: #fdf4ff; }
|
||||
|
||||
/* ════ DARK OCEAN CARD (QUEST_EXTENDED) ════ */
|
||||
.hc-ext {
|
||||
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
|
||||
border-radius: 26px; overflow: hidden; position: relative;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Animated sea shimmer */
|
||||
.hc-ext::before {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||
background:
|
||||
repeating-linear-gradient(105deg, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
|
||||
repeating-linear-gradient(75deg, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
|
||||
background-size: 320% 320%, 260% 260%;
|
||||
animation: hcExtSea 14s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes hcExtSea {
|
||||
0% { background-position: 0% 0%, 100% 0%; }
|
||||
100% { background-position: 100% 100%, 0% 100%; }
|
||||
}
|
||||
|
||||
/* Gold orb */
|
||||
.hc-ext::after {
|
||||
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
|
||||
width: 180px; height: 180px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.hc-ext-header {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.2rem 0.3rem;
|
||||
}
|
||||
.hc-ext-title {
|
||||
font-family: 'Cinzel', serif; font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.2em; text-transform: uppercase; color: rgba(251,191,36,0.65);
|
||||
}
|
||||
.hc-ext-earned {
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
|
||||
color: #fbbf24; background: rgba(251,191,36,0.1);
|
||||
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Scrollable track container */
|
||||
.hc-ext-scroll {
|
||||
position: relative; z-index: 2;
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||||
cursor: grab; padding: 1.0rem 1.0rem 0.8rem;
|
||||
}
|
||||
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
||||
.hc-ext-scroll:active { cursor: grabbing; }
|
||||
|
||||
/* Track inner wrapper — the thing that actually lays out rank nodes */
|
||||
.hc-ext-inner {
|
||||
display: flex; align-items: flex-end;
|
||||
position: relative;
|
||||
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
|
||||
height: 110px;
|
||||
/* width set inline per node count */
|
||||
}
|
||||
|
||||
/* Baseline connector line — full width, dim */
|
||||
.hc-ext-baseline {
|
||||
position: absolute;
|
||||
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */
|
||||
left: 26px; right: 26px; height: 2px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 2px; z-index: 0;
|
||||
}
|
||||
|
||||
/* Gold progress line — width set inline */
|
||||
.hc-ext-progress-line {
|
||||
position: absolute;
|
||||
top: 56px; left: 26px; height: 2px;
|
||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
||||
border-radius: 2px; z-index: 1;
|
||||
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
|
||||
/* Ship — absolutely positioned, transition on 'left' */
|
||||
.hc-ext-ship-wrap {
|
||||
position: absolute;
|
||||
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */
|
||||
z-index: 10; pointer-events: none;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0px;
|
||||
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.hc-ext-ship {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(0 2px 12px rgba(251,191,36,0.6));
|
||||
animation: hcShipBob 2.8s ease-in-out infinite;
|
||||
display: block;
|
||||
}
|
||||
@keyframes hcShipBob {
|
||||
0%,100% { transform: translateY(0) rotate(-3deg); }
|
||||
50% { transform: translateY(-6px) rotate(3deg); }
|
||||
}
|
||||
.hc-ext-ship-tether {
|
||||
width: 1px; height: 14px;
|
||||
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
||||
}
|
||||
|
||||
/* Each rank column */
|
||||
.hc-ext-col {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
position: relative; z-index: 2;
|
||||
width: 88px; flex-shrink: 0;
|
||||
}
|
||||
/* Narrow first/last columns so line extends correctly */
|
||||
.hc-ext-col:first-child,
|
||||
.hc-ext-col:last-child { width: 52px; }
|
||||
|
||||
/* Node circle */
|
||||
.hc-ext-node {
|
||||
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; position: relative; z-index: 2;
|
||||
margin-top: 42px; /* push down below ship zone */
|
||||
}
|
||||
.hc-ext-node.reached {
|
||||
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
||||
border: 2px solid rgba(251,191,36,0.45);
|
||||
box-shadow: 0 0 18px rgba(251,191,36,0.2), 0 4px 0 rgba(20,10,50,0.7);
|
||||
}
|
||||
.hc-ext-node.current {
|
||||
background: linear-gradient(145deg, #6d28d9, #a855f7);
|
||||
border: 2.5px solid #fbbf24;
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(251,191,36,0.12),
|
||||
0 0 22px rgba(168,85,247,0.45),
|
||||
0 4px 0 rgba(80,30,150,0.5);
|
||||
animation: hcExtNodePulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hcExtNodePulse {
|
||||
0%,100% { box-shadow: 0 0 0 4px rgba(251,191,36,0.12), 0 0 22px rgba(168,85,247,0.45), 0 4px 0 rgba(80,30,150,0.5); }
|
||||
50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); }
|
||||
}
|
||||
.hc-ext-node.locked {
|
||||
background: rgba(0,0,0);
|
||||
border: 2px solid rgba(255,255,255,0.09);
|
||||
filter: grayscale(0.7) opacity(0.45);
|
||||
}
|
||||
|
||||
/* Labels below node */
|
||||
.hc-ext-label {
|
||||
margin-top: 7px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
}
|
||||
.hc-ext-label-name {
|
||||
font-family: 'Cinzel', serif; font-size: 0.48rem; font-weight: 700;
|
||||
text-align: center; line-height: 1.3; letter-spacing: 0.03em; max-width: 70px;
|
||||
}
|
||||
.hc-ext-label-name.reached { color: #fbbf24; }
|
||||
.hc-ext-label-name.current { color: #c084fc; }
|
||||
.hc-ext-label-name.locked { color: rgba(255,255,255,0.2); }
|
||||
.hc-ext-label-xp {
|
||||
font-family: 'Nunito Sans', sans-serif; font-size: 0.42rem; font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
|
||||
.hc-ext-label-xp.current { color: rgba(192,132,252,0.6); }
|
||||
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
|
||||
|
||||
/* Footer link */
|
||||
.hc-ext-footer {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||
padding: 0.5rem 1.2rem 0.85rem; margin-top: 0.2rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
cursor: pointer; transition: opacity 0.15s;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.68rem; font-weight: 800;
|
||||
color: rgba(251,191,36,0.55); letter-spacing: 0.04em;
|
||||
}
|
||||
.hc-ext-footer:hover { opacity: 0.75; }
|
||||
`;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function getActiveQuests(arcs: QuestArc[]) {
|
||||
const out: { node: QuestNode; arc: QuestArc }[] = [];
|
||||
for (const arc of arcs)
|
||||
for (const node of arc.nodes)
|
||||
if (node.status === "claimable" || node.status === "active")
|
||||
out.push({ node, arc });
|
||||
out.sort((a, b) =>
|
||||
a.node.status === "claimable" && b.node.status !== "claimable"
|
||||
? -1
|
||||
: b.node.status === "claimable" && a.node.status !== "claimable"
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
return out.slice(0, 2);
|
||||
}
|
||||
|
||||
// Segment width for nodes that aren't first/last
|
||||
const SEG_W = 88;
|
||||
const EDGE_W = 52;
|
||||
// Centre x of node at index i (0-based, total N nodes)
|
||||
function nodeX(i: number, total: number): number {
|
||||
if (i === 0) return EDGE_W / 2;
|
||||
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
|
||||
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
|
||||
}
|
||||
|
||||
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
||||
const RankLadder = ({
|
||||
earnedXP,
|
||||
onViewAll,
|
||||
}: {
|
||||
earnedXP: number;
|
||||
onViewAll: () => void;
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
|
||||
const N = ladder.length;
|
||||
|
||||
// Which rank the user is currently on (0-based)
|
||||
let currentIdx = 0;
|
||||
for (let i = N - 1; i >= 0; i--) {
|
||||
if (earnedXP >= ladder[i].xpRequired) {
|
||||
currentIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const current = ladder[currentIdx];
|
||||
const nextRank = ladder[currentIdx + 1] ?? null;
|
||||
const progressToNext = nextRank
|
||||
? Math.min(
|
||||
1,
|
||||
(earnedXP - current.xpRequired) /
|
||||
(nextRank.xpRequired - current.xpRequired),
|
||||
)
|
||||
: 1;
|
||||
|
||||
// Ship x position: interpolate between current node and next node
|
||||
const shipX = nextRank
|
||||
? nodeX(currentIdx, N) +
|
||||
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
|
||||
: nodeX(currentIdx, N);
|
||||
|
||||
// Gold progress line width: from left edge to ship position
|
||||
const progressLineW = shipX;
|
||||
|
||||
// Total scroll width
|
||||
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
||||
|
||||
// Animate ship in after mount
|
||||
const [animated, setAnimated] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setAnimated(true)),
|
||||
);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to ship position on mount
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
const containerW = el.offsetWidth;
|
||||
const targetScroll = shipX - containerW / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" });
|
||||
}, [shipX]);
|
||||
|
||||
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
||||
const nextLabel = nextRank
|
||||
? `${rankPct}% · ${nextRank.xpRequired - earnedXP} XP to ${nextRank.label}`
|
||||
: "Maximum rank achieved";
|
||||
|
||||
return (
|
||||
<div className="hc-ext">
|
||||
{/* Header */}
|
||||
<div className="hc-ext-header">
|
||||
<span className="hc-ext-title">⚓ Crew Rank</span>
|
||||
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
|
||||
</div>
|
||||
|
||||
{/* Current rank label */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
padding: "0 1.2rem 0.1rem",
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "'Cinzel', serif",
|
||||
fontSize: "1.05rem",
|
||||
fontWeight: 900,
|
||||
color: "#fbbf24",
|
||||
textShadow: "0 0 18px rgba(251,191,36,0.4)",
|
||||
}}
|
||||
>
|
||||
{current.emoji} {current.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scrollable rank track */}
|
||||
<div className="hc-ext-scroll" ref={scrollRef}>
|
||||
<div className="hc-ext-inner" style={{ width: totalW }}>
|
||||
{/* Baseline dim line */}
|
||||
<div className="hc-ext-baseline" />
|
||||
|
||||
{/* Gold progress line */}
|
||||
<div
|
||||
className="hc-ext-progress-line"
|
||||
style={{ width: animated ? progressLineW : 26 }}
|
||||
/>
|
||||
|
||||
{/* Ship marker */}
|
||||
<div
|
||||
className="hc-ext-ship-wrap"
|
||||
style={{ left: animated ? shipX : nodeX(0, N) }}
|
||||
>
|
||||
<span className="hc-ext-ship" role="img" aria-label="ship">
|
||||
⛵
|
||||
</span>
|
||||
<div className="hc-ext-ship-tether" />
|
||||
</div>
|
||||
|
||||
{/* Rank nodes */}
|
||||
{ladder.map((r, i) => {
|
||||
const state =
|
||||
i < currentIdx
|
||||
? "reached"
|
||||
: i === currentIdx
|
||||
? "current"
|
||||
: "locked";
|
||||
return (
|
||||
<div key={r.id} className="hc-ext-col">
|
||||
<div className={`hc-ext-node ${state}`}>{r.emoji}</div>
|
||||
<div className="hc-ext-label">
|
||||
<span className={`hc-ext-label-name ${state}`}>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className={`hc-ext-label-xp ${state}`}>
|
||||
{r.xpRequired === 0
|
||||
? "Start"
|
||||
: `${r.xpRequired.toLocaleString()} XP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* <div className="hc-ext-footer" onClick={onViewAll}>
|
||||
<Map size={12} />
|
||||
View quest map
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
|
||||
|
||||
interface Props {
|
||||
onViewAll?: () => void;
|
||||
mode?: Mode;
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const arcs = useQuestStore((s) => s.arcs);
|
||||
const claimNode = useQuestStore((s) => s.claimNode);
|
||||
|
||||
const summary = getQuestSummary(arcs);
|
||||
const rank = getCrewRank(arcs);
|
||||
const earnedXP = getEarnedXP(arcs);
|
||||
const activeQuests = getActiveQuests(arcs);
|
||||
|
||||
const u = user as any;
|
||||
const level = u?.current_level ?? u?.level ?? 1;
|
||||
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
||||
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||
const levelEnd =
|
||||
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||
const streak = u?.streak ?? u?.current_streak ?? 0;
|
||||
const firstName = user?.name?.split(" ")[0] || "there";
|
||||
const roleLabel =
|
||||
u?.role === "ADMIN"
|
||||
? "Admin"
|
||||
: u?.role === "TEACHER"
|
||||
? "Teacher"
|
||||
: "Student";
|
||||
const hour = new Date().getHours();
|
||||
const timeLabel = hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
|
||||
|
||||
const levelRange = Math.max(levelEnd - levelStart, 1);
|
||||
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
|
||||
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
|
||||
const xpToGo = Math.max(levelEnd - totalXP, 0);
|
||||
|
||||
const [barPct, setBarPct] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setBarPct(rawPct)),
|
||||
);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [rawPct]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [claimingNode, setClaimingNode] = useState<{
|
||||
node: QuestNode;
|
||||
arcId: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleViewAll = () => {
|
||||
if (onViewAll) onViewAll();
|
||||
else navigate("/student/quests");
|
||||
};
|
||||
const handleClaim = (node: QuestNode, arcId: string) =>
|
||||
setClaimingNode({ node, arcId });
|
||||
const handleChestClose = () => {
|
||||
if (!claimingNode) return;
|
||||
claimNode(claimingNode.arcId, claimingNode.node.id);
|
||||
setClaimingNode(null);
|
||||
};
|
||||
|
||||
const rankProgress = Math.round(rank.progressToNext * 100);
|
||||
const nextLabel = rank.next
|
||||
? `${rankProgress}% to ${rank.next.label}`
|
||||
: "Max rank";
|
||||
|
||||
const showIdentity = mode === "DEFAULT";
|
||||
const showLevel = mode === "DEFAULT" || mode === "LEVEL";
|
||||
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
|
||||
const showQuestExtended = mode === "QUEST_EXTENDED";
|
||||
|
||||
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
|
||||
if (showQuestExtended) {
|
||||
return (
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
|
||||
{claimingNode && (
|
||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
|
||||
<div className="hc-card">
|
||||
{/* Identity — DEFAULT only */}
|
||||
{showIdentity && (
|
||||
<>
|
||||
<div className="hc-top">
|
||||
<div className="hc-identity">
|
||||
<div className="hc-av-wrap">
|
||||
<Avatar style={{ width: 46, height: 46, display: "block" }}>
|
||||
<AvatarImage src={u?.avatar_url} />
|
||||
<AvatarFallback
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
fontSize: "1rem",
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||
}}
|
||||
>
|
||||
{user?.name?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="hc-av-pip">{level}</div>
|
||||
</div>
|
||||
<div className="hc-nameblock">
|
||||
<p className="hc-greeting">
|
||||
Good {timeLabel}, <em>{firstName}</em> 👋
|
||||
</p>
|
||||
<p className="hc-role">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer direction="top">
|
||||
<DrawerTrigger asChild>
|
||||
<button className="hc-score-btn">
|
||||
<Gauge size={14} /> Score
|
||||
</button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<PredictedScoreCard />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
<div className="hc-sep" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* XP bar — DEFAULT + LEVEL */}
|
||||
{showLevel && (
|
||||
<div className="hc-xp-row">
|
||||
<span className="hc-lvl-tag">Lv {level}</span>
|
||||
<div className="hc-bar-wrap">
|
||||
<div className="hc-track">
|
||||
<div className="hc-fill" style={{ width: `${barPct}%` }} />
|
||||
</div>
|
||||
<div className="hc-xp-label">
|
||||
<span>{totalXP.toLocaleString()} XP</span>
|
||||
<span>{xpToGo.toLocaleString()} to go</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
|
||||
{showQuestCompact && (
|
||||
<>
|
||||
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
||||
<span className="hc-rank-emoji">{rank.emoji}</span>
|
||||
<div className="hc-rank-text">
|
||||
<p className="hc-rank-name">{rank.label}</p>
|
||||
<p className="hc-rank-progress-label">{nextLabel}</p>
|
||||
</div>
|
||||
<div className="hc-rank-right">
|
||||
{streak > 0 && (
|
||||
<span className="hc-streak-pill">🔥 {streak}</span>
|
||||
)}
|
||||
{summary.claimableNodes > 0 && (
|
||||
<span className="hc-chest-badge">
|
||||
📦 {summary.claimableNodes}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`hc-chevron${open ? " open" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hc-quests-wrap${open ? " open" : ""}`}>
|
||||
<div className="hc-quest-list">
|
||||
{activeQuests.length === 0 ? (
|
||||
<p className="hc-empty">⚓ All caught up — keep sailing!</p>
|
||||
) : (
|
||||
activeQuests.map(({ node, arc }) => {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(node.progress / node.requirement.target) * 100,
|
||||
),
|
||||
);
|
||||
const isClaimable = node.status === "claimable";
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="hc-quest-row"
|
||||
style={
|
||||
{ "--ac": arc.accentColor } as React.CSSProperties
|
||||
}
|
||||
onClick={() => !isClaimable && handleViewAll()}
|
||||
>
|
||||
<div
|
||||
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
|
||||
>
|
||||
{isClaimable ? "📦" : node.emoji}
|
||||
</div>
|
||||
<div className="hc-q-body">
|
||||
<p className="hc-q-name">{node.title}</p>
|
||||
{isClaimable ? (
|
||||
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
||||
) : (
|
||||
<p className="hc-q-sub">
|
||||
{node.progress}/{node.requirement.target}{" "}
|
||||
{node.requirement.label} · {pct}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isClaimable ? (
|
||||
<button
|
||||
className="hc-claim-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClaim(node, arc.id);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
) : (
|
||||
<ChevronRight size={14} color="#d1d5db" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="hc-map-link" onClick={handleViewAll}>
|
||||
<Map size={13} />
|
||||
View quest map
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claimingNode && (
|
||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
93
src/components/LevelBar.tsx
Normal file
93
src/components/LevelBar.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
|
||||
const STYLES = `
|
||||
.lb-wrap {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
background: white;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-radius: 100px;
|
||||
padding: 0.38rem 0.75rem 0.38rem 0.42rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
animation: lbIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
@keyframes lbIn {
|
||||
from { opacity:0; transform: scale(0.9) translateX(6px); }
|
||||
to { opacity:1; transform: scale(1) translateX(0); }
|
||||
}
|
||||
|
||||
/* Level bubble */
|
||||
.lb-bubble {
|
||||
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 2px 0 #5b21b644;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.7rem; font-weight: 900; color: white;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Bar track */
|
||||
.lb-track {
|
||||
width: 80px; height: 7px;
|
||||
background: #f3f4f6; border-radius: 100px; overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lb-fill {
|
||||
height: 100%; border-radius: 100px;
|
||||
background: linear-gradient(90deg, #a855f7, #f97316);
|
||||
transition: width 1s cubic-bezier(0.34,1.56,0.64,1);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.lb-fill::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: lbShimmer 2.2s ease-in-out 1s infinite;
|
||||
}
|
||||
@keyframes lbShimmer { to { transform: translateX(200%); } }
|
||||
|
||||
/* XP label */
|
||||
.lb-label {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.68rem; font-weight: 900;
|
||||
color: #a855f7; white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LevelBar = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const u = user as any;
|
||||
|
||||
const level = u?.current_level ?? u?.level ?? 1;
|
||||
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
||||
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||
const levelEnd =
|
||||
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||
|
||||
const levelRange = Math.max(levelEnd - levelStart, 1);
|
||||
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
|
||||
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
|
||||
|
||||
const [pct, setPct] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setPct(rawPct)),
|
||||
);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [rawPct]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
<div className="lb-wrap">
|
||||
<div className="lb-bubble">{level}</div>
|
||||
<div className="lb-track">
|
||||
<div className="lb-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="lb-label">{pct}%</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,26 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react";
|
||||
import { CheckCircle, Play, Search } from "lucide-react";
|
||||
import { api } from "../../utils/api";
|
||||
import type { PracticeSheet } from "../../types/sheet";
|
||||
import { formatStatus } from "../../lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SearchOverlay } from "../../components/SearchOverlay";
|
||||
import { PredictedScoreCard } from "../../components/PredictedScoreCard";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTrigger,
|
||||
} from "../../components/ui/drawer";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
import { QuestProgressCard } from "../../components/QuestProgressCard";
|
||||
|
||||
// somewhere in the Home JSX, above the sheets tabs:
|
||||
import { InfoHeader } from "../../components/InfoHeader";
|
||||
|
||||
// ─── Shared blob/dot background (same as break/results screens) ────────────────
|
||||
const DOTS = [
|
||||
@ -78,36 +64,6 @@ const STYLES = `
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.home-header-left { display:flex;align-items:center;gap:0.75rem; }
|
||||
.home-user-name {
|
||||
font-size: 1.1rem; font-weight: 900; color: #1e1b4b; line-height:1.1;
|
||||
}
|
||||
.home-user-role {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing:0.08em;
|
||||
text-transform: uppercase; color: #a855f7;
|
||||
}
|
||||
.home-header-right { display:flex;align-items:center;gap:0.6rem; }
|
||||
|
||||
/* Header action chips */
|
||||
.h-chip {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
background: white; border: 2.5px solid #f3f4f6;
|
||||
border-radius: 100px; padding: 0.5rem 0.9rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.06);
|
||||
cursor: pointer; font-size:0.85rem; font-weight:800;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.h-chip:hover { transform:translateY(-2px);box-shadow:0 6px 14px rgba(0,0,0,0.08); }
|
||||
.h-chip.streak { border-color:#fecaca; background:#fff5f5; color:#ef4444; }
|
||||
.h-chip.score { border-color:#d9f99d; background:#f7ffe4; color:#65a30d; }
|
||||
|
||||
/* ── Section titles ── */
|
||||
.h-section-title {
|
||||
font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
|
||||
@ -331,12 +287,11 @@ const TIPS = [
|
||||
];
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 2;
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
export const Home = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
const { userMetrics } = useExamConfigStore();
|
||||
|
||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||
@ -397,15 +352,8 @@ export const Home = () => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
};
|
||||
|
||||
const greeting =
|
||||
new Date().getHours() < 12
|
||||
? "Good morning"
|
||||
: new Date().getHours() < 17
|
||||
? "Good afternoon"
|
||||
: "Good evening";
|
||||
|
||||
return (
|
||||
<div className="home-screen pb-12">
|
||||
<div className="home-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
@ -436,56 +384,10 @@ export const Home = () => {
|
||||
|
||||
<div className="home-inner">
|
||||
{/* ── Header ── */}
|
||||
<header className="home-header">
|
||||
<div className="home-header-left">
|
||||
<Avatar style={{ width: 48, height: 48 }}>
|
||||
<AvatarImage src={user?.avatar_url} />
|
||||
<AvatarFallback
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
fontSize: "1.1rem",
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||
}}
|
||||
>
|
||||
{user?.name?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="home-user-name">
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"}
|
||||
</p>
|
||||
<p className="home-user-role">
|
||||
{user?.role === "STUDENT"
|
||||
? "Student"
|
||||
: user?.role === "ADMIN"
|
||||
? "Admin"
|
||||
: "Teacher"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-header-right">
|
||||
{/* Streak chip */}
|
||||
<div className="h-chip streak">
|
||||
<Flame size={18} style={{ fill: "#fca5a5" }} />
|
||||
<span>{userMetrics.streak}</span>
|
||||
</div>
|
||||
|
||||
{/* Score chip */}
|
||||
<Drawer direction="top">
|
||||
<DrawerTrigger asChild>
|
||||
<div className="h-chip score">
|
||||
<Gauge size={18} />
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<PredictedScoreCard />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</header>
|
||||
<InfoHeader
|
||||
mode="DEFAULT"
|
||||
onViewAll={() => navigate("/student/quests")}
|
||||
/>
|
||||
|
||||
{/* ── Search ── */}
|
||||
<div className="h-search-wrap h-anim h-anim-1">
|
||||
@ -499,7 +401,7 @@ export const Home = () => {
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<QuestProgressCard onViewAll={() => navigate("/student/quests")} />
|
||||
|
||||
{/* ── In progress ── */}
|
||||
<section className="h-anim h-anim-2">
|
||||
<p className="h-section-title">📌 Pick up where you left off</p>
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
import { LevelBar } from "../../components/LevelBar";
|
||||
import { InfoHeader } from "../../components/InfoHeader";
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" },
|
||||
@ -240,10 +242,9 @@ const MODE_CARDS = [
|
||||
|
||||
export const Practice = () => {
|
||||
const navigate = useNavigate();
|
||||
const userXp = useExamConfigStore.getState().userXp;
|
||||
|
||||
return (
|
||||
<div className="pr-screen pb-12">
|
||||
<div className="pr-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
@ -274,15 +275,7 @@ export const Practice = () => {
|
||||
|
||||
<div className="pr-inner">
|
||||
{/* ── Header ── */}
|
||||
<header className="pr-header">
|
||||
<div className="pr-logo-btn">
|
||||
<BookOpen size={20} color="white" />
|
||||
</div>
|
||||
<div className="pr-xp-chip">
|
||||
<span>⚡ {userXp} XP</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<InfoHeader mode="LEVEL" />
|
||||
{/* ── Hero banner ── */}
|
||||
<div className="pr-hero pr-anim pr-anim-1">
|
||||
<div className="pr-hero-icon-bg">
|
||||
|
||||
@ -4,6 +4,7 @@ import type { QuestArc, QuestNode, NodeStatus } from "../../types/quest";
|
||||
import { useQuestStore, getQuestSummary } from "../../stores/useQuestStore";
|
||||
import { QuestNodeModal } from "../../components/QuestNodeModal";
|
||||
import { ChestOpenModal } from "../../components/ChestOpenModal";
|
||||
import { InfoHeader } from "../../components/InfoHeader";
|
||||
|
||||
// ─── Map geometry (all in SVG user-units, viewBox width = 390) ───────────────
|
||||
const VW = 390; // viewBox width — matches typical phone width
|
||||
@ -784,9 +785,9 @@ export const QuestMap = () => {
|
||||
|
||||
{/* Header */}
|
||||
<div className="qm-header">
|
||||
<p className="qm-page-title">🏴☠️ Treasure Quests</p>
|
||||
<p className="qm-page-sub">Chart your course across the Grand Line</p>
|
||||
<div className="qm-stats-strip">
|
||||
{/* <p className="qm-page-title">🏴☠️ Treasure Quests</p>
|
||||
<p className="qm-page-sub">Chart your course across the Grand Line</p> */}
|
||||
{/* <div className="qm-stats-strip">
|
||||
{[
|
||||
{
|
||||
e: "⚓",
|
||||
@ -807,7 +808,8 @@ export const QuestMap = () => {
|
||||
<span className="qm-stat-label">{s.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
<InfoHeader mode="QUEST_EXTENDED" />
|
||||
<div className="qm-arc-tabs">
|
||||
{arcs.map((a) => (
|
||||
<button
|
||||
|
||||
@ -26,6 +26,10 @@ export interface User {
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
joined_at: string;
|
||||
last_active: string;
|
||||
total_xp: number;
|
||||
current_level: number;
|
||||
next_level_threshold: number;
|
||||
current_level_start: number;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
Reference in New Issue
Block a user