TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useEffect, useState } from "react";4import { Gift, LifeBuoy, Target, Trophy, Lock, Check, Flame, Star, Sparkles, Zap } from "lucide-react";5import type { AchievementView, DailyRewardStatus, MissionView, RescueStatus } from "@spinza/shared";6import { RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, formatSC } from "@spinza/shared";7import { api } from "@/lib/api";8import { toast, useSession } from "@/lib/store";9import { useApi } from "@/lib/use-api";10import { cn, countdown } from "@/lib/utils";11import { Button, Card, Credits, Progress, Skeleton, SectionHead, Empty, Badge } from "@/components/ui";12import { AppShell } from "@/components/shell/app-shell";13import { RequireAuth } from "@/components/shell/require-auth";14import { ApiErrorState } from "@/components/shell/api-error";1516/* ---------------------------------------------------------------- helpers */1718function useTick(active: boolean) {19 const [, set] = useState(0);20 useEffect(() => {21 if (!active) return;22 const t = setInterval(() => set((n) => n + 1), 1000);23 return () => clearInterval(t);24 }, [active]);25}2627/* ------------------------------------------------------------ daily card */2829interface DailyClaim {30 amount: number;31 streakDay: number;32 balance: number;33 nextAvailableAt: string;34 xp: { xp: number; level: number } | null;35}3637function DailyCard() {38 const { data, error, reload, setData } = useApi<DailyRewardStatus>("/api/rewards/daily");39 const setBalance = useSession((s) => s.setBalance);40 const setUserXp = useSession((s) => s.setUserXp);41 const [busy, setBusy] = useState(false);42 useTick(!!data && !data.available);4344 const claim = async () => {45 if (busy) return;46 setBusy(true);47 try {48 const res = await api<DailyClaim>("/api/rewards/daily/claim", { method: "POST" });49 setBalance(res.balance);50 if (res.xp) setUserXp(res.xp.xp, res.xp.level);51 toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: `Day ${res.streakDay} claimed. Come back tomorrow to keep the streak.`, tone: "credit" });52 setData((d) => (d ? { ...d, available: false, claimedToday: true, streakDay: res.streakDay, nextAvailableAt: res.nextAvailableAt } : d));53 } catch (e) {54 toast({ title: "Could not claim", description: e instanceof Error ? e.message : undefined, tone: "danger" });55 void reload();56 } finally {57 setBusy(false);58 }59 };6061 if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;62 if (!data) {63 return (64 <Card className="p-5">65 <Skeleton className="h-5 w-40" />66 <div className="mt-5 grid grid-cols-7 gap-2">67 {Array.from({ length: 7 }).map((_, i) => (68 <Skeleton key={i} className="h-20" />69 ))}70 </div>71 <Skeleton className="mt-5 h-12 w-full" />72 </Card>73 );74 }7576 // Day the next claim will land on (or the last claimed day when waiting).77 const targetDay = data.available ? Math.min((data.streakDay % data.schedule.length) + 1, data.schedule.length) : data.streakDay;7879 return (80 <Card className={cn("relative overflow-hidden p-5 sm:p-6", data.available && "border-accent/40 shadow-glow")}>81 <div className="flex items-start justify-between gap-4">82 <div className="flex items-center gap-3">83 <span className={cn("grid h-11 w-11 place-items-center rounded-md", data.available ? "bg-accent text-bg" : "metal text-accent-2")}>84 <Gift className="h-5 w-5" />85 </span>86 <div>87 <h2 className="text-lg font-semibold tracking-tight">Daily reward</h2>88 <p className="text-[13px] text-fg-3">Claim once a day. Seven days in a row unlock the big one.</p>89 </div>90 </div>91 {data.streakDay > 0 ? (92 <span className="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2.5 py-1 text-[12px] font-bold text-fg-2">93 <Flame className="h-3.5 w-3.5 text-accent" /> {data.streakDay}-day streak94 </span>95 ) : null}96 </div>9798 <ol className="mt-5 grid grid-cols-7 gap-1.5 sm:gap-2" aria-label="Seven-day schedule">99 {data.schedule.map((amt, i) => {100 const day = i + 1;101 const claimed = day <= data.streakDay && !(data.available && day === targetDay);102 const isNext = data.available && day === targetDay;103 return (104 <li key={day} className={cn("flex flex-col items-center justify-between rounded-md border px-1 py-2.5 text-center", isNext ? "border-accent bg-accent-soft" : claimed ? "border-success/30 bg-success/5" : "border-line bg-surface", day === 7 && !isNext && !claimed && "border-line-2")} aria-current={isNext ? "step" : undefined}>105 <span className="text-[10px] font-bold uppercase tracking-wider text-fg-3">Day {day}</span>106 <span className={cn("my-1.5 grid h-7 w-7 place-items-center rounded-full", claimed ? "bg-success text-bg" : isNext ? "bg-accent text-bg" : "bg-surface-2 text-fg-4")}>{claimed ? <Check className="h-4 w-4" /> : day === 7 ? <Star className="h-3.5 w-3.5" /> : <Gift className="h-3.5 w-3.5" />}</span>107 <span className={cn("text-[11px] font-semibold tabular sm:text-[12px]", claimed ? "text-fg-3" : "text-credit")}>{amt >= 1000 ? `${(amt / 1000).toFixed(amt % 1000 ? 2 : 0).replace(/\.?0+$/, "")}K` : amt}</span>108 </li>109 );110 })}111 </ol>112113 <div className="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">114 <div className="text-sm text-fg-2">115 {data.available ? (116 <>117 Ready now: <Credits amount={data.nextAmount} size="md" />118 </>119 ) : (120 <>121 Next reward <Credits amount={data.nextAmount} size="sm" /> in <span className="font-semibold tabular text-fg">{countdown(data.nextAvailableAt)}</span>122 </>123 )}124 </div>125 <Button variant="accent" size="lg" onClick={claim} disabled={!data.available} loading={busy} className="sm:min-w-[180px]">126 {data.available ? `Claim ${formatSC(data.nextAmount)}` : "Claimed today"}127 </Button>128 </div>129 </Card>130 );131}132133/* ----------------------------------------------------------- rescue card */134135function RescueCard() {136 const { data, error, reload, setData } = useApi<RescueStatus>("/api/rewards/rescue");137 const setBalance = useSession((s) => s.setBalance);138 const balance = useSession((s) => s.wallet?.balance ?? 0);139 const [busy, setBusy] = useState(false);140 useTick(!!data?.nextAvailableAt);141142 const claim = async () => {143 if (busy) return;144 setBusy(true);145 try {146 const res = await api<{ amount: number; balance: number; nextAvailableAt: string }>("/api/rewards/rescue/claim", { method: "POST" });147 setBalance(res.balance);148 toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: "Rescue credits added. Back in the game.", tone: "credit" });149 setData((d) => (d ? { ...d, eligible: false, balance: res.balance, nextAvailableAt: res.nextAvailableAt } : d));150 } catch (e) {151 toast({ title: "Not available", description: e instanceof Error ? e.message : undefined, tone: "danger" });152 void reload();153 } finally {154 setBusy(false);155 }156 };157158 if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;159 const amount = data?.amount ?? RESCUE_CREDITS_AMOUNT;160 return (161 <Card className={cn("p-5 sm:p-6", data?.eligible && "border-success/40")}>162 <div className="flex items-center gap-3">163 <span className={cn("grid h-11 w-11 place-items-center rounded-md", data?.eligible ? "bg-success text-bg" : "metal text-fg-2")}>164 <LifeBuoy className="h-5 w-5" />165 </span>166 <div>167 <h2 className="text-lg font-semibold tracking-tight">Rescue credits</h2>168 <p className="text-[13px] text-fg-3">169 {formatSC(amount)} every {RESCUE_CREDITS_COOLDOWN_HOURS} hours whenever you reach 0 SC.170 </p>171 </div>172 </div>173 <p className="mt-4 text-sm leading-relaxed text-fg-2">Spinza Credits are fictional, so nobody ever gets stuck. If your balance hits zero, claim a free top-up here. The cooldown resets {RESCUE_CREDITS_COOLDOWN_HOURS} hours after each rescue.</p>174 <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">175 <div className="text-sm text-fg-3">176 {!data ? (177 <Skeleton className="h-4 w-40" />178 ) : data.eligible ? (179 <span className="text-success">Available now</span>180 ) : data.nextAvailableAt ? (181 <>182 Recharging · ready in <span className="font-semibold tabular text-fg">{countdown(data.nextAvailableAt)}</span>183 </>184 ) : (185 <>186 Balance <Credits amount={balance} size="sm" /> — unlocks at 0 SC187 </>188 )}189 </div>190 <Button variant={data?.eligible ? "primary" : "secondary"} onClick={claim} disabled={!data?.eligible} loading={busy}>191 {data?.eligible ? `Claim ${formatSC(amount)}` : "Not needed yet"}192 </Button>193 </div>194 </Card>195 );196}197198/* ------------------------------------------------------------ level card */199200function LevelCard() {201 const user = useSession((s) => s.user);202 if (!user) return null;203 const max = user.xpForNext || 1;204 const pct = user.xpForNext ? user.xpIntoLevel / user.xpForNext : 1;205 return (206 <Card className="p-5 sm:p-6">207 <div className="flex items-center gap-3">208 <span className="grid h-11 w-11 place-items-center rounded-md bg-[linear-gradient(180deg,#e8cf8f,#c9a961)] text-bg">209 <Zap className="h-5 w-5" />210 </span>211 <div className="flex-1">212 <div className="flex items-baseline justify-between">213 <h2 className="text-lg font-semibold tracking-tight">Level {user.level}</h2>214 <span className="text-[12px] tabular text-fg-3">{user.xp.toLocaleString("en-US")} XP total</span>215 </div>216 <p className="text-[13px] text-fg-3">Every spin earns XP. Level-ups grant bonus credits.</p>217 </div>218 </div>219 <div className="mt-4">220 <Progress value={user.xpIntoLevel} max={max} />221 <div className="mt-2 flex justify-between text-[12px] tabular text-fg-3">222 <span>{user.xpIntoLevel.toLocaleString("en-US")} XP</span>223 <span>{user.xpForNext ? `${(user.xpForNext - user.xpIntoLevel).toLocaleString("en-US")} XP to level ${user.level + 1}` : "Max level reached"}</span>224 </div>225 <div className="mt-1 text-right text-[11px] text-fg-4">{Math.round(pct * 100)}%</div>226 </div>227 </Card>228 );229}230231/* --------------------------------------------------------------- missions */232233function Missions() {234 const { data, error, loading, reload } = useApi<{ missions: MissionView[]; enabled: boolean }>("/api/missions");235 useTick(!!data);236 if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;237 if (loading && !data) {238 return (239 <div className="grid gap-3 md:grid-cols-2">240 {Array.from({ length: 4 }).map((_, i) => (241 <Skeleton key={i} className="h-28 rounded-lg" />242 ))}243 </div>244 );245 }246 const missions = data?.missions ?? [];247 if (!data?.enabled) return <Empty title="Missions are paused" description="Daily and weekly missions will return shortly." icon={<Target className="h-5 w-5" />} />;248 if (!missions.length) return <Empty title="No missions right now" description="Check back soon for new daily and weekly challenges." icon={<Target className="h-5 w-5" />} />;249250 const groups: { period: "daily" | "weekly"; label: string; items: MissionView[] }[] = [251 { period: "daily", label: "Daily", items: missions.filter((m) => m.period === "daily") },252 { period: "weekly", label: "Weekly", items: missions.filter((m) => m.period === "weekly") },253 ].filter((g) => g.items.length) as { period: "daily" | "weekly"; label: string; items: MissionView[] }[];254255 return (256 <div className="space-y-6">257 {groups.map((g) => (258 <div key={g.period}>259 <div className="mb-2 flex items-center justify-between text-[12px] text-fg-3">260 <span className="eyebrow">{g.label}</span>261 <span className="tabular">Resets in {countdown(g.items[0].expiresAt)}</span>262 </div>263 <div className="grid gap-3 md:grid-cols-2">264 {g.items.map((m) => {265 const done = !!m.completedAt;266 return (267 <Card key={m.key} className={cn("p-4", done && "border-success/30")}>268 <div className="flex items-start justify-between gap-3">269 <div className="min-w-0">270 <div className="flex items-center gap-2">271 <h3 className="truncate text-[15px] font-semibold tracking-tight">{m.name}</h3>272 {done ? <Badge tone="success">Done</Badge> : null}273 </div>274 <p className="mt-0.5 text-[13px] text-fg-3">{m.description}</p>275 </div>276 <div className="shrink-0 text-right">277 <Credits amount={m.rewardCredits} size="sm" sign />278 <div className="text-[11px] text-fg-3">+{m.rewardXp} XP</div>279 </div>280 </div>281 <div className="mt-3 flex items-center gap-3">282 <Progress value={m.progress} max={m.target} tone={done ? "success" : "accent"} className="flex-1" />283 <span className="text-[12px] tabular text-fg-2">284 {m.progress.toLocaleString("en-US")}/{m.target.toLocaleString("en-US")}285 </span>286 </div>287 </Card>288 );289 })}290 </div>291 </div>292 ))}293 <p className="text-[12px] text-fg-4">Mission rewards are credited automatically the moment a mission completes.</p>294 </div>295 );296}297298/* ----------------------------------------------------------- achievements */299300function Achievements() {301 const { data, error, loading, reload } = useApi<{ achievements: AchievementView[]; unlocked: number; total: number }>("/api/achievements");302 if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;303 if (loading && !data) {304 return (305 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">306 {Array.from({ length: 6 }).map((_, i) => (307 <Skeleton key={i} className="h-32 rounded-lg" />308 ))}309 </div>310 );311 }312 const list = data?.achievements ?? [];313 if (!list.length) return <Empty title="No achievements yet" description="Achievements unlock as you play." icon={<Trophy className="h-5 w-5" />} />;314 const sorted = [...list].sort((a, b) => Number(!!b.unlockedAt) - Number(!!a.unlockedAt) || b.progress / b.target - a.progress / a.target);315 return (316 <div className="space-y-3">317 <div className="flex items-center justify-between text-sm text-fg-3">318 <span>319 <span className="font-semibold text-fg">{data?.unlocked ?? 0}</span> of {data?.total ?? list.length} unlocked320 </span>321 <Progress value={data?.unlocked ?? 0} max={(data?.total ?? list.length) || 1} className="w-32" tone="credit" />322 </div>323 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">324 {sorted.map((a) => {325 const unlocked = !!a.unlockedAt;326 return (327 <Card key={a.key} className={cn("relative p-4", unlocked ? "border-accent/35 bg-[linear-gradient(180deg,rgba(201,169,97,0.10),rgba(255,255,255,0.02))]" : "opacity-90")}>328 <div className="flex items-start gap-3">329 <span className={cn("grid h-11 w-11 shrink-0 place-items-center rounded-md", unlocked ? "bg-accent text-bg shadow-glow" : "bg-surface-2 text-fg-4")} aria-hidden>330 {unlocked ? <Sparkles className="h-5 w-5" /> : <Lock className="h-4 w-4" />}331 </span>332 <div className="min-w-0 flex-1">333 <h3 className="truncate text-[15px] font-semibold tracking-tight">{a.name}</h3>334 <p className="mt-0.5 text-[13px] leading-snug text-fg-3">{a.description}</p>335 <div className="mt-2 flex items-center gap-3 text-[12px]">336 <span className="inline-flex items-center gap-1 text-credit tabular">+{a.rewardCredits.toLocaleString("en-US")} SC</span>337 <span className="text-fg-3">+{a.rewardXp} XP</span>338 </div>339 </div>340 </div>341 {unlocked ? (342 <div className="mt-3 text-[11px] text-fg-3">Unlocked {new Date(a.unlockedAt!).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</div>343 ) : (344 <div className="mt-3 flex items-center gap-3">345 <Progress value={a.progress} max={a.target} className="flex-1" />346 <span className="text-[11px] tabular text-fg-3">347 {a.progress.toLocaleString("en-US")}/{a.target.toLocaleString("en-US")}348 </span>349 </div>350 )}351 </Card>352 );353 })}354 </div>355 </div>356 );357}358359/* ------------------------------------------------------------------- page */360361export default function RewardsPage() {362 return (363 <AppShell>364 <RequireAuth>365 <div className="mb-6">366 <div className="eyebrow mb-1">Rewards</div>367 <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Keep the credits flowing.</h1>368 <p className="mt-2 max-w-xl text-sm text-fg-3">Daily rewards, missions, achievements and level-ups all pay in Spinza Credits — fictional, free and never for sale.</p>369 </div>370 <div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">371 <DailyCard />372 <div className="grid gap-4">373 <LevelCard />374 <RescueCard />375 </div>376 </div>377 <section className="mt-12" aria-labelledby="missions-title">378 <SectionHead eyebrow="Challenges" title="Missions" />379 <Missions />380 </section>381 <section className="mt-12" aria-labelledby="achievements-title">382 <SectionHead eyebrow="Milestones" title="Achievements" />383 <Achievements />384 </section>385 </RequireAuth>386 </AppShell>387 );388}389