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%
5.0 KB · 116 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState } from "react";4import Link from "next/link";5import { Gift, Users, ChevronRight, Flame } from "lucide-react";6import type { DailyRewardStatus } from "@spinza/shared";7import { api } from "@/lib/api";8import { toast, useSession } from "@/lib/store";9import { useApi } from "@/lib/use-api";10import { countdown, cn } from "@/lib/utils";11import { Button, Credits, Skeleton } from "@/components/ui";1213interface ClaimResponse {14  amount: number;15  streakDay: number;16  balance: number;17  nextAvailableAt: string;18  xp: { xp: number; level: number; leveledUp?: boolean } | null;19}2021/** Lobby strip: daily reward availability + live players. */22export function RewardsStrip({ livePlayers }: { livePlayers: number }) {23  const { data, loading, error, setData, reload } = useApi<DailyRewardStatus>("/api/rewards/daily");24  const setBalance = useSession((s) => s.setBalance);25  const setUserXp = useSession((s) => s.setUserXp);26  const [claiming, setClaiming] = useState(false);27  const [, tick] = useState(0);2829  useEffect(() => {30    if (!data || data.available) return;31    const t = setInterval(() => tick((n) => n + 1), 1000);32    return () => clearInterval(t);33  }, [data]);3435  const claim = async () => {36    if (claiming) return;37    setClaiming(true);38    try {39      const res = await api<ClaimResponse>("/api/rewards/daily/claim", { method: "POST" });40      setBalance(res.balance);41      if (res.xp) setUserXp(res.xp.xp, res.xp.level);42      toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: `Daily reward · day ${res.streakDay} of ${data?.schedule.length ?? 7}`, tone: "credit" });43      setData((d) => (d ? { ...d, available: false, claimedToday: true, streakDay: res.streakDay, nextAvailableAt: res.nextAvailableAt } : d));44    } catch (e) {45      toast({ title: "Could not claim", description: e instanceof Error ? e.message : undefined, tone: "danger" });46      void reload();47    } finally {48      setClaiming(false);49    }50  };5152  return (53    <div className="grid gap-3 sm:grid-cols-[1fr_auto]">54      <div className={cn("surface flex items-center gap-4 rounded-lg p-3 pl-4", data?.available && "border-accent/40 shadow-glow")}>55        <span className={cn("grid h-11 w-11 shrink-0 place-items-center rounded-md", data?.available ? "bg-accent text-bg" : "metal text-accent-2")}>56          <Gift className="h-5 w-5" />57        </span>58        <div className="min-w-0 flex-1">59          {loading && !data ? (60            <>61              <Skeleton className="h-4 w-40" />62              <Skeleton className="mt-2 h-3 w-24" />63            </>64          ) : error && !data ? (65            <>66              <div className="text-[15px] font-semibold">Daily reward</div>67              <div className="text-[13px] text-fg-3">Unavailable right now.</div>68            </>69          ) : data ? (70            <>71              <div className="flex items-center gap-2 text-[15px] font-semibold tracking-tight">72                {data.available ? "Your daily reward is ready" : "Daily reward claimed"}73                {data.streakDay > 1 ? (74                  <span className="inline-flex items-center gap-0.5 rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-bold text-fg-2">75                    <Flame className="h-3 w-3 text-accent" /> {data.streakDay}-day streak76                  </span>77                ) : null}78              </div>79              <div className="text-[13px] text-fg-3">80                {data.available ? (81                  <>82                    Claim <Credits amount={data.nextAmount} size="sm" /> · day {Math.min(data.streakDay + 1, data.schedule.length)} of {data.schedule.length}83                  </>84                ) : (85                  <>86                    Next reward <Credits amount={data.nextAmount} size="sm" /> in <span className="tabular text-fg-2">{countdown(data.nextAvailableAt)}</span>87                  </>88                )}89              </div>90            </>91          ) : null}92        </div>93        {data?.available ? (94          <Button variant="accent" size="md" onClick={claim} loading={claiming} className="shrink-0">95            Claim96          </Button>97        ) : (98          <Link href="/rewards" className="tap grid h-11 w-11 place-items-center rounded-md text-fg-3 hover:bg-surface-2 hover:text-fg focus-ring" aria-label="Open rewards">99            <ChevronRight className="h-5 w-5" />100          </Link>101        )}102      </div>103      <div className="surface flex items-center gap-3 rounded-lg px-4 py-3">104        <span className="relative grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-success">105          <Users className="h-4 w-4" />106          <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-success shadow-[0_0_10px_rgba(61,220,151,0.9)] animate-pulse-soft" />107        </span>108        <div>109          <div className="text-[15px] font-semibold tabular">{livePlayers.toLocaleString("en-US")}</div>110          <div className="text-[12px] text-fg-3">{livePlayers === 1 ? "player" : "players"} live now</div>111        </div>112      </div>113    </div>114  );115}116