144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Award } from "lucide-react";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { getVisitorEventLeaderboard } from "../lib/api";
|
|
import { getAttendeeToken } from "../lib/attendeeAuth";
|
|
import { HeaderMobile } from "../components/HeaderMobile";
|
|
import { MobileShell } from "../components/MobileShell";
|
|
import { readStoredVisitorContext } from "../types/visitorContext";
|
|
import type { VisitorLeaderboardRow } from "../lib/api";
|
|
|
|
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
|
|
const cls =
|
|
tier === "gold"
|
|
? "text-amber-500"
|
|
: tier === "silver"
|
|
? "text-slate-400"
|
|
: "text-amber-800";
|
|
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
|
|
}
|
|
|
|
function medalForRank(rank: number): "gold" | "silver" | "bronze" | null {
|
|
if (rank === 1) return "gold";
|
|
if (rank === 2) return "silver";
|
|
if (rank === 3) return "bronze";
|
|
return null;
|
|
}
|
|
|
|
export function LeaderboardPage() {
|
|
const [searchParams] = useSearchParams();
|
|
const urlEventId = searchParams.get("event_id")?.trim() || "";
|
|
const ctx = readStoredVisitorContext();
|
|
const eventId = urlEventId || ctx?.eventId || "";
|
|
|
|
const [rows, setRows] = useState<VisitorLeaderboardRow[]>([]);
|
|
const [yourRank, setYourRank] = useState<number | null>(null);
|
|
const [yourTotal, setYourTotal] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!eventId) {
|
|
setRows([]);
|
|
setYourRank(null);
|
|
setYourTotal(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setErr(null);
|
|
getVisitorEventLeaderboard(eventId, { limit: 50 })
|
|
.then((r) => {
|
|
if (cancelled) return;
|
|
setRows(r.leaderboard);
|
|
if (getAttendeeToken()) {
|
|
const y = r.your;
|
|
setYourRank(y?.rank ?? null);
|
|
setYourTotal(typeof y?.total === "number" ? y.total : null);
|
|
} else {
|
|
setYourRank(null);
|
|
setYourTotal(null);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled) setErr(e instanceof Error ? e.message : "Could not load leaderboard.");
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [eventId]);
|
|
|
|
const top5 = rows.slice(0, 5);
|
|
const signedIn = Boolean(getAttendeeToken());
|
|
|
|
return (
|
|
<MobileShell className="relative">
|
|
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
|
|
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-6 pb-10 pt-6">
|
|
<Award className="size-[68px] text-amber-300" strokeWidth={1.25} />
|
|
<h1 className="mt-1 font-inter text-xl font-semibold text-white">Leaderboard</h1>
|
|
|
|
<div className="mt-6 w-full max-w-[354px] rounded-xl bg-whit card-bg shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
|
<div className="p-4">
|
|
<div className="eb-rank-highlight flex flex-col gap-2 rounded-3xl border-4 border-white px-4 py-4 text-white shadow-[0_9px_16px_rgba(0,0,0,0.14)]">
|
|
<p className="text-center font-inter text-base font-semibold uppercase tracking-wide">Your rank</p>
|
|
<div className="flex items-center justify-center gap-4">
|
|
<span className="font-inter text-5xl font-bold drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)]">
|
|
{!signedIn ? "—" : loading ? "…" : yourRank != null ? yourRank : "—"}
|
|
</span>
|
|
<span className="rounded-xl bg-[#cff7d3] px-4 py-2 text-sm font-medium text-[#009951] shadow-sm">
|
|
{!signedIn
|
|
? "Sign in"
|
|
: loading
|
|
? "…"
|
|
: yourTotal != null
|
|
? `${yourTotal} points`
|
|
: "0 points"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<h2 className="mt-6 font-inter text-base font-semibold text-[#24282f]">Top participants</h2>
|
|
{err ? (
|
|
<p className="mt-3 text-xs text-red-600">{err}</p>
|
|
) : loading && rows.length === 0 ? (
|
|
<p className="mt-3 text-xs text-[rgba(36,40,47,0.67)]">Loading…</p>
|
|
) : top5.length === 0 ? (
|
|
<p className="mt-3 text-xs text-[rgba(36,40,47,0.67)]">
|
|
No scores yet. Complete activities and earn points when organizers enable rewards.
|
|
</p>
|
|
) : (
|
|
<ul className="mt-3 flex flex-col gap-3">
|
|
{top5.map((row) => {
|
|
const medal = medalForRank(row.rank);
|
|
return (
|
|
<li
|
|
key={row.participantId}
|
|
className="flex items-center gap-3 border-b border-[#ededed] pb-3 last:border-0"
|
|
>
|
|
<span className="shrink-0 rounded bg-[#ebffee] px-3 py-2 text-xs font-medium text-[#009951] shadow-sm">
|
|
#{row.rank} · {row.total} pts
|
|
</span>
|
|
<span className="min-w-0 flex-1 text-sm font-semibold text-[#24282f]">{row.displayName}</span>
|
|
{medal ? <Medal tier={medal} /> : null}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Link
|
|
to={eventId ? `/rewards?event_id=${encodeURIComponent(eventId)}` : "/rewards"}
|
|
className="mt-6 font-inter font-semibold text-[var(--theme-text-color)] underline"
|
|
>
|
|
Rewards
|
|
</Link>
|
|
</div>
|
|
</MobileShell>
|
|
);
|
|
}
|