fix(rewards): fix null state in rewards screen
This commit is contained in:
@ -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>
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user