From c7f01839569908de42ee41a2e220985dbb3b370c Mon Sep 17 00:00:00 2001 From: shafin-r Date: Fri, 27 Feb 2026 02:18:47 +0600 Subject: [PATCH] feat(ui): add infoheader component, improve quest map visuals --- src/components/InfoHeader.tsx | 820 +++++++++++++++++++++++++++++++++ src/components/LevelBar.tsx | 93 ++++ src/pages/student/Home.tsx | 116 +---- src/pages/student/Practice.tsx | 15 +- src/pages/student/QuestMap.tsx | 10 +- src/utils/api.ts | 4 + 6 files changed, 936 insertions(+), 122 deletions(-) create mode 100644 src/components/InfoHeader.tsx create mode 100644 src/components/LevelBar.tsx diff --git a/src/components/InfoHeader.tsx b/src/components/InfoHeader.tsx new file mode 100644 index 0000000..181b9af --- /dev/null +++ b/src/components/InfoHeader.tsx @@ -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(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 ( +
+ {/* Header */} +
+ ⚓ Crew Rank + {earnedXP.toLocaleString()} XP +
+ + {/* Current rank label */} +
+ + {current.emoji} {current.label} + + + {nextLabel} + +
+ + {/* Scrollable rank track */} +
+
+ {/* Baseline dim line */} +
+ + {/* Gold progress line */} +
+ + {/* Ship marker */} +
+ + ⛵ + +
+
+ + {/* Rank nodes */} + {ladder.map((r, i) => { + const state = + i < currentIdx + ? "reached" + : i === currentIdx + ? "current" + : "locked"; + return ( +
+
{r.emoji}
+
+ + {r.label} + + + {r.xpRequired === 0 + ? "Start" + : `${r.xpRequired.toLocaleString()} XP`} + +
+
+ ); + })} +
+
+ + {/* Footer */} + {/*
+ + View quest map +
*/} +
+ ); +}; + +// ─── 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 ( + <> + + + {claimingNode && ( + + )} + + ); + } + + return ( + <> + + +
+ {/* Identity — DEFAULT only */} + {showIdentity && ( + <> +
+
+
+ + + + {user?.name?.slice(0, 1)} + + +
{level}
+
+
+

+ Good {timeLabel}, {firstName} 👋 +

+

{roleLabel}

+
+
+ + + + + + + + +
+
+ + )} + + {/* XP bar — DEFAULT + LEVEL */} + {showLevel && ( +
+ Lv {level} +
+
+
+
+
+ {totalXP.toLocaleString()} XP + {xpToGo.toLocaleString()} to go +
+
+
+ )} + + {/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */} + {showQuestCompact && ( + <> +
setOpen((o) => !o)}> + {rank.emoji} +
+

{rank.label}

+

{nextLabel}

+
+
+ {streak > 0 && ( + 🔥 {streak} + )} + {summary.claimableNodes > 0 && ( + + 📦 {summary.claimableNodes} + + )} + +
+
+ +
+
+ {activeQuests.length === 0 ? ( +

⚓ All caught up — keep sailing!

+ ) : ( + activeQuests.map(({ node, arc }) => { + const pct = Math.min( + 100, + Math.round( + (node.progress / node.requirement.target) * 100, + ), + ); + const isClaimable = node.status === "claimable"; + return ( +
!isClaimable && handleViewAll()} + > +
+ {isClaimable ? "📦" : node.emoji} +
+
+

{node.title}

+ {isClaimable ? ( +

✨ Ready to claim!

+ ) : ( +

+ {node.progress}/{node.requirement.target}{" "} + {node.requirement.label} · {pct}% +

+ )} +
+ {isClaimable ? ( + + ) : ( + + )} +
+ ); + }) + )} +
+
+ + View quest map +
+
+ + )} +
+ + {claimingNode && ( + + )} + + ); +}; diff --git a/src/components/LevelBar.tsx b/src/components/LevelBar.tsx new file mode 100644 index 0000000..339cbf9 --- /dev/null +++ b/src/components/LevelBar.tsx @@ -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 ( + <> + +
+
{level}
+
+
+
+ {pct}% +
+ + ); +}; diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index fd2a2da..29b03ec 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -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([]); const [notStartedSheets, setNotStartedSheets] = useState([]); @@ -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 ( -
+
{/* Blobs */} @@ -436,56 +384,10 @@ export const Home = () => {
{/* ── Header ── */} -
-
- - - - {user?.name?.slice(0, 1)} - - -
-

- {greeting}, {user?.name?.split(" ")[0] || "Student"} -

-

- {user?.role === "STUDENT" - ? "Student" - : user?.role === "ADMIN" - ? "Admin" - : "Teacher"} -

-
-
- -
- {/* Streak chip */} -
- - {userMetrics.streak} -
- - {/* Score chip */} - - -
- -
-
- - - -
-
-
+ navigate("/student/quests")} + /> {/* ── Search ── */}
@@ -499,7 +401,7 @@ export const Home = () => { onFocus={() => setIsSearchOpen(true)} />
- navigate("/student/quests")} /> + {/* ── In progress ── */}

📌 Pick up where you left off

diff --git a/src/pages/student/Practice.tsx b/src/pages/student/Practice.tsx index 51b518b..3b6e3a5 100644 --- a/src/pages/student/Practice.tsx +++ b/src/pages/student/Practice.tsx @@ -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 ( -
+
{/* Blobs */} @@ -274,15 +275,7 @@ export const Practice = () => {
{/* ── Header ── */} -
-
- -
-
- ⚡ {userXp} XP -
-
- + {/* ── Hero banner ── */}
diff --git a/src/pages/student/QuestMap.tsx b/src/pages/student/QuestMap.tsx index 1137d05..3ef97bb 100644 --- a/src/pages/student/QuestMap.tsx +++ b/src/pages/student/QuestMap.tsx @@ -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 */}
-

🏴‍☠️ Treasure Quests

-

Chart your course across the Grand Line

-
+ {/*

🏴‍☠️ Treasure Quests

+

Chart your course across the Grand Line

*/} + {/*
{[ { e: "⚓", @@ -807,7 +808,8 @@ export const QuestMap = () => { {s.l}
))} -
+
*/} +
{arcs.map((a) => (