diff --git a/src/app/(frontend)/flappy/page.tsx b/src/app/(frontend)/flappy/page.tsx
index d52bc99..56248d3 100644
--- a/src/app/(frontend)/flappy/page.tsx
+++ b/src/app/(frontend)/flappy/page.tsx
@@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Bird } from "lucide-react";
-import { FlappyBird } from "@/components/frontend/flappy/FlappyBird";
+import { FlappyArena } from "@/components/frontend/flappy/FlappyArena";
export const metadata: Metadata = {
title: "Flappy Bird — Polaris Task Force",
@@ -56,7 +56,7 @@ export default async function FlappyPage() {
-
+
);
}
diff --git a/src/components/frontend/flappy/FlappyArena.tsx b/src/components/frontend/flappy/FlappyArena.tsx
new file mode 100644
index 0000000..bd720fa
--- /dev/null
+++ b/src/components/frontend/flappy/FlappyArena.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useState } from "react";
+import type { FlappyStats } from "@/app/(frontend)/flappy/actions";
+import { FlappyBird } from "./FlappyBird";
+import { FlappyLeaderboard } from "./FlappyLeaderboard";
+
+export function FlappyArena({
+ initialStats,
+ currentUserId,
+}: {
+ initialStats: FlappyStats;
+ currentUserId: number | string;
+}) {
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ return (
+
+ setRefreshKey((k) => k + 1)} />
+
+
+ );
+}
diff --git a/src/components/frontend/flappy/FlappyBird.tsx b/src/components/frontend/flappy/FlappyBird.tsx
index b77530c..c387f22 100644
--- a/src/components/frontend/flappy/FlappyBird.tsx
+++ b/src/components/frontend/flappy/FlappyBird.tsx
@@ -142,7 +142,13 @@ function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean {
return topBlocked || bottomBlocked;
}
-export function FlappyBird({ initialStats: serverStats }: { initialStats: FlappyStats }) {
+export function FlappyBird({
+ initialStats: serverStats,
+ onGameRecorded,
+}: {
+ initialStats: FlappyStats;
+ onGameRecorded?: () => void;
+}) {
const canvasRef = useRef(null);
const wrapperRef = useRef(null);
const phaseRef = useRef("ready");
@@ -206,6 +212,7 @@ export function FlappyBird({ initialStats: serverStats }: { initialStats: Flappy
if (res.success && res.data) {
statsRef.current = res.data;
setStats(res.data);
+ onGameRecorded?.();
if (res.data.xpEarned > 0) {
playXp();
}
@@ -223,7 +230,7 @@ export function FlappyBird({ initialStats: serverStats }: { initialStats: Flappy
});
}
},
- [],
+ [onGameRecorded],
);
const handleGameOverRef = useRef(handleGameOver);
diff --git a/src/components/frontend/flappy/FlappyLeaderboard.tsx b/src/components/frontend/flappy/FlappyLeaderboard.tsx
new file mode 100644
index 0000000..23e99b6
--- /dev/null
+++ b/src/components/frontend/flappy/FlappyLeaderboard.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Trophy } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ApiUser {
+ id: number;
+ username?: string | null;
+ displayName?: string | null;
+ payloadDisplayName?: string | null;
+}
+
+interface ApiProfile {
+ id: number;
+ profileTitle?: string | null;
+ user?: ApiUser | number | null;
+ progression?: {
+ minigames?: {
+ flappyBird?: {
+ gamesPlayed?: number | null;
+ totalPasses?: number | null;
+ bestScore?: number | null;
+ xpEarned?: number | null;
+ } | null;
+ } | null;
+ } | null;
+}
+
+interface LeaderboardRow {
+ id: number;
+ name: string;
+ gamesPlayed: number;
+ totalPasses: number;
+ bestScore: number;
+ xpEarned: number;
+ isCurrentUser: boolean;
+}
+
+interface FlappyLeaderboardProps {
+ refreshKey?: number;
+ currentUserId?: number | string;
+}
+
+function rankClass(index: number) {
+ if (index === 0) return "font-bold text-amber-400";
+ if (index === 1) return "font-semibold text-slate-300";
+ if (index === 2) return "font-semibold text-amber-700";
+ return "text-muted-foreground";
+}
+
+export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeaderboardProps) {
+ const [rows, setRows] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setFailed(false);
+
+ const params = new URLSearchParams({
+ "where[progression.minigames.flappyBird.bestScore][greater_than]": "0",
+ sort: "-progression.minigames.flappyBird.bestScore",
+ limit: "10",
+ depth: "1",
+ });
+
+ fetch(`/api/profiles?${params.toString()}`)
+ .then((res) => {
+ if (!res.ok) throw new Error("leaderboard fetch failed");
+ return res.json() as Promise<{ docs?: ApiProfile[] }>;
+ })
+ .then((data) => {
+ if (cancelled) return;
+ setRows(
+ (data.docs ?? []).map((doc) => {
+ const flappy = doc.progression?.minigames?.flappyBird ?? {};
+ const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
+ const name =
+ user?.displayName || doc.profileTitle || user?.username || `Pilot #${doc.id}`;
+ return {
+ id: doc.id,
+ name,
+ gamesPlayed: flappy.gamesPlayed ?? 0,
+ totalPasses: flappy.totalPasses ?? 0,
+ bestScore: flappy.bestScore ?? 0,
+ xpEarned: flappy.xpEarned ?? 0,
+ isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
+ };
+ }),
+ );
+ })
+ .catch(() => {
+ if (!cancelled) setFailed(true);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [refreshKey, currentUserId]);
+
+ return (
+
+
+
+
Flappy Leaderboard
+ Top 10 · best run
+
+
+ {failed ? (
+
Could not load the leaderboard.
+ ) : rows === null ? (
+
+ ) : rows.length === 0 ? (
+
+ No pilots on the board yet — be the first to pass a pipe!
+
+ ) : (
+
+
+
+ | # |
+ Pilot |
+ Games |
+ Pipes |
+ Best |
+ XP |
+
+
+
+ {rows.map((row, i) => (
+
+ |
+ {i + 1}
+ |
+
+
+ {row.name}
+ {row.isCurrentUser && (
+ (you)
+ )}
+
+ |
+
+ {row.gamesPlayed}
+ |
+
+ {row.totalPasses}
+ |
+ {row.bestScore} |
+
+ {row.xpEarned}
+ |
+
+ ))}
+
+
+ )}
+
+ );
+}