web #1
@ -157,6 +157,15 @@ const STYLES = `
|
||||
.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;} }
|
||||
|
||||
/* ── 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 ── */
|
||||
.rw-island-wrap {
|
||||
position:fixed;
|
||||
@ -224,11 +233,13 @@ const STYLES = `
|
||||
user-select:none;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
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;
|
||||
}
|
||||
.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); }
|
||||
/* 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; }
|
||||
|
||||
@ -281,10 +292,9 @@ type TabId = (typeof TABS)[number]["id"];
|
||||
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
|
||||
|
||||
// ── Score helpers ─────────────────────────────────────────────────────────────
|
||||
// Tab ID is "streaks" (plural) — match exactly. Try multiple plausible field names.
|
||||
const getScore = (u: Record<string, unknown>, tab: TabId): number | string => {
|
||||
if (tab === "xp" || tab === "questions")
|
||||
return (u.score ?? u.total_xp ?? u.xp ?? "—") as number | string;
|
||||
if (tab === "xp") return (u.score ?? "—") as number | string;
|
||||
if (tab === "questions") return (u.score ?? "—") as number | string;
|
||||
if (tab === "streaks") return (u.streak ?? "—") as number | string;
|
||||
|
||||
return "—";
|
||||
@ -299,12 +309,11 @@ const getUserScore = (
|
||||
};
|
||||
|
||||
// ── Island card config — driven by active tab ─────────────────────────────────
|
||||
// Each tab shows 3 contextually relevant stats in the expanded card.
|
||||
const getIslandStats = (
|
||||
ur: Record<string, unknown> | undefined,
|
||||
tab: TabId,
|
||||
) => {
|
||||
const rank = `#${ur?.rank ?? "—"}`;
|
||||
const rank = ur?.rank != null ? `#${ur.rank}` : "—";
|
||||
const xp = String(ur?.score ?? ur?.total_xp ?? "—");
|
||||
const level = String(ur?.current_level ?? ur?.level ?? "—");
|
||||
const qs = String(
|
||||
@ -350,6 +359,14 @@ const SkeletonRows = () => (
|
||||
</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 = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [time, setTime] = useState("today");
|
||||
@ -383,10 +400,11 @@ export const Rewards = () => {
|
||||
TIME_MAP[time] ?? "daily",
|
||||
);
|
||||
setLeaderboard(response);
|
||||
// ✅ FIX 1: Guard against null user_rank before accessing its properties
|
||||
setUserMetrics({
|
||||
xp: response.user_rank.score,
|
||||
xp: response.user_rank?.score ?? 0,
|
||||
questions: 0,
|
||||
streak: response.user_rank.streak,
|
||||
streak: response.user_rank?.streak ?? 0,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -415,9 +433,14 @@ export const Rewards = () => {
|
||||
<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 userMetric = getUserScore(ur, activeTab);
|
||||
const hasUserRank = ur != null;
|
||||
|
||||
const formatTimeLabel = (t: string) =>
|
||||
({ 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">
|
||||
You're <span>#{ur?.rank ?? "—"}</span> — keep grinding!
|
||||
{hasUserRank ? (
|
||||
<>
|
||||
You're <span>#{ur.rank}</span> — keep grinding!
|
||||
</>
|
||||
) : (
|
||||
"No rank yet — start answering questions!"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
@ -523,8 +553,11 @@ export const Rewards = () => {
|
||||
>
|
||||
{loading ? (
|
||||
<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 rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
|
||||
const score = getScore(row, activeTab);
|
||||
@ -587,10 +620,19 @@ export const Rewards = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pill — metric updates with active tab */}
|
||||
{/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */}
|
||||
<div
|
||||
className={`rw-island-pill${islandOpen ? " open" : ""}`}
|
||||
onClick={() => !loading && setIslandOpen((o) => !o)}
|
||||
className={[
|
||||
"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-avatar">
|
||||
@ -603,7 +645,11 @@ export const Rewards = () => {
|
||||
<div className="rw-island-info">
|
||||
<span className="rw-island-you">You</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -614,7 +660,8 @@ export const Rewards = () => {
|
||||
{metricIconWhite()}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -261,8 +261,8 @@ const TARGETED_XP = 15;
|
||||
const TARGETED_SCORE = 15;
|
||||
|
||||
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const { userXp, setUserXp } = useExamConfigStore();
|
||||
const previousXP = userXp ?? 0;
|
||||
const { userMetrics, setUserMetrics } = useExamConfigStore();
|
||||
const previousXP = userMetrics.xp ?? 0;
|
||||
const gainedXP = TARGETED_XP;
|
||||
const levelMinXP = Math.floor(previousXP / 100) * 100;
|
||||
const levelMaxXP = levelMinXP + 100;
|
||||
@ -270,7 +270,11 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const displayXP = useCountUp(gainedXP);
|
||||
|
||||
useEffect(() => {
|
||||
setUserXp(previousXP);
|
||||
setUserMetrics({
|
||||
xp: previousXP,
|
||||
questions: 0,
|
||||
streak: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@ -393,11 +397,16 @@ export const Results = () => {
|
||||
const navigate = useNavigate();
|
||||
const results = useResults((s) => s.results);
|
||||
const clearResults = useResults((s) => s.clearResults);
|
||||
const { setUserXp, payload } = useExamConfigStore();
|
||||
const { setUserMetrics, payload } = useExamConfigStore();
|
||||
const isTargeted = payload?.mode === "TARGETED";
|
||||
|
||||
useEffect(() => {
|
||||
if (results) setUserXp(results.total_xp);
|
||||
if (results)
|
||||
setUserMetrics({
|
||||
xp: results.total_xp,
|
||||
questions: results.correct_count,
|
||||
streak: 0,
|
||||
});
|
||||
}, [results]);
|
||||
|
||||
function handleFinishExam() {
|
||||
|
||||
Reference in New Issue
Block a user