TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { Plus, RotateCcw, Save, Trash2 } from "lucide-react";5import { DAILY_REWARDS, RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, RESCUE_CREDITS_THRESHOLD } from "@spinza/shared";6import { Button } from "@/components/ui";7import { api } from "@/lib/api";8import { toast } from "@/lib/store";9import { cn } from "@/lib/utils";10import { useAdminQuery } from "@/components/admin/use-query";11import type { DailyRewardsSetting, RescueSetting, SettingsResponse } from "@/components/admin/types";12import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, DenseInput, FieldLabel, InlineError, Pill } from "@/components/admin/primitives";13import { describeError, int, sc } from "@/components/admin/format";1415function readSchedule(s: Record<string, unknown> | undefined): number[] {16 const v = s?.["dailyRewards"] as Partial<DailyRewardsSetting> | undefined;17 return Array.isArray(v?.schedule) && v.schedule.length ? v.schedule.map((n) => Math.max(0, Math.trunc(Number(n) || 0))) : [...DAILY_REWARDS];18}1920function readRescue(s: Record<string, unknown> | undefined): RescueSetting {21 const v = s?.["rescue"] as Partial<RescueSetting> | undefined;22 return { amount: Number(v?.amount ?? RESCUE_CREDITS_AMOUNT), cooldownHours: Number(v?.cooldownHours ?? RESCUE_CREDITS_COOLDOWN_HOURS), threshold: Number(v?.threshold ?? RESCUE_CREDITS_THRESHOLD) };23}2425export default function AdminRewardsPage() {26 const q = useAdminQuery<SettingsResponse>("/api/admin/settings");27 const d = q.data;28 const flags = d?.flags ?? {};2930 return (31 <>32 <PageHeader title="Daily Rewards" description="Streak schedule and rescue credits. Changes apply to the next claim; existing streaks are preserved." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />33 {q.error && !d ? (34 <ErrorState error={q.error} onRetry={() => void q.refresh()} />35 ) : !d ? (36 <div className="grid gap-4 xl:grid-cols-[1.3fr_1fr]">37 <Panel>38 <TableSkeleton rows={7} cols={3} />39 </Panel>40 <Panel>41 <TableSkeleton rows={3} cols={2} />42 </Panel>43 </div>44 ) : (45 <div className={cn("grid gap-4 xl:grid-cols-[1.3fr_1fr]", q.stale && "opacity-70")}>46 <ScheduleEditor key={`s-${q.updatedAt}`} initial={readSchedule(d.settings)} enabled={flags["dailyRewards.enabled"] ?? true} onSaved={() => void q.refresh()} />47 <RescueEditor key={`r-${q.updatedAt}`} initial={readRescue(d.settings)} enabled={flags["rescue.enabled"] ?? true} onSaved={() => void q.refresh()} />48 </div>49 )}50 </>51 );52}5354function ScheduleEditor({ initial, enabled, onSaved }: { initial: number[]; enabled: boolean; onSaved: () => void }) {55 const [schedule, setSchedule] = React.useState<number[]>(initial);56 const [busy, setBusy] = React.useState(false);57 const [error, setError] = React.useState<string | null>(null);58 const dirty = JSON.stringify(schedule) !== JSON.stringify(initial);59 const valid = schedule.length >= 1 && schedule.length <= 30 && schedule.every((n) => Number.isInteger(n) && n >= 0);60 const total = schedule.reduce((a, n) => a + n, 0);6162 async function save() {63 if (!valid) return;64 setBusy(true);65 setError(null);66 try {67 await api("/api/admin/settings/dailyRewards", { json: { value: { schedule } } });68 toast({ title: "Daily reward schedule saved", description: `${schedule.length}-day streak · ${sc(total)} per full cycle`, tone: "success" });69 onSaved();70 } catch (e) {71 setError(describeError(e));72 } finally {73 setBusy(false);74 }75 }7677 return (78 <Panel79 title="Streak schedule"80 description="Credits granted on each consecutive day; the last day repeats until the streak breaks."81 actions={82 <>83 <Pill tone={enabled ? "success" : "danger"} dot>84 {enabled ? "flag on" : "flag off"}85 </Pill>86 <Button size="sm" variant="ghost" onClick={() => setSchedule(initial)} disabled={!dirty || busy} aria-label="Reset">87 <RotateCcw className="h-3.5 w-3.5" />88 </Button>89 <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty || !valid} loading={busy}>90 <Save className="h-3.5 w-3.5" /> Save91 </Button>92 </>93 }94 >95 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">96 {schedule.map((v, i) => (97 <div key={i} className="flex items-center gap-2 rounded-sm border border-line bg-bg-1/60 px-2.5 py-2">98 <span className="w-12 shrink-0 text-[11px] font-semibold uppercase tracking-wider text-fg-4">Day {i + 1}</span>99 <DenseInput inputMode="numeric" className="h-8 flex-1 text-right font-mono" value={String(v)} onChange={(e) => setSchedule((s) => s.map((x, j) => (j === i ? Number(e.target.value.replace(/\D/g, "") || 0) : x)))} aria-label={`Day ${i + 1} reward`} />100 <span className="text-[11px] text-fg-4">SC</span>101 <button type="button" onClick={() => setSchedule((s) => s.filter((_, j) => j !== i))} disabled={schedule.length <= 1} className="grid h-7 w-7 place-items-center rounded-xs text-fg-4 hover:text-danger disabled:opacity-30" aria-label={`Remove day ${i + 1}`}>102 <Trash2 className="h-3.5 w-3.5" />103 </button>104 </div>105 ))}106 {schedule.length < 30 ? (107 <button type="button" onClick={() => setSchedule((s) => [...s, s[s.length - 1] ?? 1000])} className="flex h-[46px] items-center justify-center gap-1.5 rounded-sm border border-dashed border-line-2 text-[12px] text-fg-3 hover:border-accent/50 hover:text-fg">108 <Plus className="h-3.5 w-3.5" /> Add day {schedule.length + 1}109 </button>110 ) : null}111 </div>112 <div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-line pt-3 text-[12px] text-fg-3">113 <span>114 <span className="text-fg-4">days</span> {schedule.length}115 </span>116 <span>117 <span className="text-fg-4">full cycle</span> {sc(total)}118 </span>119 <span>120 <span className="text-fg-4">avg / day</span> {sc(Math.round(total / Math.max(1, schedule.length)))}121 </span>122 <button type="button" className="ml-auto text-fg-3 underline-offset-2 hover:text-fg hover:underline" onClick={() => setSchedule([...DAILY_REWARDS])}>123 Load defaults124 </button>125 </div>126 <FieldLabel className="mt-4">Preview</FieldLabel>127 <div className="flex items-end gap-1" aria-hidden>128 {schedule.map((v, i) => (129 <div key={i} className="flex flex-1 flex-col items-center gap-1">130 <div className="w-full rounded-t-[3px] bg-accent/70" style={{ height: `${Math.max(4, (v / Math.max(1, ...schedule)) * 56)}px` }} title={`Day ${i + 1}: ${sc(v)}`} />131 <span className="text-[9px] text-fg-4">{i + 1}</span>132 </div>133 ))}134 </div>135 {!valid ? <div className="mt-3"><InlineError>Schedule must have 1–30 non-negative integer amounts.</InlineError></div> : null}136 {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}137 </Panel>138 );139}140141function RescueEditor({ initial, enabled, onSaved }: { initial: RescueSetting; enabled: boolean; onSaved: () => void }) {142 const [v, setV] = React.useState<RescueSetting>(initial);143 const [busy, setBusy] = React.useState(false);144 const [error, setError] = React.useState<string | null>(null);145 const dirty = JSON.stringify(v) !== JSON.stringify(initial);146 const valid = Number.isInteger(v.amount) && v.amount >= 0 && Number.isFinite(v.cooldownHours) && v.cooldownHours >= 0 && Number.isInteger(v.threshold) && v.threshold >= 0;147148 async function save() {149 if (!valid) return;150 setBusy(true);151 setError(null);152 try {153 await api("/api/admin/settings/rescue", { json: { value: v } });154 toast({ title: "Rescue credits saved", description: `${sc(v.amount)} every ${v.cooldownHours} h under ${sc(v.threshold)}`, tone: "success" });155 onSaved();156 } catch (e) {157 setError(describeError(e));158 } finally {159 setBusy(false);160 }161 }162163 return (164 <Panel165 title="Rescue credits"166 description="Granted to players who run out of credits."167 actions={168 <>169 <Pill tone={enabled ? "success" : "danger"} dot>170 {enabled ? "flag on" : "flag off"}171 </Pill>172 <Button size="sm" variant="ghost" onClick={() => setV(initial)} disabled={!dirty || busy} aria-label="Reset">173 <RotateCcw className="h-3.5 w-3.5" />174 </Button>175 <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty || !valid} loading={busy}>176 <Save className="h-3.5 w-3.5" /> Save177 </Button>178 </>179 }180 >181 <div className="space-y-3">182 <div>183 <FieldLabel hint={`default ${int(RESCUE_CREDITS_AMOUNT)}`}>Amount (SC)</FieldLabel>184 <DenseInput inputMode="numeric" className="font-mono" value={String(v.amount)} onChange={(e) => setV({ ...v, amount: Number(e.target.value.replace(/\D/g, "") || 0) })} />185 </div>186 <div>187 <FieldLabel hint={`default ${RESCUE_CREDITS_COOLDOWN_HOURS} h`}>Cooldown (hours)</FieldLabel>188 <DenseInput inputMode="decimal" className="font-mono" value={String(v.cooldownHours)} onChange={(e) => setV({ ...v, cooldownHours: Number(e.target.value.replace(/[^\d.]/g, "") || 0) })} />189 </div>190 <div>191 <FieldLabel hint="balance at or below which rescue is offered">Threshold (SC)</FieldLabel>192 <DenseInput inputMode="numeric" className="font-mono" value={String(v.threshold)} onChange={(e) => setV({ ...v, threshold: Number(e.target.value.replace(/\D/g, "") || 0) })} />193 </div>194 </div>195 <p className="mt-4 rounded-sm border border-line bg-bg-1/60 px-3 py-2 text-[12px] text-fg-3">196 A player with <span className="text-fg">≤ {sc(v.threshold)}</span> can claim <span className="text-credit">{sc(v.amount)}</span> at most once every <span className="text-fg">{v.cooldownHours} h</span>.197 </p>198 {!valid ? <div className="mt-3"><InlineError>Amount and threshold must be non-negative integers; cooldown a non-negative number.</InlineError></div> : null}199 {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}200 </Panel>201 );202}203