Replace remote with local repo

This commit is contained in:
drjones
2026-04-26 22:28:40 -07:00
parent 04d64fb993
commit e7c9da944e
57 changed files with 3658 additions and 3971 deletions

View File

@@ -0,0 +1,156 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { useAccount } from "@/contexts/AccountContext";
const EPOCH = new Date("2026-04-14T00:00:00Z").getTime();
const INTERVAL = 7 * 24 * 60 * 60 * 1000;
function getNextDraw() {
const now = Date.now();
return EPOCH + (Math.floor((now - EPOCH) / INTERVAL) + 1) * INTERVAL;
}
function fmt(ms: number) {
if (ms <= 0) return "DRAWING NOW";
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}d ${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
type RaffleStats = { totalTickets: number; participants: number; drawAt: number };
export default function RaffleCountdownBanner() {
const { user } = useAccount();
const [remaining, setRemaining] = useState(() => getNextDraw() - Date.now());
const [stats, setStats] = useState<RaffleStats | null>(null);
const [buying, setBuying] = useState(false);
const [qty, setQty] = useState(1);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const t = setInterval(() => setRemaining(getNextDraw() - Date.now()), 1000);
return () => clearInterval(t);
}, []);
const fetchStats = useCallback(async () => {
try {
const r = await fetch("/api/raffle/buy");
const d = (await r.json()) as RaffleStats & { ok: boolean };
if (d.ok) setStats(d);
} catch { /* ignore */ }
}, []);
useEffect(() => { void fetchStats(); }, [fetchStats]);
const buyTickets = async () => {
if (!user) return;
setBuying(true);
setMsg(null);
try {
const r = await fetch("/api/raffle/buy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ handle: user.username, quantity: qty }),
});
const d = (await r.json()) as { ok: boolean; error?: string; ticketsBought?: number; voidBalance?: number };
if (d.ok) {
setMsg({ text: `${d.ticketsBought} ticket${(d.ticketsBought ?? 1) > 1 ? "s" : ""} entered! Balance: ${d.voidBalance} VOID`, ok: true });
void fetchStats();
} else {
setMsg({ text: d.error ?? "Failed", ok: false });
}
} catch {
setMsg({ text: "Network error", ok: false });
} finally {
setBuying(false);
}
};
if (dismissed) return null;
return (
<div className="sticky top-0 z-50 w-full bg-gradient-to-r from-[#0d0d0d] via-[#111827] to-[#0d0d0d] border-b border-[#00ff66]/20 shadow-[0_2px_20px_rgba(0,255,102,0.08)]">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-2 px-4 py-1.5 text-xs">
{/* Left: timer + pot */}
<div className="flex items-center gap-3 min-w-0">
<span className="text-[#00ff66]/50 font-mono hidden sm:inline"> WEEKLY VOID RAFFLE</span>
<div className="font-mono text-[#00ff66] text-sm font-bold tabular-nums tracking-widest">
{fmt(remaining)}
</div>
{stats && (
<span className="text-zinc-500 hidden md:inline">
{stats.totalTickets} tickets · {stats.participants} players
</span>
)}
</div>
{/* Centre: quick buy */}
<div className="flex items-center gap-2">
{user ? (
open ? (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<button
onClick={() => setQty((q) => Math.max(1, q - 1))}
className="w-6 h-6 rounded border border-[#00ff66]/30 text-[#00ff66] hover:bg-[#00ff66]/10 font-bold leading-none"
></button>
<span className="font-mono text-[#00ff66] w-6 text-center">{qty}</span>
<button
onClick={() => setQty((q) => Math.min(100, q + 1))}
className="w-6 h-6 rounded border border-[#00ff66]/30 text-[#00ff66] hover:bg-[#00ff66]/10 font-bold leading-none"
>+</button>
<button
onClick={buyTickets}
disabled={buying}
className="rounded bg-[#00ff66]/20 border border-[#00ff66]/40 px-3 py-1 font-bold text-[#00ff66] hover:bg-[#00ff66]/30 disabled:opacity-50 transition-colors text-xs"
>
{buying ? "…" : `BUY ${qty} · ${qty} VOID`}
</button>
{msg && (
<span className={msg.ok ? "text-[#00ff66]" : "text-red-400"}>
{msg.text}
</span>
)}
</div>
) : (
<button
onClick={() => setOpen(true)}
className="rounded border border-[#00ff66]/30 bg-[#00ff66]/10 px-3 py-1 font-bold text-[#00ff66] hover:bg-[#00ff66]/20 transition-colors"
>
🎟 BUY TICKET · 1 VOID
</button>
)
) : (
<Link
href="/raffle"
className="rounded border border-[#00ff66]/30 bg-[#00ff66]/10 px-3 py-1 font-bold text-[#00ff66] hover:bg-[#00ff66]/20 transition-colors"
>
🎟 WEEKLY RAFFLE
</Link>
)}
</div>
{/* Right: prize + close */}
<div className="flex items-center gap-3">
<Link href="/raffle" className="text-zinc-500 hover:text-[#00ff66] hidden sm:inline transition-colors">
500 VOID top prize
</Link>
<button
onClick={() => { setOpen(false); setDismissed(true); }}
className="text-zinc-600 hover:text-zinc-400 text-base leading-none transition-colors"
aria-label="Close raffle banner"
>
</button>
</div>
</div>
</div>
);
}