"use client"; import { useEffect, useState } from "react"; import { Gift, LifeBuoy, Target, Trophy, Lock, Check, Flame, Star, Sparkles, Zap } from "lucide-react"; import type { AchievementView, DailyRewardStatus, MissionView, RescueStatus } from "@spinza/shared"; import { RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, formatSC } from "@spinza/shared"; import { api } from "@/lib/api"; import { toast, useSession } from "@/lib/store"; import { useApi } from "@/lib/use-api"; import { cn, countdown } from "@/lib/utils"; import { Button, Card, Credits, Progress, Skeleton, SectionHead, Empty, Badge } from "@/components/ui"; import { AppShell } from "@/components/shell/app-shell"; import { RequireAuth } from "@/components/shell/require-auth"; import { ApiErrorState } from "@/components/shell/api-error"; /* ---------------------------------------------------------------- helpers */ function useTick(active: boolean) { const [, set] = useState(0); useEffect(() => { if (!active) return; const t = setInterval(() => set((n) => n + 1), 1000); return () => clearInterval(t); }, [active]); } /* ------------------------------------------------------------ daily card */ interface DailyClaim { amount: number; streakDay: number; balance: number; nextAvailableAt: string; xp: { xp: number; level: number } | null; } function DailyCard() { const { data, error, reload, setData } = useApi("/api/rewards/daily"); const setBalance = useSession((s) => s.setBalance); const setUserXp = useSession((s) => s.setUserXp); const [busy, setBusy] = useState(false); useTick(!!data && !data.available); const claim = async () => { if (busy) return; setBusy(true); try { const res = await api("/api/rewards/daily/claim", { method: "POST" }); setBalance(res.balance); if (res.xp) setUserXp(res.xp.xp, res.xp.level); toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: `Day ${res.streakDay} claimed. Come back tomorrow to keep the streak.`, tone: "credit" }); setData((d) => (d ? { ...d, available: false, claimedToday: true, streakDay: res.streakDay, nextAvailableAt: res.nextAvailableAt } : d)); } catch (e) { toast({ title: "Could not claim", description: e instanceof Error ? e.message : undefined, tone: "danger" }); void reload(); } finally { setBusy(false); } }; if (error && !data) return ; if (!data) { return (
{Array.from({ length: 7 }).map((_, i) => ( ))}
); } // Day the next claim will land on (or the last claimed day when waiting). const targetDay = data.available ? Math.min((data.streakDay % data.schedule.length) + 1, data.schedule.length) : data.streakDay; return (

Daily reward

Claim once a day. Seven days in a row unlock the big one.

