SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
18.1 KB · 389 lines tsx
Raw Blame History
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