web #1

Merged
shafin808s merged 35 commits from web into main 2026-03-11 20:41:06 +00:00
30 changed files with 8574 additions and 2569 deletions
Showing only changes of commit 894863c196 - Show all commits

View File

@ -157,6 +157,15 @@ const STYLES = `
.rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; } .rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
@keyframes rwShimmer { 0%,100%{opacity:1;}50%{opacity:0.4;} } @keyframes rwShimmer { 0%,100%{opacity:1;}50%{opacity:0.4;} }
/* ── Empty state ── */
.rw-empty {
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:3.5rem 1rem;gap:0.5rem;text-align:center;
}
.rw-empty-emoji { font-size:3rem;line-height:1; }
.rw-empty-title { font-size:1rem;font-weight:800;color:#6b7280;margin:0; }
.rw-empty-sub { font-size:0.82rem;font-weight:600;color:#9ca3af;margin:0; }
/* ── Floating island pill ── */ /* ── Floating island pill ── */
.rw-island-wrap { .rw-island-wrap {
position:fixed; position:fixed;
@ -224,11 +233,13 @@ const STYLES = `
user-select:none; user-select:none;
-webkit-tap-highlight-color:transparent; -webkit-tap-highlight-color:transparent;
justify-content:space-between; justify-content:space-between;
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s ease; transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s ease,opacity 0.2s ease;
box-sizing:border-box; box-sizing:border-box;
} }
.rw-island-pill:active { transform:scale(0.93); } .rw-island-pill:active { transform:scale(0.93); }
.rw-island-pill.open { box-shadow:0 10px 28px rgba(124,58,237,0.4),0 4px 10px rgba(124,58,237,0.25),inset 0 1px 0 rgba(255,255,255,0.2); } .rw-island-pill.open { box-shadow:0 10px 28px rgba(124,58,237,0.4),0 4px 10px rgba(124,58,237,0.25),inset 0 1px 0 rgba(255,255,255,0.2); }
/* Visually dim the pill when there's no user rank data to expand */
.rw-island-pill.no-data { opacity:0.65;cursor:default; }
.rw-island-left { display:flex;align-items:center;gap:0.65rem; } .rw-island-left { display:flex;align-items:center;gap:0.65rem; }
@ -281,10 +292,9 @@ type TabId = (typeof TABS)[number]["id"];
const trophies = [firstTrophy, secondTrophy, thirdTrophy]; const trophies = [firstTrophy, secondTrophy, thirdTrophy];
// ── Score helpers ───────────────────────────────────────────────────────────── // ── Score helpers ─────────────────────────────────────────────────────────────
// Tab ID is "streaks" (plural) — match exactly. Try multiple plausible field names.
const getScore = (u: Record<string, unknown>, tab: TabId): number | string => { const getScore = (u: Record<string, unknown>, tab: TabId): number | string => {
if (tab === "xp" || tab === "questions") if (tab === "xp") return (u.score ?? "—") as number | string;
return (u.score ?? u.total_xp ?? u.xp ?? "—") as number | string; if (tab === "questions") return (u.score ?? "—") as number | string;
if (tab === "streaks") return (u.streak ?? "—") as number | string; if (tab === "streaks") return (u.streak ?? "—") as number | string;
return "—"; return "—";
@ -299,12 +309,11 @@ const getUserScore = (
}; };
// ── Island card config — driven by active tab ───────────────────────────────── // ── Island card config — driven by active tab ─────────────────────────────────
// Each tab shows 3 contextually relevant stats in the expanded card.
const getIslandStats = ( const getIslandStats = (
ur: Record<string, unknown> | undefined, ur: Record<string, unknown> | undefined,
tab: TabId, tab: TabId,
) => { ) => {
const rank = `#${ur?.rank ?? "—"}`; const rank = ur?.rank != null ? `#${ur.rank}` : "—";
const xp = String(ur?.score ?? ur?.total_xp ?? "—"); const xp = String(ur?.score ?? ur?.total_xp ?? "—");
const level = String(ur?.current_level ?? ur?.level ?? "—"); const level = String(ur?.current_level ?? ur?.level ?? "—");
const qs = String( const qs = String(
@ -350,6 +359,14 @@ const SkeletonRows = () => (
</div> </div>
); );
const EmptyState = () => (
<div className="rw-empty">
<span className="rw-empty-emoji">🏜</span>
<p className="rw-empty-title">No entries yet</p>
<p className="rw-empty-sub">Be the first on the board!</p>
</div>
);
export const Rewards = () => { export const Rewards = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [time, setTime] = useState("today"); const [time, setTime] = useState("today");
@ -383,10 +400,11 @@ export const Rewards = () => {
TIME_MAP[time] ?? "daily", TIME_MAP[time] ?? "daily",
); );
setLeaderboard(response); setLeaderboard(response);
// ✅ FIX 1: Guard against null user_rank before accessing its properties
setUserMetrics({ setUserMetrics({
xp: response.user_rank.score, xp: response.user_rank?.score ?? 0,
questions: 0, questions: 0,
streak: response.user_rank.streak, streak: response.user_rank?.streak ?? 0,
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -415,9 +433,14 @@ export const Rewards = () => {
<Flame size={size} color="rgba(255,255,255,0.85)" /> <Flame size={size} color="rgba(255,255,255,0.85)" />
); );
const ur = leaderboard?.user_rank as Record<string, unknown> | undefined; // ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works
const ur = (leaderboard?.user_rank ?? undefined) as
| Record<string, unknown>
| undefined;
const islandStats = getIslandStats(ur, activeTab); const islandStats = getIslandStats(ur, activeTab);
const userMetric = getUserScore(ur, activeTab); const userMetric = getUserScore(ur, activeTab);
const hasUserRank = ur != null;
const formatTimeLabel = (t: string) => const formatTimeLabel = (t: string) =>
({ today: "Today", week: "Week", month: "Month", alltime: "All" })[t] ?? t; ({ today: "Today", week: "Week", month: "Month", alltime: "All" })[t] ?? t;
@ -466,8 +489,15 @@ export const Rewards = () => {
}} }}
/> />
) : ( ) : (
// ✅ FIX 3: Show a sensible message when user has no rank yet
<p className="rw-rank-text"> <p className="rw-rank-text">
You're <span>#{ur?.rank ?? "—"}</span> — keep grinding! {hasUserRank ? (
<>
You're <span>#{ur.rank}</span> — keep grinding!
</>
) : (
"No rank yet — start answering questions!"
)}
</p> </p>
)} )}
</header> </header>
@ -523,8 +553,11 @@ export const Rewards = () => {
> >
{loading ? ( {loading ? (
<SkeletonRows /> <SkeletonRows />
) : !leaderboard?.top_users?.length ? (
// ✅ FIX 4: Show empty state when top_users is empty or missing
<EmptyState />
) : ( ) : (
leaderboard?.top_users?.map((u, index) => { leaderboard.top_users.map((u, index) => {
const row = u as Record<string, unknown>; const row = u as Record<string, unknown>;
const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`; const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
const score = getScore(row, activeTab); const score = getScore(row, activeTab);
@ -587,10 +620,19 @@ export const Rewards = () => {
))} ))}
</div> </div>
{/* Pill — metric updates with active tab */} {/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */}
<div <div
className={`rw-island-pill${islandOpen ? " open" : ""}`} className={[
onClick={() => !loading && setIslandOpen((o) => !o)} "rw-island-pill",
islandOpen ? "open" : "",
!hasUserRank ? "no-data" : "",
]
.join(" ")
.trim()}
onClick={() => {
// Only allow expanding if not loading and user rank data exists
if (!loading && hasUserRank) setIslandOpen((o) => !o);
}}
> >
<div className="rw-island-left"> <div className="rw-island-left">
<div className="rw-island-avatar"> <div className="rw-island-avatar">
@ -603,7 +645,11 @@ export const Rewards = () => {
<div className="rw-island-info"> <div className="rw-island-info">
<span className="rw-island-you">You</span> <span className="rw-island-you">You</span>
<span className="rw-island-name"> <span className="rw-island-name">
{loading ? "Loading" : String(ur?.name ?? user?.name ?? "")} {loading
? "Loading…"
: hasUserRank
? String(ur.name ?? user?.name ?? "—")
: (user?.name ?? "Not ranked")}
</span> </span>
</div> </div>
</div> </div>
@ -614,7 +660,8 @@ export const Rewards = () => {
{metricIconWhite()} {metricIconWhite()}
</div> </div>
<div className="rw-island-chevron"> <div className="rw-island-chevron">
<ChevronDown size={13} color="white" /> {/* Hide chevron when there's nothing to expand */}
{hasUserRank && <ChevronDown size={13} color="white" />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -261,8 +261,8 @@ const TARGETED_XP = 15;
const TARGETED_SCORE = 15; const TARGETED_SCORE = 15;
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => { const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const { userXp, setUserXp } = useExamConfigStore(); const { userMetrics, setUserMetrics } = useExamConfigStore();
const previousXP = userXp ?? 0; const previousXP = userMetrics.xp ?? 0;
const gainedXP = TARGETED_XP; const gainedXP = TARGETED_XP;
const levelMinXP = Math.floor(previousXP / 100) * 100; const levelMinXP = Math.floor(previousXP / 100) * 100;
const levelMaxXP = levelMinXP + 100; const levelMaxXP = levelMinXP + 100;
@ -270,7 +270,11 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const displayXP = useCountUp(gainedXP); const displayXP = useCountUp(gainedXP);
useEffect(() => { useEffect(() => {
setUserXp(previousXP); setUserMetrics({
xp: previousXP,
questions: 0,
streak: 0,
});
}, []); }, []);
return ( return (
@ -393,11 +397,16 @@ export const Results = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const results = useResults((s) => s.results); const results = useResults((s) => s.results);
const clearResults = useResults((s) => s.clearResults); const clearResults = useResults((s) => s.clearResults);
const { setUserXp, payload } = useExamConfigStore(); const { setUserMetrics, payload } = useExamConfigStore();
const isTargeted = payload?.mode === "TARGETED"; const isTargeted = payload?.mode === "TARGETED";
useEffect(() => { useEffect(() => {
if (results) setUserXp(results.total_xp); if (results)
setUserMetrics({
xp: results.total_xp,
questions: results.correct_count,
streak: 0,
});
}, [results]); }, [results]);
function handleFinishExam() { function handleFinishExam() {