mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
Such Moon Launch: global leaderboard page
Server-rendered /products/such-moon-launch/leaderboard backed by the public scores API. Level tabs (1-12), Fastest / Most Moonrocks boards, Endless Mode wave board, responsive table with graceful empty/error states. Linked from the product page. Ships with the 1.1 release.
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
|
import { Card } from "@repo/ui/components/card";
|
||||||
|
import { JsonLd, breadcrumbLd } from "@/components/seo/json-ld";
|
||||||
|
import { SuchMoonLaunchStoreButtons } from "@/components/store-buttons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Such Moon Launch — Global Leaderboard | Such Software",
|
||||||
|
description:
|
||||||
|
"Live global rankings for Such Moon Launch: fastest landings, biggest Moonrock hauls, and Endless Mode wave records across all 12 levels.",
|
||||||
|
alternates: { canonical: "/products/such-moon-launch/leaderboard" },
|
||||||
|
openGraph: {
|
||||||
|
title: "Such Moon Launch — Global Leaderboard",
|
||||||
|
description:
|
||||||
|
"Live global rankings: fastest landings, biggest Moonrock hauls, and Endless Mode wave records.",
|
||||||
|
type: "website",
|
||||||
|
images: ["/images/og/such-moon-launch.png"],
|
||||||
|
},
|
||||||
|
twitter: { card: "summary_large_image", images: ["/images/og/such-moon-launch.png"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_MOONLAUNCH_API ?? "https://api.such.software/v1/moonlaunch";
|
||||||
|
|
||||||
|
// index 0..11 maps to level 1..12 (level 12 = Endless Mode, wave board)
|
||||||
|
const LEVELS = [
|
||||||
|
"Moon", "Mars", "Venus", "Io", "Jupiter", "Saturn",
|
||||||
|
"Neptune", "Pluto", "Asteroid Belt", "Space Station", "Mothership", "Endless",
|
||||||
|
];
|
||||||
|
|
||||||
|
type Score = {
|
||||||
|
nickname: string;
|
||||||
|
completion_time: number;
|
||||||
|
fuel_remaining: number;
|
||||||
|
crypto_collected: number;
|
||||||
|
stars: number;
|
||||||
|
wave: number;
|
||||||
|
platform: string | null;
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// null = request failed (vs [] = no scores yet)
|
||||||
|
async function fetchBoard(level: number, board: string): Promise<Score[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/scores?level=${level}&board=${board}&limit=50`,
|
||||||
|
{ next: { revalidate: 60 } }, // cache the board for a minute
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data?.scores) ? data.scores : [];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(s: number): string {
|
||||||
|
if (s >= 60) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = (s % 60).toFixed(1).padStart(4, "0");
|
||||||
|
return `${m}:${sec}`;
|
||||||
|
}
|
||||||
|
return `${s.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankCell(rank: number): string {
|
||||||
|
return rank === 1 ? "🥇" : rank === 2 ? "🥈" : rank === 3 ? "🥉" : String(rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LeaderboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ level?: string; board?: string }>;
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
|
||||||
|
let level = parseInt(sp.level ?? "1", 10);
|
||||||
|
if (!Number.isFinite(level) || level < 1 || level > 12) level = 1;
|
||||||
|
const isEndless = level === 12;
|
||||||
|
|
||||||
|
let board = sp.board ?? (isEndless ? "wave" : "time");
|
||||||
|
if (isEndless) board = "wave";
|
||||||
|
else if (board !== "time" && board !== "score") board = "time";
|
||||||
|
|
||||||
|
const scores = await fetchBoard(level, board);
|
||||||
|
const levelName = level === 12 ? "Endless Mode" : LEVELS[level - 1];
|
||||||
|
const metricLabel = board === "time" ? "Time" : board === "score" ? "Moonrocks" : "Wave";
|
||||||
|
const base = "/products/such-moon-launch/leaderboard";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbLd([
|
||||||
|
{ name: "Home", path: "/" },
|
||||||
|
{ name: "Products", path: "/products" },
|
||||||
|
{ name: "Such Moon Launch", path: "/products/such-moon-launch" },
|
||||||
|
{ name: "Leaderboard", path: base },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<Header />
|
||||||
|
<div id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
|
||||||
|
<Link
|
||||||
|
href="/products/such-moon-launch"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block"
|
||||||
|
>
|
||||||
|
← Such Moon Launch
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-2 text-amber-600 dark:text-amber-400">
|
||||||
|
🏆 Leaderboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground mb-6">
|
||||||
|
Live global rankings — <span className="text-foreground font-medium">{levelName}</span>. Updates every minute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Level selector */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{LEVELS.map((name, i) => {
|
||||||
|
const lv = i + 1;
|
||||||
|
const active = lv === level;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={name}
|
||||||
|
href={`${base}?level=${lv}`}
|
||||||
|
className={`px-2.5 py-1.5 rounded-md text-sm border transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-amber-500 text-black border-amber-500 font-semibold"
|
||||||
|
: "border-amber-500/30 text-muted-foreground hover:text-foreground hover:border-amber-500/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="opacity-60 mr-1">{lv}</span>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board selector (story levels only) */}
|
||||||
|
{!isEndless && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{([["time", "⏱ Fastest"], ["score", "💰 Most Moonrocks"]] as const).map(([key, label]) => {
|
||||||
|
const active = key === board;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={key}
|
||||||
|
href={`${base}?level=${level}&board=${key}`}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm border transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/60 font-semibold"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Board */}
|
||||||
|
{scores === null ? (
|
||||||
|
<Card className="glass-card border-amber-500/20 p-8 text-center text-muted-foreground">
|
||||||
|
Leaderboard is temporarily unavailable. Please try again shortly.
|
||||||
|
</Card>
|
||||||
|
) : scores.length === 0 ? (
|
||||||
|
<Card className="glass-card border-amber-500/20 p-8 text-center text-muted-foreground">
|
||||||
|
No scores yet for {levelName}. Be the first —{" "}
|
||||||
|
<a href="https://moonlaunch.such.software" className="text-amber-600 dark:text-amber-400 hover:underline">
|
||||||
|
play on web
|
||||||
|
</a>
|
||||||
|
!
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="glass-card border-amber-500/20 overflow-hidden !p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-amber-500/20 text-muted-foreground">
|
||||||
|
<th className="px-3 py-3 w-12 text-center font-medium">#</th>
|
||||||
|
<th className="px-3 py-3 text-left font-medium">Pilot</th>
|
||||||
|
<th className="px-3 py-3 text-right font-medium">{metricLabel}</th>
|
||||||
|
{board === "wave" ? (
|
||||||
|
<th className="px-3 py-3 text-right font-medium hidden sm:table-cell">Moonrocks</th>
|
||||||
|
) : (
|
||||||
|
<th className="px-3 py-3 text-right font-medium hidden sm:table-cell">Fuel</th>
|
||||||
|
)}
|
||||||
|
<th className="px-3 py-3 text-center font-medium hidden sm:table-cell">Stars</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scores.map((s, i) => (
|
||||||
|
<tr key={`${s.rank}-${s.nickname}-${i}`} className="border-b border-border/40 last:border-0">
|
||||||
|
<td className="px-3 py-2.5 text-center tabular-nums">{rankCell(s.rank)}</td>
|
||||||
|
<td className="px-3 py-2.5 font-medium truncate max-w-[9rem] sm:max-w-[16rem]">{s.nickname}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right tabular-nums font-semibold text-amber-600 dark:text-amber-400">
|
||||||
|
{board === "time"
|
||||||
|
? fmtTime(s.completion_time)
|
||||||
|
: board === "score"
|
||||||
|
? s.crypto_collected.toLocaleString()
|
||||||
|
: s.wave}
|
||||||
|
</td>
|
||||||
|
{board === "wave" ? (
|
||||||
|
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground hidden sm:table-cell">
|
||||||
|
{s.crypto_collected.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground hidden sm:table-cell">
|
||||||
|
{Math.round(s.fuel_remaining)}%
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-3 py-2.5 text-center text-amber-500 hidden sm:table-cell">
|
||||||
|
{"★".repeat(Math.max(0, Math.min(3, s.stars)))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
Each pilot's best run per level.{" "}
|
||||||
|
{board === "time"
|
||||||
|
? "Ranked by fastest completion time."
|
||||||
|
: board === "score"
|
||||||
|
? "Ranked by most Moonrocks collected."
|
||||||
|
: "Ranked by highest wave survived."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 mb-8">
|
||||||
|
<h2 className="text-xl font-bold mb-3">Climb the ranks</h2>
|
||||||
|
<SuchMoonLaunchStoreButtons />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<Link href="/products/such-moon-launch" className="hover:text-foreground transition-colors">
|
||||||
|
About the game
|
||||||
|
</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<a href="https://moonlaunch.such.software" className="hover:text-foreground transition-colors">
|
||||||
|
Play on web
|
||||||
|
</a>
|
||||||
|
<span>•</span>
|
||||||
|
<Link href="/support" className="hover:text-foreground transition-colors">
|
||||||
|
Support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MobileNav />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,12 @@ export default function SuchMoonLaunchPage() {
|
|||||||
|
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<SuchMoonLaunchStoreButtons />
|
<SuchMoonLaunchStoreButtons />
|
||||||
|
<Link
|
||||||
|
href="/products/such-moon-launch/leaderboard"
|
||||||
|
className="inline-flex items-center gap-1 mt-4 font-medium text-amber-600 dark:text-amber-400 hover:underline"
|
||||||
|
>
|
||||||
|
🏆 View the global leaderboard →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-6">The Story</h2>
|
<h2 className="text-2xl font-bold mb-6">The Story</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user