{data.streakDay > 0 ? ( {data.streakDay}-day streak ) : null}
    {data.schedule.map((amt, i) => { const day = i + 1; const claimed = day <= data.streakDay && !(data.available && day === targetDay); const isNext = data.available && day === targetDay; return (
  1. Day {day} {claimed ? : day === 7 ? : } {amt >= 1000 ? `${(amt / 1000).toFixed(amt % 1000 ? 2 : 0).replace(/\.?0+$/, "")}K` : amt}
  2. ); })}
{data.available ? ( <> Ready now: ) : ( <> Next reward in {countdown(data.nextAvailableAt)} )}
); } /* ----------------------------------------------------------- rescue card */ function RescueCard() { const { data, error, reload, setData } = useApi("/api/rewards/rescue"); const setBalance = useSession((s) => s.setBalance); const balance = useSession((s) => s.wallet?.balance ?? 0); const [busy, setBusy] = useState(false); useTick(!!data?.nextAvailableAt); const claim = async () => { if (busy) return; setBusy(true); try { const res = await api<{ amount: number; balance: number; nextAvailableAt: string }>("/api/rewards/rescue/claim", { method: "POST" }); setBalance(res.balance); toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: "Rescue credits added. Back in the game.", tone: "credit" }); setData((d) => (d ? { ...d, eligible: false, balance: res.balance, nextAvailableAt: res.nextAvailableAt } : d)); } catch (e) { toast({ title: "Not available", description: e instanceof Error ? e.message : undefined, tone: "danger" }); void reload(); } finally { setBusy(false); } }; if (error && !data) return ; const amount = data?.amount ?? RESCUE_CREDITS_AMOUNT; return (

Rescue credits

{formatSC(amount)} every {RESCUE_CREDITS_COOLDOWN_HOURS} hours whenever you reach 0 SC.

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.

{!data ? ( ) : data.eligible ? ( Available now ) : data.nextAvailableAt ? ( <> Recharging · ready in {countdown(data.nextAvailableAt)} ) : ( <> Balance — unlocks at 0 SC )}
); } /* ------------------------------------------------------------ level card */ function LevelCard() { const user = useSession((s) => s.user); if (!user) return null; const max = user.xpForNext || 1; const pct = user.xpForNext ? user.xpIntoLevel / user.xpForNext : 1; return (

Level {user.level}

{user.xp.toLocaleString("en-US")} XP total

Every spin earns XP. Level-ups grant bonus credits.

{user.xpIntoLevel.toLocaleString("en-US")} XP {user.xpForNext ? `${(user.xpForNext - user.xpIntoLevel).toLocaleString("en-US")} XP to level ${user.level + 1}` : "Max level reached"}
{Math.round(pct * 100)}%
); } /* --------------------------------------------------------------- missions */ function Missions() { const { data, error, loading, reload } = useApi<{ missions: MissionView[]; enabled: boolean }>("/api/missions"); useTick(!!data); if (error && !data) return ; if (loading && !data) { return (
{Array.from({ length: 4 }).map((_, i) => ( ))}
); } const missions = data?.missions ?? []; if (!data?.enabled) return } />; if (!missions.length) return } />; const groups: { period: "daily" | "weekly"; label: string; items: MissionView[] }[] = [ { period: "daily", label: "Daily", items: missions.filter((m) => m.period === "daily") }, { period: "weekly", label: "Weekly", items: missions.filter((m) => m.period === "weekly") }, ].filter((g) => g.items.length) as { period: "daily" | "weekly"; label: string; items: MissionView[] }[]; return (
{groups.map((g) => (
{g.label} Resets in {countdown(g.items[0].expiresAt)}
{g.items.map((m) => { const done = !!m.completedAt; return (

{m.name}

{done ? Done : null}

{m.description}

+{m.rewardXp} XP
{m.progress.toLocaleString("en-US")}/{m.target.toLocaleString("en-US")}
); })}
))}

Mission rewards are credited automatically the moment a mission completes.

); } /* ----------------------------------------------------------- achievements */ function Achievements() { const { data, error, loading, reload } = useApi<{ achievements: AchievementView[]; unlocked: number; total: number }>("/api/achievements"); if (error && !data) return ; if (loading && !data) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
); } const list = data?.achievements ?? []; if (!list.length) return } />; const sorted = [...list].sort((a, b) => Number(!!b.unlockedAt) - Number(!!a.unlockedAt) || b.progress / b.target - a.progress / a.target); return (
{data?.unlocked ?? 0} of {data?.total ?? list.length} unlocked
{sorted.map((a) => { const unlocked = !!a.unlockedAt; return (
{unlocked ? : }

{a.name}

{a.description}

+{a.rewardCredits.toLocaleString("en-US")} SC +{a.rewardXp} XP
{unlocked ? (
Unlocked {new Date(a.unlockedAt!).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
) : (
{a.progress.toLocaleString("en-US")}/{a.target.toLocaleString("en-US")}
)}
); })}
); } /* ------------------------------------------------------------------- page */ export default function RewardsPage() { return (
Rewards

Keep the credits flowing.

Daily rewards, missions, achievements and level-ups all pay in Spinza Credits — fictional, free and never for sale.

); }