Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/pages/api/game/toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default async function handler(
}

// 3. Forward to Google Apps Script
const { enabled } = req.body;
const { enabled, action, resultsPublished } = req.body;
const scriptUrl = process.env.GOOGLE_APPS_SCRIPT_URL;

if (!scriptUrl) {
Expand All @@ -64,8 +64,9 @@ export default async function handler(
"Content-Type": "text/plain",
},
body: JSON.stringify({
action: "toggleGame",
action: action || "toggleGame",
enabled: enabled,
resultsPublished: resultsPublished,
}),
});

Expand Down
314 changes: 289 additions & 25 deletions src/pages/game/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import FeedbackForm, { FeedbackData } from "@/components/FeedbackForm";
import { useAuthRole } from "@/lib/useAuthRole";
import NotFound from "@/pages/404";
import { client } from "@/lib/supabase/supabase";
import { motion, AnimatePresence } from "framer-motion";

export default function GamePage() {
const { role, loading: roleLoading } = useAuthRole();
const isPanelist = role && (role.slug === "admin" || role.slug.includes("panel"));

const [isGameEnabled, setIsGameEnabled] = useState<boolean | null>(null);
const [isResultsPublished, setIsResultsPublished] = useState<boolean | null>(null);
const [branchLeaderboard, setBranchLeaderboard] = useState<any[]>([]);
const [isToggling, setIsToggling] = useState(false);
const [isTogglingResults, setIsTogglingResults] = useState(false);

const [feedbackData, setFeedbackData] = useState<FeedbackData | null>(null);
const [leaderboard, setLeaderboard] = useState<any[]>([]);
Expand Down Expand Up @@ -42,6 +45,9 @@ export default function GamePage() {
if (data && typeof data.isGameEnabled === "boolean") {
setIsGameEnabled(data.isGameEnabled);
}
if (data && typeof data.isResultsPublished === "boolean") {
setIsResultsPublished(data.isResultsPublished);
}
if (data && data.branchTop10) {
setBranchTop10(data.branchTop10);
}
Expand Down Expand Up @@ -251,6 +257,31 @@ export default function GamePage() {
}
};

const handleToggleResults = async () => {
setIsTogglingResults(true);
try {
const { data: { session } } = await client.auth.getSession();

const res = await fetch("/api/game/toggle", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session?.access_token || ""}`
},
body: JSON.stringify({ action: "toggleResults", resultsPublished: !isResultsPublished })
});
if (res.ok) {
setIsResultsPublished(!isResultsPublished);
} else {
console.error("Toggle results failed with status:", res.status);
}
} catch (err) {
console.error("Failed to toggle results", err);
} finally {
setIsTogglingResults(false);
}
};

if (isLoading || roleLoading) {
return (
<div className="min-h-screen bg-[#F6F6F7] flex items-center justify-center p-4">
Expand All @@ -259,27 +290,65 @@ export default function GamePage() {
);
}

// Hide the game for standard users if disabled globally
if (isGameEnabled === false && !isPanelist) {
return <NotFound />;
const AdminControls = () => {
if (!isPanelist || isGameEnabled === null) return null;
return (
<div className="absolute top-4 left-4 z-50 flex flex-col gap-3">
<button
onClick={handleToggleGame}
disabled={isToggling}
className={`px-6 py-2 rounded-full font-bold text-white shadow-lg transition-opacity ${isToggling ? 'opacity-50' : ''} ${isGameEnabled ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'}`}
>
{isToggling ? "Toggling..." : isGameEnabled ? "Disable Game Globally" : "Enable Game Globally"}
</button>
{isGameEnabled === false && (
<button
onClick={handleToggleResults}
disabled={isTogglingResults}
className={`px-6 py-2 rounded-full font-bold text-white shadow-lg transition-opacity ${isTogglingResults ? 'opacity-50' : ''} ${isResultsPublished ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-500 hover:bg-blue-600'}`}
>
{isTogglingResults ? "Toggling..." : isResultsPublished ? "Hide Results View" : "Publish Results View"}
</button>
)}
</div>
);
};

// Handle disabled game states
if (isGameEnabled === false) {
if (isResultsPublished) {
return (
<div className="min-h-screen bg-slate-50 relative">
<AdminControls />
<ResultsView branchTop10={branchTop10} />
</div>
);
} else {
if (!isPanelist) {
return <NotFound />;
}
// Panelist: game is disabled, results not yet published — show holding screen with controls
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-4 relative">
<AdminControls />
<div className="text-center max-w-md mt-16">
<div className="text-blue-600 font-mono text-xs tracking-[0.3em] uppercase mb-4 border border-blue-200 bg-blue-50 px-4 py-1.5 rounded-full inline-block">
[ PANELIST VIEW ]
</div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Game is Disabled</h2>
<p className="text-slate-500 text-sm">Results are not published yet. Use the controls above to publish results when ready.</p>
</div>
</div>
);
}
}



if (!feedbackData && !isReturningUser) {
return (
<div className="min-h-screen bg-[#F6F6F7] flex flex-col items-center justify-center p-4 relative">
{isPanelist && isGameEnabled !== null && (
<div className="absolute top-4 left-4 z-50">
<button
onClick={handleToggleGame}
disabled={isToggling}
className={`px-6 py-2 rounded-full font-bold text-white shadow-sm transition-opacity ${isToggling ? 'opacity-50' : ''} ${isGameEnabled ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'}`}
>
{isToggling ? "Toggling..." : isGameEnabled ? "Disable Game Globally" : "Enable Game Globally"}
</button>
</div>
)}
<AdminControls />
<div className="w-full max-w-3xl bg-white rounded-2xl shadow-sm border p-6 md:p-8 mt-16">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 mb-2">Play Dino Run</h1>
Expand Down Expand Up @@ -341,17 +410,7 @@ export default function GamePage() {

return (
<div className="min-h-screen bg-[#061820] flex flex-col items-center">
{isPanelist && isGameEnabled !== null && (
<div className="absolute top-4 left-4 z-50">
<button
onClick={handleToggleGame}
disabled={isToggling}
className={`px-6 py-2 rounded-full font-bold text-white shadow-sm transition-opacity ${isToggling ? 'opacity-50' : ''} ${isGameEnabled ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'}`}
>
{isToggling ? "Toggling..." : isGameEnabled ? "Disable Game Globally" : "Enable Game Globally"}
</button>
</div>
)}
<AdminControls />
<div className="w-full relative">
<DinoGame key={gameKey} onGameOver={handleGameOver} needsCooldown={needsCooldown} onPlayAgain={handlePlayAgain} />

Expand All @@ -371,6 +430,211 @@ export default function GamePage() {
);
}

function ResultsView({ branchTop10 }: { branchTop10: Record<string, any[]> }) {
const [currentSlide, setCurrentSlide] = useState(0);

// Extract the #1 player from each branch
const winners = Object.entries(branchTop10)
.map(([branchName, players]) => {
return {
branch: branchName,
player: players[0] // The top player is always index 0 because it's sorted in the backend
};
})
.filter(w => w.player) // Make sure a player exists
.sort((a, b) => b.player.score - a.player.score); // Sort winners globally

const topWinner = winners.length > 0 ? winners[0] : null;
const [featuredBranch, setFeaturedBranch] = useState<string | null>(null);

useEffect(() => {
if (winners.length > 0 && !featuredBranch) {
setFeaturedBranch(winners[0].branch);
}
}, [winners, featuredBranch]);

useEffect(() => {
if (currentSlide === 0) {
const timer = setTimeout(() => setCurrentSlide(1), 5000);
return () => clearTimeout(timer);
} else if (currentSlide === 1) {
const timer = setTimeout(() => setCurrentSlide(2), 5000);
return () => clearTimeout(timer);
}
}, [currentSlide]);

const featuredWinner = winners.find(w => w.branch === featuredBranch) || topWinner;
const gridWinners = winners.filter(w => w.branch !== featuredBranch);

return (
<div className="w-full min-h-screen bg-slate-50 text-slate-900 flex flex-col items-center overflow-hidden font-sans relative">
{/* Background Tech Grid */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.1)_1px,transparent_1px)] bg-[size:30px_30px] [mask-image:radial-gradient(ellipse_100%_100%_at_50%_0%,#000_10%,transparent_100%)] pointer-events-none z-0"></div>

<AnimatePresence mode="wait">
{currentSlide === 0 && (
<motion.div
key="slide0"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex-1 flex flex-col items-center justify-center p-4 max-w-3xl text-center z-10 w-full min-h-[80vh]"
>
<div className="text-blue-600 font-mono text-sm tracking-[0.3em] uppercase mb-8 border border-blue-200 bg-blue-50/50 px-4 py-1.5 rounded-full inline-block">
[ SYSTEM INITIALIZATION ]
</div>
<h2 className="text-4xl md:text-6xl font-extrabold text-slate-900 tracking-tight leading-tight">
We engineered this challenge to test your absolute limits.
</h2>
<button onClick={() => setCurrentSlide(2)} className="mt-12 text-slate-400 hover:text-blue-600 font-mono text-sm tracking-wider transition-colors">
&gt; SKIP_SEQUENCE
</button>
</motion.div>
)}

{currentSlide === 1 && (
<motion.div
key="slide1"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex-1 flex flex-col items-center justify-center p-4 max-w-3xl text-center z-10 w-full min-h-[80vh]"
>
<div className="text-blue-600 font-mono text-sm tracking-[0.3em] uppercase mb-8 border border-blue-200 bg-blue-50/50 px-4 py-1.5 rounded-full inline-block">
[ DATA COMPILED ]
</div>
<h2 className="text-4xl md:text-6xl font-extrabold text-slate-900 tracking-tight leading-tight">
Hundreds played.
<br/><span className="text-blue-600">But a few conquered.</span>
</h2>
<p className="text-slate-500 text-xl mt-6 tracking-wide">
It is time to reveal the Grand Champions of each branch.
</p>
<button onClick={() => setCurrentSlide(2)} className="mt-12 text-slate-400 hover:text-blue-600 font-mono text-sm tracking-wider transition-colors">
&gt; SKIP_SEQUENCE
</button>
</motion.div>
)}

{currentSlide === 2 && (
<motion.div
key="slide2"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="w-full max-w-7xl mx-auto px-4 py-16 flex flex-col items-center z-10"
>
<div className="text-center mb-16 relative w-full">
<div className="absolute inset-0 bg-blue-500/5 blur-[100px] rounded-full"></div>
<h1 className="relative text-5xl md:text-7xl font-extrabold text-slate-900 mb-4 tracking-tight drop-shadow-sm">
GRAND CHAMPIONS
</h1>
<p className="relative text-xl md:text-2xl text-blue-600 font-mono tracking-widest uppercase font-semibold">The Best of Each Branch</p>
</div>

{featuredWinner && (
<motion.div
layoutId={`card-${featuredWinner.branch}`}
className="w-full max-w-4xl bg-white border border-blue-200 rounded-3xl p-8 md:p-12 shadow-2xl relative overflow-hidden flex flex-col mb-16 group"
>
{/* Cool swirling blue light border effect via a massive pseudo-element that spins behind a mask */}
<div className="absolute -inset-[150%] bg-[conic-gradient(from_0deg,transparent_0_340deg,rgba(59,130,246,0.3)_360deg)] animate-[spin_4s_linear_infinite] pointer-events-none transition-opacity duration-500"></div>

{/* The actual interior background that blocks the swirl from showing anywhere except the borders */}
<div className="absolute inset-1 bg-white rounded-[22px] z-0"></div>

{/* Tech Accents for the top card */}
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-50/50 rounded-full blur-3xl -mr-32 -mt-32 z-0"></div>

<div className="relative z-10 flex flex-col items-center text-center">
<motion.div layoutId={`rank-${featuredWinner.branch}`} className="flex flex-wrap justify-center items-center gap-4 mb-8">
<span className="text-blue-700 bg-blue-50 font-mono text-sm tracking-widest uppercase px-3 py-1.5 rounded-lg border border-blue-100 font-semibold shadow-sm">
Global Rank #{featuredWinner.player.globalRank}
</span>
<span className="text-slate-300 font-mono text-sm hidden sm:inline">|</span>
<span className="text-slate-700 font-mono text-xl font-bold bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100">{featuredWinner.player.score.toLocaleString()} PTS</span>
</motion.div>

<motion.h3 layoutId={`name-${featuredWinner.branch}`} className="text-4xl md:text-6xl font-extrabold text-slate-900 mb-8 tracking-tight leading-tight">
{featuredWinner.player.name}
</motion.h3>

{/* Easter Egg: Only show this specific lore for the true Global Rank #1 */}
{featuredWinner.player.globalRank === 1 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-blue-50/50 border border-blue-100 rounded-xl p-6 max-w-2xl w-full mb-8 relative overflow-hidden"
>
<div className="absolute top-0 left-0 w-1 h-full bg-blue-500"></div>
<div className="text-blue-600 font-mono text-xs tracking-[0.2em] uppercase mb-3 font-bold flex items-center justify-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
[ SYSTEM ANOMALY DETECTED ]
</div>
<p className="text-slate-600 font-mono text-sm leading-relaxed">
&gt; LOG: This user found a matrix exploit to achieve this score. <br/>
&gt; STATUS: Genius enough to execute it. Absolute Respect.
</p>
</motion.div>
)}

<div className="mt-4 pt-8 border-t border-slate-100 w-full flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<motion.p layoutId={`branch-${featuredWinner.branch}`} className="text-lg font-bold text-slate-600 uppercase tracking-widest flex items-center gap-3">
<span className="w-3 h-3 rounded-full bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.4)]"></span>
{featuredWinner.branch}
</motion.p>

<div className="text-slate-400 font-mono text-xs tracking-[0.2em]">
[ DATA VERIFIED ]
</div>
</div>
</div>
</motion.div>
)}

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{gridWinners.map((winner) => (
<motion.div
key={winner.branch}
layoutId={`card-${winner.branch}`}
onClick={() => setFeaturedBranch(winner.branch)}
className="bg-white border border-slate-200 hover:border-blue-300 rounded-2xl p-6 shadow-sm hover:shadow-xl transition-all cursor-pointer flex flex-col group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50/50 rounded-full blur-3xl -mr-16 -mt-16 group-hover:bg-blue-100 transition-colors"></div>

<motion.div layoutId={`rank-${winner.branch}`} className="flex items-center justify-between mb-6 relative z-10">
<span className="text-slate-500 font-mono text-xs tracking-widest uppercase bg-slate-50 px-2 py-1 rounded border border-slate-100">
Global #{winner.player.globalRank}
</span>
<span className="text-blue-600 font-bold font-mono text-xl">{winner.player.score.toLocaleString()}</span>
</motion.div>

<motion.h3 layoutId={`name-${winner.branch}`} className="text-2xl font-bold text-slate-900 mb-2 relative z-10 truncate" title={winner.player.name}>
{winner.player.name}
</motion.h3>

<div className="mt-auto pt-6 border-t border-slate-100 relative z-10">
<motion.p layoutId={`branch-${winner.branch}`} className="text-sm font-semibold text-slate-500 uppercase tracking-widest flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
{winner.branch}
</motion.p>
</div>
</motion.div>
))}
</div>

{winners.length === 0 && (
<div className="text-slate-400 font-mono text-lg animate-pulse mt-12">[ NO DATA_FOUND ]</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

// Extracted Leaderboard UI into a small component so we can reuse it easily
function LeaderboardSection({ leaderboard, branchTop10, leaderboardRef }: { leaderboard: any[], branchTop10: Record<string, any[]>, leaderboardRef: any }) {
const [selectedBranch, setSelectedBranch] = useState<string>("Overall");
Expand Down
Loading
Loading