diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7e3d160
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.git
+.next
+node_modules
+npm-debug.log
+.env*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b7b9b4e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+FROM node:20-bookworm-slim AS deps
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends openssl ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM deps AS builder
+COPY . .
+ENV DATABASE_URL=postgresql://user:password@db:5432/binouz
+RUN npx prisma generate
+RUN npm run build
+
+FROM node:20-bookworm-slim AS runner
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends openssl ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/prisma ./prisma
+
+EXPOSE 3000
+
+CMD ["npm", "run", "start"]
diff --git a/README.md b/README.md
index e215bc4..3f3bb22 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,77 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# BinouzUHC
-## Getting Started
+Modern Minecraft UHC landing page with Discord authentication, admin panel, and a ready-to-docker stack.
-First, run the development server:
+## Stack
+
+- Next.js (App Router) + TypeScript
+- Tailwind CSS
+- Prisma + PostgreSQL
+- NextAuth (Discord OAuth)
+- Docker / docker-compose
+
+## Quick start (Docker)
+
+1. Copy env file:
```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
+cp .env.example .env
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+2. Fill the values in `.env`:
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+```txt
+NEXTAUTH_SECRET=
+NEXTAUTH_URL=http://localhost:3000
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
-## Learn More
+DATABASE_URL=postgresql://user:password@db:5432/binouz
-To learn more about Next.js, take a look at the following resources:
+ADMIN_DISCORD_ID=
+```
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+3. Build and run:
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+```bash
+docker compose up --build
+```
-## Deploy on Vercel
+4. (Optional) Seed initial grades/events:
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+```bash
+docker compose exec app npm run db:seed
+```
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+Open http://localhost:3000.
+
+## Admin access
+
+- Set `ADMIN_DISCORD_ID` in `.env` to the Discord user ID that should be admin.
+- First sign-in will automatically register the user and grant admin access.
+- Admin panel: `/admin`.
+
+## Local development (no Docker)
+
+```bash
+npm install
+npm run prisma:migrate
+npm run dev
+```
+
+## Project structure
+
+```
+app/ App Router pages, layouts, API routes
+components/ UI and dashboard components
+lib/ Auth, db, helpers
+prisma/ Prisma schema and seed
+docker/ Docker notes and future overrides
+```
+
+## Notes
+
+- The purchase flow is mocked and ready for Stripe integration.
+- Discord profile data is stored on sign-in (id, username, avatar).
+- Environment variables are required for OAuth and database access.
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..ad7e13c
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,104 @@
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import AdminDashboard from "@/components/admin/admin-dashboard";
+import Footer from "@/components/footer";
+import Navbar from "@/components/navbar";
+import { authOptions } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+type AdminWithUser = {
+ id: string;
+ discordId: string;
+ user: {
+ name?: string | null;
+ email?: string | null;
+ discordUsername?: string | null;
+ } | null;
+};
+
+type GradeRecord = {
+ id: string;
+ name: string;
+ price: number;
+ description: string;
+};
+
+type EventRecord = {
+ id: string;
+ title: string;
+ description: string;
+ eventDate: Date;
+};
+
+export default async function AdminPage() {
+ const session = await getServerSession(authOptions);
+ const discordId = session?.user?.discordId;
+
+ if (!discordId) {
+ redirect("/auth/signin");
+ }
+
+ const envAdmin = process.env.ADMIN_DISCORD_ID;
+ const adminRecord = envAdmin && envAdmin === discordId
+ ? null
+ : await db.admin.findUnique({ where: { discordId } });
+
+ if (!adminRecord && (!envAdmin || envAdmin !== discordId)) {
+ redirect("/");
+ }
+
+ const [grades, events, admins] = await Promise.all([
+ db.grade.findMany({ orderBy: { price: "asc" } }),
+ db.event.findMany({ orderBy: { eventDate: "asc" } }),
+ db.admin.findMany({
+ orderBy: { createdAt: "desc" },
+ include: { user: true },
+ }),
+ ] as const);
+
+ const adminUsers = admins as AdminWithUser[];
+ const gradeRecords = grades as GradeRecord[];
+ const eventRecords = events as EventRecord[];
+
+ return (
+
+
+
+
+
+ Admin panel
+
+
+ Control center
+
+
+ Manage admins, grades, and events for BinouzUHC.
+
+
+ ({
+ id: admin.id,
+ discordId: admin.discordId,
+ user: admin.user
+ ? {
+ name: admin.user.name,
+ email: admin.user.email,
+ discordUsername: admin.user.discordUsername,
+ }
+ : null,
+ }))}
+ initialGrades={gradeRecords}
+ initialEvents={eventRecords.map((event) => ({
+ id: event.id,
+ title: event.title,
+ description: event.description,
+ eventDate: event.eventDate.toISOString(),
+ }))}
+ />
+
+
+
+ );
+}
diff --git a/app/api/admin/admins/[id]/route.ts b/app/api/admin/admins/[id]/route.ts
new file mode 100644
index 0000000..a52386c
--- /dev/null
+++ b/app/api/admin/admins/[id]/route.ts
@@ -0,0 +1,17 @@
+import type { NextRequest } from "next/server";
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const { id } = await params;
+ await db.admin.delete({ where: { id } });
+ return Response.json({ ok: true });
+}
diff --git a/app/api/admin/admins/route.ts b/app/api/admin/admins/route.ts
new file mode 100644
index 0000000..cea8789
--- /dev/null
+++ b/app/api/admin/admins/route.ts
@@ -0,0 +1,73 @@
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+type AdminWithUser = {
+ id: string;
+ discordId: string;
+ user: {
+ name?: string | null;
+ email?: string | null;
+ discordUsername?: string | null;
+ } | null;
+};
+
+export async function GET() {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const admins = await db.admin.findMany({
+ orderBy: { createdAt: "desc" },
+ include: { user: true },
+ });
+
+ const adminUsers = admins as AdminWithUser[];
+
+ return Response.json(
+ adminUsers.map((admin) => ({
+ id: admin.id,
+ discordId: admin.discordId,
+ user: admin.user
+ ? {
+ name: admin.user.name,
+ email: admin.user.email,
+ discordUsername: admin.user.discordUsername,
+ }
+ : null,
+ }))
+ );
+}
+
+export async function POST(request: Request) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const body = await request.json();
+ const discordId = body?.discordId?.toString().trim();
+
+ if (!discordId) {
+ return Response.json({ error: "Discord ID required" }, { status: 400 });
+ }
+
+ const user = await db.user.findUnique({ where: { discordId } });
+ if (!user) {
+ return Response.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const admin = await db.admin.upsert({
+ where: { discordId },
+ update: {},
+ create: { discordId, userId: user.id },
+ });
+
+ return Response.json({
+ id: admin.id,
+ discordId: admin.discordId,
+ user: {
+ name: user.name,
+ email: user.email,
+ discordUsername: user.discordUsername,
+ },
+ });
+}
diff --git a/app/api/admin/events/[id]/route.ts b/app/api/admin/events/[id]/route.ts
new file mode 100644
index 0000000..ae63c1f
--- /dev/null
+++ b/app/api/admin/events/[id]/route.ts
@@ -0,0 +1,49 @@
+import type { NextRequest } from "next/server";
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const { id } = await params;
+
+ const body = await request.json();
+ const title = body?.title?.toString().trim();
+ const description = body?.description?.toString().trim();
+ const eventDateValue = body?.eventDate?.toString();
+ const eventDate = eventDateValue ? new Date(eventDateValue) : null;
+
+ if (!title || !description || !eventDate || Number.isNaN(eventDate.getTime())) {
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const event = await db.event.update({
+ where: { id },
+ data: { title, description, eventDate },
+ });
+
+ return Response.json({
+ id: event.id,
+ title: event.title,
+ description: event.description,
+ eventDate: event.eventDate.toISOString(),
+ });
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const { id } = await params;
+ await db.event.delete({ where: { id } });
+ return Response.json({ ok: true });
+}
diff --git a/app/api/admin/events/route.ts b/app/api/admin/events/route.ts
new file mode 100644
index 0000000..7528590
--- /dev/null
+++ b/app/api/admin/events/route.ts
@@ -0,0 +1,38 @@
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const events = await db.event.findMany({ orderBy: { eventDate: "asc" } });
+ return Response.json(events);
+}
+
+export async function POST(request: Request) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const body = await request.json();
+ const title = body?.title?.toString().trim();
+ const description = body?.description?.toString().trim();
+ const eventDateValue = body?.eventDate?.toString();
+ const eventDate = eventDateValue ? new Date(eventDateValue) : null;
+
+ if (!title || !description || !eventDate || Number.isNaN(eventDate.getTime())) {
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const event = await db.event.create({
+ data: { title, description, eventDate },
+ });
+
+ return Response.json({
+ id: event.id,
+ title: event.title,
+ description: event.description,
+ eventDate: event.eventDate.toISOString(),
+ });
+}
diff --git a/app/api/admin/grades/[id]/route.ts b/app/api/admin/grades/[id]/route.ts
new file mode 100644
index 0000000..206f988
--- /dev/null
+++ b/app/api/admin/grades/[id]/route.ts
@@ -0,0 +1,43 @@
+import type { NextRequest } from "next/server";
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const { id } = await params;
+
+ const body = await request.json();
+ const name = body?.name?.toString().trim();
+ const price = Number(body?.price);
+ const description = body?.description?.toString().trim();
+
+ if (!name || Number.isNaN(price) || !description) {
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const grade = await db.grade.update({
+ where: { id },
+ data: { name, price, description },
+ });
+
+ return Response.json(grade);
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const { id } = await params;
+ await db.grade.delete({ where: { id } });
+ return Response.json({ ok: true });
+}
diff --git a/app/api/admin/grades/route.ts b/app/api/admin/grades/route.ts
new file mode 100644
index 0000000..c7667ff
--- /dev/null
+++ b/app/api/admin/grades/route.ts
@@ -0,0 +1,32 @@
+import { db } from "@/lib/db";
+import { requireAdmin } from "@/lib/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const grades = await db.grade.findMany({ orderBy: { price: "asc" } });
+ return Response.json(grades);
+}
+
+export async function POST(request: Request) {
+ const guard = await requireAdmin();
+ if (!guard.ok) return guard.response;
+
+ const body = await request.json();
+ const name = body?.name?.toString().trim();
+ const price = Number(body?.price);
+ const description = body?.description?.toString().trim();
+
+ if (!name || Number.isNaN(price) || !description) {
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const grade = await db.grade.create({
+ data: { name, price, description },
+ });
+
+ return Response.json(grade);
+}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..7b38c1b
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,6 @@
+import NextAuth from "next-auth";
+import { authOptions } from "@/lib/auth";
+
+const handler = NextAuth(authOptions);
+
+export { handler as GET, handler as POST };
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
new file mode 100644
index 0000000..a3006af
--- /dev/null
+++ b/app/api/checkout/route.ts
@@ -0,0 +1,97 @@
+import Stripe from "stripe";
+import { db } from "@/lib/db";
+import { fallbackGrades } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
+const stripe = stripeSecretKey
+ ? new Stripe(stripeSecretKey, {
+ apiVersion: "2023-10-16",
+ })
+ : null;
+
+const minecraftNameRegex = /^[A-Za-z0-9_]{3,16}$/;
+
+type GradeInfo = {
+ id: string;
+ name: string;
+ price: number;
+ description: string;
+};
+
+const getGradeById = async (gradeId: string): Promise => {
+ try {
+ const grade = await db.grade.findUnique({ where: { id: gradeId } });
+ if (grade) {
+ return {
+ id: grade.id,
+ name: grade.name,
+ price: grade.price,
+ description: grade.description,
+ };
+ }
+ } catch {
+ // ignore db errors and fall back to static data
+ }
+
+ return fallbackGrades.find((grade) => grade.id === gradeId) ?? null;
+};
+
+export async function POST(request: Request) {
+ const body = await request.json().catch(() => ({}));
+ const gradeId = body?.gradeId?.toString();
+ const minecraftUsername = body?.minecraftUsername?.toString().trim();
+
+ if (!gradeId || !minecraftUsername) {
+ return Response.json({ error: "Missing checkout details." }, { status: 400 });
+ }
+
+ if (!minecraftNameRegex.test(minecraftUsername)) {
+ return Response.json(
+ { error: "Invalid Minecraft username." },
+ { status: 400 }
+ );
+ }
+
+ const grade = await getGradeById(gradeId);
+ if (!grade) {
+ return Response.json({ error: "Grade not found." }, { status: 404 });
+ }
+
+ if (!stripe) {
+ return Response.json({ error: "Stripe not configured." }, { status: 500 });
+ }
+
+ const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
+ const session = await stripe.checkout.sessions.create({
+ mode: "payment",
+ payment_method_types: ["card"],
+ success_url: `${baseUrl}/?checkout=success`,
+ cancel_url: `${baseUrl}/?checkout=cancel`,
+ line_items: [
+ {
+ price_data: {
+ currency: "eur",
+ unit_amount: Math.round(grade.price * 100),
+ product_data: {
+ name: grade.name,
+ description: grade.description,
+ },
+ },
+ quantity: 1,
+ },
+ ],
+ metadata: {
+ gradeId: grade.id,
+ minecraftUsername,
+ },
+ client_reference_id: minecraftUsername,
+ });
+
+ if (!session.url) {
+ return Response.json({ error: "Stripe session failed." }, { status: 500 });
+ }
+
+ return Response.json({ url: session.url });
+}
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
new file mode 100644
index 0000000..0b501cd
--- /dev/null
+++ b/app/api/events/route.ts
@@ -0,0 +1,8 @@
+import { db } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const events = await db.event.findMany({ orderBy: { eventDate: "asc" } });
+ return Response.json(events);
+}
diff --git a/app/api/grades/route.ts b/app/api/grades/route.ts
new file mode 100644
index 0000000..759e607
--- /dev/null
+++ b/app/api/grades/route.ts
@@ -0,0 +1,8 @@
+import { db } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const grades = await db.grade.findMany({ orderBy: { price: "asc" } });
+ return Response.json(grades);
+}
diff --git a/app/api/purchases/route.ts b/app/api/purchases/route.ts
new file mode 100644
index 0000000..b2ae244
--- /dev/null
+++ b/app/api/purchases/route.ts
@@ -0,0 +1,38 @@
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(request: Request) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const gradeId = body?.gradeId as string | undefined;
+ if (!gradeId) {
+ return Response.json({ error: "Grade id required" }, { status: 400 });
+ }
+
+ const grade = await db.grade.findUnique({ where: { id: gradeId } });
+ if (!grade) {
+ return Response.json({ error: "Grade not found" }, { status: 404 });
+ }
+
+ const purchase = await db.purchase.create({
+ data: {
+ userId: session.user.id,
+ gradeId: grade.id,
+ amount: grade.price,
+ status: "PENDING",
+ },
+ });
+
+ return Response.json({
+ id: purchase.id,
+ status: purchase.status,
+ message: "Mock purchase queued",
+ });
+}
diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx
new file mode 100644
index 0000000..1b16a95
--- /dev/null
+++ b/app/auth/signin/page.tsx
@@ -0,0 +1,29 @@
+import AuthButton from "@/components/auth-button";
+import Footer from "@/components/footer";
+import Navbar from "@/components/navbar";
+
+export default function SignInPage() {
+ return (
+
+
+
+
+
+ Auth required
+
+
+ Connect your Discord account
+
+
+ Unlock your profile, purchases, and admin access if your Discord ID
+ is whitelisted.
+
+
+
+
+
+
+ );
+}
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..d9844c8 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,26 +1,83 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --bg-950: #05070d;
+ --bg-900: #0b1020;
+ --bg-800: #10172c;
+ --text-100: #e5e7eb;
+ --text-200: #cbd5f5;
+ --accent-500: #7c3aed;
+ --accent-400: #60a5fa;
+ --accent-300: #22d3ee;
+ --glass: rgba(15, 23, 42, 0.6);
+ --glass-border: rgba(148, 163, 184, 0.2);
+ --shadow-strong: 0 30px 80px rgba(2, 6, 23, 0.6);
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --color-background: var(--bg-950);
+ --color-foreground: var(--text-100);
+ --font-sans: var(--font-display);
+ --font-mono: var(--font-code);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
}
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background:
+ radial-gradient(1200px 700px at 20% 10%, rgba(124, 58, 237, 0.25),
+ transparent 60%),
+ radial-gradient(900px 600px at 80% 15%, rgba(96, 165, 250, 0.25),
+ transparent 55%),
+ var(--bg-950);
+ color: var(--text-100);
+ font-family: var(--font-display), "Space Grotesk", sans-serif;
+}
+
+::selection {
+ background: rgba(124, 58, 237, 0.55);
+ color: #0f172a;
+}
+
+@keyframes float {
+ 0% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+ 100% {
+ transform: translateY(0px);
+ }
+}
+
+@keyframes glow {
+ 0% {
+ opacity: 0.4;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+ 100% {
+ opacity: 0.4;
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 976eb90..ce6a6b0 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,33 +1,59 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { JetBrains_Mono, Space_Grotesk } from "next/font/google";
+import { getServerSession } from "next-auth";
import "./globals.css";
+import SessionProvider from "@/components/session-provider";
+import { authOptions } from "@/lib/auth";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const displayFont = Space_Grotesk({
+ variable: "--font-display",
subsets: ["latin"],
+ weight: ["400", "500", "600", "700"],
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const monoFont = JetBrains_Mono({
+ variable: "--font-code",
subsets: ["latin"],
+ weight: ["400", "600"],
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: {
+ default: "BinouzUHC - UHC PvP",
+ template: "%s | BinouzUHC",
+ },
+ description:
+ "Serveur UHC competitif et immersif. PvP nerveux, events reguliers et communaute engagee.",
+ keywords: ["Minecraft", "UHC", "PvP", "BinouzUHC", "1.8.X"],
+ openGraph: {
+ title: "BinouzUHC - UHC PvP",
+ description:
+ "Rejoignez BinouzUHC pour une experience UHC premium, events reguliers et rewards exclusifs.",
+ type: "website",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "BinouzUHC - UHC PvP",
+ description:
+ "Serveur UHC competitif et immersif. PvP nerveux, events reguliers et communaute engagee.",
+ },
};
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ const session = await getServerSession(authOptions);
+
return (
- {children}
+
+ {children}
+
);
}
diff --git a/app/page.tsx b/app/page.tsx
index 3f36f7c..20fdf82 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,65 +1,161 @@
-import Image from "next/image";
+import EventCard from "@/components/event-card";
+import Footer from "@/components/footer";
+import GradeCard from "@/components/grade-card";
+import Hero from "@/components/hero";
+import Navbar from "@/components/navbar";
+import SectionHeader from "@/components/section-header";
+import { db } from "@/lib/db";
+import { fallbackEvents, fallbackGrades, siteConfig } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+type Grade = {
+ id: string;
+ name: string;
+ price: number;
+ description: string;
+};
+
+type Event = {
+ id: string;
+ title: string;
+ description: string;
+ eventDate: Date;
+};
+
+const getGrades = async (): Promise => {
+ try {
+ const grades = await db.grade.findMany({ orderBy: { price: "asc" } });
+ return grades.length > 0 ? grades : fallbackGrades;
+ } catch {
+ return fallbackGrades;
+ }
+};
+
+const getEvents = async (): Promise => {
+ try {
+ const events = await db.event.findMany({ orderBy: { eventDate: "asc" } });
+ return events.length > 0 ? events : fallbackEvents;
+ } catch {
+ return fallbackEvents;
+ }
+};
+
+export default async function Home() {
+ const [grades, events] = await Promise.all([getGrades(), getEvents()]);
-export default function Home() {
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
+
+
+
+
+
+
+
+ {[
+ {
+ title: "Precision combat",
+ description:
+ "Hits register fast, clean hitboxes, and optimized knockback for UHC duels.",
+ },
+ {
+ title: "Event experience",
+ description:
+ "Weekly tournaments, bracket nights, and events built for squads.",
+ },
+ {
+ title: "Community core",
+ description:
+ "Active moderation, high signal Discord, and competitive rankings.",
+ },
+ ].map((item) => (
+
+
+ {item.title}
+
+
{item.description}
+
+ ))}
+
+
+
+
+
+
+
+
+ {grades.map((grade) => (
+
+ ))}
+
+
+ {siteConfig.shopDisclaimer}
+
+
+
+
+
+
+
+
+ {events.map((event) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ Discord
+
+
+ Join the BinouzUHC squad
+
+
+ News, tournaments, and admin contact. Stay synced with the
+ community.
+
+
+
+
+
+
);
}
diff --git a/components/admin/admin-dashboard.tsx b/components/admin/admin-dashboard.tsx
new file mode 100644
index 0000000..22580ab
--- /dev/null
+++ b/components/admin/admin-dashboard.tsx
@@ -0,0 +1,535 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type AdminUser = {
+ id: string;
+ discordId: string;
+ user?: {
+ name?: string | null;
+ email?: string | null;
+ discordUsername?: string | null;
+ } | null;
+};
+
+type Grade = {
+ id: string;
+ name: string;
+ price: number;
+ description: string;
+};
+
+type Event = {
+ id: string;
+ title: string;
+ description: string;
+ eventDate: string;
+};
+
+type AdminDashboardProps = {
+ initialAdmins: AdminUser[];
+ initialGrades: Grade[];
+ initialEvents: Event[];
+};
+
+const requestJson = async (
+ url: string,
+ options: RequestInit
+): Promise => {
+ const res = await fetch(url, {
+ headers: { "Content-Type": "application/json" },
+ ...options,
+ });
+ const data = (await res.json().catch(() => ({}))) as T & {
+ error?: string;
+ };
+
+ if (!res.ok) {
+ throw new Error(data.error ?? "Request failed");
+ }
+
+ return data;
+};
+
+const toInputDate = (iso: string) => {
+ return new Date(iso).toISOString().slice(0, 16);
+};
+
+export default function AdminDashboard({
+ initialAdmins,
+ initialGrades,
+ initialEvents,
+}: AdminDashboardProps) {
+ const [admins, setAdmins] = useState(initialAdmins);
+ const [grades, setGrades] = useState(initialGrades);
+ const [events, setEvents] = useState(initialEvents);
+ const [notice, setNotice] = useState(null);
+ const [isPending, startTransition] = useTransition();
+
+ const handleAddAdmin = (form: FormData) => {
+ const discordId = form.get("discordId")?.toString().trim();
+ if (!discordId) return;
+
+ startTransition(async () => {
+ try {
+ const admin = await requestJson("/api/admin/admins", {
+ method: "POST",
+ body: JSON.stringify({ discordId }),
+ });
+ setAdmins((prev) => [admin, ...prev]);
+ setNotice("Admin added");
+ } catch (error) {
+ setNotice((error as Error).message);
+ }
+ });
+ };
+
+ const handleAddGrade = (form: FormData) => {
+ const name = form.get("name")?.toString().trim();
+ const price = Number(form.get("price"));
+ const description = form.get("description")?.toString().trim();
+
+ if (!name || Number.isNaN(price) || !description) return;
+
+ startTransition(async () => {
+ try {
+ const grade = await requestJson("/api/admin/grades", {
+ method: "POST",
+ body: JSON.stringify({ name, price, description }),
+ });
+ setGrades((prev) => [...prev, grade]);
+ setNotice("Grade added");
+ } catch (error) {
+ setNotice((error as Error).message);
+ }
+ });
+ };
+
+ const handleAddEvent = (form: FormData) => {
+ const title = form.get("title")?.toString().trim();
+ const description = form.get("description")?.toString().trim();
+ const eventDate = form.get("eventDate")?.toString();
+
+ if (!title || !description || !eventDate) return;
+
+ startTransition(async () => {
+ try {
+ const event = await requestJson("/api/admin/events", {
+ method: "POST",
+ body: JSON.stringify({
+ title,
+ description,
+ eventDate: new Date(eventDate).toISOString(),
+ }),
+ });
+ setEvents((prev) => [...prev, event]);
+ setNotice("Event added");
+ } catch (error) {
+ setNotice((error as Error).message);
+ }
+ });
+ };
+
+ return (
+
+
+
Admin access
+
+ Manage admins, grades, and events. Changes are applied live.
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+
+
+
+
+
Admins
+
+
+ {admins.map((admin) => (
+
+ setAdmins((prev) => prev.filter((item) => item.id !== id))
+ }
+ />
+ ))}
+
+
+
+
Create grade
+
+
+
+
+
+ Grades
+
+ {grades.map((grade) => (
+
+ setGrades((prev) =>
+ prev.map((item) =>
+ item.id === updated.id ? updated : item
+ )
+ )
+ }
+ onDelete={(id) =>
+ setGrades((prev) => prev.filter((item) => item.id !== id))
+ }
+ />
+ ))}
+
+
+
+
+
+ );
+}
+
+function AdminRow({
+ admin,
+ onDelete,
+}: {
+ admin: AdminUser;
+ onDelete: (id: string) => void;
+}) {
+ const [isPending, startTransition] = useTransition();
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ try {
+ await requestJson<{ ok: boolean }>(`/api/admin/admins/${admin.id}`, {
+ method: "DELETE",
+ });
+ onDelete(admin.id);
+ } catch {
+ // no-op
+ }
+ });
+ };
+
+ return (
+
+
+
+ {admin.user?.discordUsername ?? admin.user?.name ?? "Unknown"}
+
+
{admin.discordId}
+
+
+
+ );
+}
+
+function GradeRow({
+ grade,
+ onUpdate,
+ onDelete,
+}: {
+ grade: Grade;
+ onUpdate: (grade: Grade) => void;
+ onDelete: (id: string) => void;
+}) {
+ const [draft, setDraft] = useState(grade);
+ const [isPending, startTransition] = useTransition();
+
+ const handleSave = () => {
+ startTransition(async () => {
+ try {
+ const updated = await requestJson(
+ `/api/admin/grades/${grade.id}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ name: draft.name,
+ price: draft.price,
+ description: draft.description,
+ }),
+ }
+ );
+ onUpdate(updated);
+ } catch {
+ // no-op
+ }
+ });
+ };
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ try {
+ await requestJson<{ ok: boolean }>(`/api/admin/grades/${grade.id}`, {
+ method: "DELETE",
+ });
+ onDelete(grade.id);
+ } catch {
+ // no-op
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+function EventRow({
+ event,
+ onUpdate,
+ onDelete,
+}: {
+ event: Event;
+ onUpdate: (event: Event) => void;
+ onDelete: (id: string) => void;
+}) {
+ const [draft, setDraft] = useState({
+ ...event,
+ eventDate: toInputDate(event.eventDate),
+ });
+ const [isPending, startTransition] = useTransition();
+
+ const handleSave = () => {
+ startTransition(async () => {
+ try {
+ const updated = await requestJson(
+ `/api/admin/events/${event.id}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ title: draft.title,
+ description: draft.description,
+ eventDate: new Date(draft.eventDate).toISOString(),
+ }),
+ }
+ );
+ onUpdate(updated);
+ } catch {
+ // no-op
+ }
+ });
+ };
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ try {
+ await requestJson<{ ok: boolean }>(`/api/admin/events/${event.id}`, {
+ method: "DELETE",
+ });
+ onDelete(event.id);
+ } catch {
+ // no-op
+ }
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/components/auth-button.tsx b/components/auth-button.tsx
new file mode 100644
index 0000000..3fc359f
--- /dev/null
+++ b/components/auth-button.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import Link from "next/link";
+import { signIn, signOut, useSession } from "next-auth/react";
+
+type AuthButtonProps = {
+ className?: string;
+ label?: string;
+ compact?: boolean;
+};
+
+export default function AuthButton({
+ className = "",
+ label = "Se connecter avec Discord",
+ compact = false,
+}: AuthButtonProps) {
+ const { data: session, status } = useSession();
+
+ if (status === "loading") {
+ return (
+
+
+ Loading
+
+ );
+ }
+
+ if (!session?.user) {
+ return (
+
+ );
+ }
+
+ const avatarUrl = session.user.discordAvatar && session.user.discordId
+ ? `https://cdn.discordapp.com/avatars/${session.user.discordId}/${session.user.discordAvatar}.png`
+ : session.user.image ?? "";
+
+ return (
+
+ {avatarUrl ? (
+

+ ) : (
+
+ )}
+
+
+ {session.user.discordUsername ?? session.user.name ?? "Player"}
+
+
+ {session.user.isAdmin ? "Admin" : "Connected"}
+
+
+
+ {session.user.isAdmin ? (
+
+ Admin
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/components/copy-button.tsx b/components/copy-button.tsx
new file mode 100644
index 0000000..6521872
--- /dev/null
+++ b/components/copy-button.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from "react";
+
+type CopyButtonProps = {
+ value: string;
+ label?: string;
+ className?: string;
+};
+
+export default function CopyButton({
+ value,
+ label = "Copy IP",
+ className = "",
+}: CopyButtonProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/components/event-card.tsx b/components/event-card.tsx
new file mode 100644
index 0000000..0d05f9a
--- /dev/null
+++ b/components/event-card.tsx
@@ -0,0 +1,22 @@
+import { formatEventDate } from "@/lib/format";
+
+type EventCardProps = {
+ event: {
+ id: string;
+ title: string;
+ description: string;
+ eventDate: Date;
+ };
+};
+
+export default function EventCard({ event }: EventCardProps) {
+ return (
+
+
+ {formatEventDate(event.eventDate)}
+
+
{event.title}
+
{event.description}
+
+ );
+}
diff --git a/components/footer.tsx b/components/footer.tsx
new file mode 100644
index 0000000..1803f7e
--- /dev/null
+++ b/components/footer.tsx
@@ -0,0 +1,35 @@
+import { siteConfig } from "@/lib/site";
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/components/grade-card.tsx b/components/grade-card.tsx
new file mode 100644
index 0000000..be5f013
--- /dev/null
+++ b/components/grade-card.tsx
@@ -0,0 +1,33 @@
+import PurchaseButton from "@/components/purchase-button";
+import { formatPrice } from "@/lib/format";
+
+type GradeCardProps = {
+ grade: {
+ id: string;
+ name: string;
+ price: number;
+ description: string;
+ };
+};
+
+export default function GradeCard({ grade }: GradeCardProps) {
+ return (
+
+
+
+ {grade.name}
+
+
+ {formatPrice(grade.price)}
+
+
{grade.description}
+
+
+
+ );
+}
diff --git a/components/hero.tsx b/components/hero.tsx
new file mode 100644
index 0000000..4b9408d
--- /dev/null
+++ b/components/hero.tsx
@@ -0,0 +1,75 @@
+import CopyButton from "@/components/copy-button";
+import { siteConfig } from "@/lib/site";
+
+const quickStats = [
+ {
+ title: "Competitive UHC",
+ detail: "UHC 1.8.X, PvP focus",
+ },
+ {
+ title: "Events weekly",
+ detail: "Tournaments and rush",
+ },
+ {
+ title: "Premium rewards",
+ detail: "Grades, cosmetics, perks",
+ },
+];
+
+export default function Hero() {
+ return (
+
+
+
+
+
+
+
+ BinouzUHC - Minecraft 1.8.X
+
+
+ Immerse yourself in elite UHC PvP.
+
+
+ {siteConfig.description}
+
+
+
+ {siteConfig.serverAddress}
+ 1.8.X
+ Discord ready
+
+
+
+ {quickStats.map((item) => (
+
+
+ {item.title}
+
+
{item.detail}
+
+ ))}
+
+
+ Drop in
+
+
+ Ranked, balanced, and tuned for competitive squads.
+
+
+
+
+
+ );
+}
diff --git a/components/navbar.tsx b/components/navbar.tsx
new file mode 100644
index 0000000..fef59d7
--- /dev/null
+++ b/components/navbar.tsx
@@ -0,0 +1,49 @@
+import Link from "next/link";
+import AuthButton from "@/components/auth-button";
+
+const navLinks = [
+ { label: "Presentation", href: "#presentation" },
+ { label: "Grades", href: "#grades" },
+ { label: "Events", href: "#events" },
+ { label: "Discord", href: "#discord" },
+];
+
+export default function Navbar() {
+ return (
+
+ );
+}
diff --git a/components/purchase-button.tsx b/components/purchase-button.tsx
new file mode 100644
index 0000000..37e3c0c
--- /dev/null
+++ b/components/purchase-button.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useState } from "react";
+
+type PurchaseButtonProps = {
+ gradeId: string;
+};
+
+type PurchaseState = "idle" | "loading" | "error";
+
+const minecraftNameRegex = /^[A-Za-z0-9_]{3,16}$/;
+
+export default function PurchaseButton({ gradeId }: PurchaseButtonProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [username, setUsername] = useState("");
+ const [state, setState] = useState("idle");
+ const [error, setError] = useState(null);
+
+ const openModal = () => {
+ setIsOpen(true);
+ setError(null);
+ };
+
+ const closeModal = () => {
+ setIsOpen(false);
+ setState("idle");
+ setError(null);
+ };
+
+ const handleCheckout = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmed = username.trim();
+
+ if (!minecraftNameRegex.test(trimmed)) {
+ setError("Enter a valid Minecraft username (3-16, letters, numbers, _).");
+ return;
+ }
+
+ setState("loading");
+ setError(null);
+
+ try {
+ const res = await fetch("/api/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ gradeId, minecraftUsername: trimmed }),
+ });
+
+ const data = (await res.json().catch(() => ({}))) as {
+ url?: string;
+ error?: string;
+ };
+
+ if (!res.ok || !data.url) {
+ setState("error");
+ setError(data.error ?? "Checkout failed.");
+ return;
+ }
+
+ window.location.href = data.url;
+ } catch {
+ setState("error");
+ setError("Checkout failed.");
+ }
+ };
+
+ return (
+ <>
+
+
+ {isOpen ? (
+
+
event.stopPropagation()}
+ >
+
+ Checkout
+
+
+ Enter Minecraft username
+
+
+ No account needed. We will send you to Stripe.
+
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/components/section-header.tsx b/components/section-header.tsx
new file mode 100644
index 0000000..0f80dd3
--- /dev/null
+++ b/components/section-header.tsx
@@ -0,0 +1,25 @@
+type SectionHeaderProps = {
+ eyebrow: string;
+ title: string;
+ description?: string;
+};
+
+export default function SectionHeader({
+ eyebrow,
+ title,
+ description,
+}: SectionHeaderProps) {
+ return (
+
+
+ {eyebrow}
+
+
+ {title}
+
+ {description ? (
+
{description}
+ ) : null}
+
+ );
+}
diff --git a/components/session-provider.tsx b/components/session-provider.tsx
new file mode 100644
index 0000000..7cef6a8
--- /dev/null
+++ b/components/session-provider.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+import type { Session } from "next-auth";
+
+type SessionProviderProps = {
+ children: React.ReactNode;
+ session: Session | null;
+};
+
+export default function AppSessionProvider({
+ children,
+ session,
+}: SessionProviderProps) {
+ return {children};
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..da7a62f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+services:
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ healthcheck:
+ test:
+ ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+
+ app:
+ build: .
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${DATABASE_URL}
+ ports:
+ - "${WEB_PORT}:3000"
+ command: sh -c "npx prisma db push && npx prisma db seed && npm run start"
+
+volumes:
+ db_data:
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..0092a05
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,4 @@
+Docker notes
+
+- This folder is reserved for docker assets or overrides.
+- Default setup uses the root Dockerfile and docker-compose.yml.
diff --git a/lib/admin.ts b/lib/admin.ts
new file mode 100644
index 0000000..0519aca
--- /dev/null
+++ b/lib/admin.ts
@@ -0,0 +1,40 @@
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+export type AdminGuardResult =
+ | {
+ ok: true;
+ discordId: string;
+ }
+ | {
+ ok: false;
+ response: Response;
+ };
+
+export const requireAdmin = async (): Promise => {
+ const session = await getServerSession(authOptions);
+ const discordId = session?.user?.discordId;
+
+ if (!discordId) {
+ return {
+ ok: false,
+ response: Response.json({ error: "Unauthorized" }, { status: 401 }),
+ };
+ }
+
+ const envAdmin = process.env.ADMIN_DISCORD_ID;
+ if (envAdmin && envAdmin === discordId) {
+ return { ok: true, discordId };
+ }
+
+ const admin = await db.admin.findUnique({ where: { discordId } });
+ if (!admin) {
+ return {
+ ok: false,
+ response: Response.json({ error: "Forbidden" }, { status: 403 }),
+ };
+ }
+
+ return { ok: true, discordId };
+};
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..d3b25a9
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,99 @@
+import type { NextAuthOptions } from "next-auth";
+import DiscordProvider from "next-auth/providers/discord";
+import { PrismaAdapter } from "@next-auth/prisma-adapter";
+import { db } from "@/lib/db";
+
+const adminDiscordId = process.env.ADMIN_DISCORD_ID;
+const discordRedirectUrl = process.env.DISCORD_REDIRECT_URL;
+
+const resolveAdmin = async (discordId?: string) => {
+ if (!discordId) return false;
+ if (adminDiscordId && discordId === adminDiscordId) return true;
+ const admin = await db.admin.findUnique({ where: { discordId } });
+ return Boolean(admin);
+};
+
+export const authOptions: NextAuthOptions = {
+ adapter: PrismaAdapter(db),
+ session: {
+ strategy: "jwt",
+ },
+ providers: [
+ DiscordProvider({
+ clientId: process.env.DISCORD_CLIENT_ID ?? "",
+ clientSecret: process.env.DISCORD_CLIENT_SECRET ?? "",
+ authorization: {
+ params: {
+ scope: "identify email",
+ ...(discordRedirectUrl
+ ? { redirect_uri: discordRedirectUrl }
+ : {}),
+ },
+ },
+ }),
+ ],
+ callbacks: {
+ async jwt({ token, account, profile }) {
+ if (account && profile) {
+ const discordProfile = profile as {
+ id?: string;
+ username?: string;
+ avatar?: string;
+ };
+ token.discordId = discordProfile.id;
+ token.discordUsername = discordProfile.username;
+ token.discordAvatar = discordProfile.avatar;
+ }
+
+ token.isAdmin = await resolveAdmin(token.discordId as string | undefined);
+
+ return token;
+ },
+ async session({ session, token }) {
+ if (session.user) {
+ session.user.id = token.sub ?? "";
+ session.user.discordId = (token.discordId as string | undefined) ?? "";
+ session.user.discordUsername =
+ (token.discordUsername as string | undefined) ?? "";
+ session.user.discordAvatar =
+ (token.discordAvatar as string | undefined) ?? "";
+ session.user.isAdmin = token.isAdmin === true;
+ }
+ return session;
+ },
+ },
+ events: {
+ async signIn({ user, account, profile }) {
+ if (!account || account.provider !== "discord" || !profile) return;
+
+ const discordId = (profile as { id?: string }).id;
+ const discordUsername = (profile as { username?: string }).username;
+ const discordAvatar = (profile as { avatar?: string }).avatar;
+
+ if (!discordId) return;
+
+ await db.user.update({
+ where: { id: user.id },
+ data: {
+ discordId,
+ discordUsername,
+ discordAvatar,
+ },
+ });
+
+ if (adminDiscordId && discordId === adminDiscordId) {
+ await db.admin.upsert({
+ where: { discordId },
+ update: {},
+ create: {
+ discordId,
+ userId: user.id,
+ },
+ });
+ }
+ },
+ },
+ pages: {
+ signIn: "/auth/signin",
+ },
+};
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..4a371ae
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,18 @@
+import { PrismaClient } from "@prisma/client";
+
+type PrismaGlobal = typeof globalThis & {
+ prisma?: PrismaClient;
+};
+
+const globalForPrisma = globalThis as PrismaGlobal;
+
+export const db =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log:
+ process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
+ });
+
+if (process.env.NODE_ENV !== "production") {
+ globalForPrisma.prisma = db;
+}
diff --git a/lib/format.ts b/lib/format.ts
new file mode 100644
index 0000000..671ceff
--- /dev/null
+++ b/lib/format.ts
@@ -0,0 +1,14 @@
+export const formatEventDate = (value: Date) => {
+ return new Intl.DateTimeFormat("en-GB", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(value);
+};
+
+export const formatPrice = (value: number) => {
+ return new Intl.NumberFormat("en-GB", {
+ style: "currency",
+ currency: "EUR",
+ maximumFractionDigits: 0,
+ }).format(value);
+};
diff --git a/lib/site.ts b/lib/site.ts
new file mode 100644
index 0000000..83ab1ae
--- /dev/null
+++ b/lib/site.ts
@@ -0,0 +1,58 @@
+export const siteConfig = {
+ name: "BinouzUHC",
+ description:
+ "Serveur UHC competitif et immersif. PvP nerveux, events reguliers et communaute engagee.",
+ serverAddress: "play.binouzuhc.eu",
+ version: "1.8.X",
+ discordInviteUrl: "https://discord.gg/binouz",
+ shopDisclaimer:
+ "Paiement Stripe a venir. Les achats sont simules pour l'instant.",
+} as const;
+
+export const fallbackGrades = [
+ {
+ id: "starter",
+ name: "Eclaireur",
+ price: 9,
+ description:
+ "Acces prioritaire, prefix colore, particules discretes et slots reserves.",
+ },
+ {
+ id: "elite",
+ name: "Gladiateur",
+ price: 19,
+ description:
+ "Kit cosmetique PvP, tags exclusifs, bonus d'events et loot personnalise.",
+ },
+ {
+ id: "legend",
+ name: "Titan",
+ price: 39,
+ description:
+ "Rang ultime, aura lumineuse, salons prives et avantages boutique VIP.",
+ },
+];
+
+export const fallbackEvents = [
+ {
+ id: "uhc-arena",
+ title: "UHC Arena - Duel Night",
+ description:
+ "Tournoi 1v1 avec bracket rapide. Score bonus pour les eliminations propres.",
+ eventDate: new Date("2026-05-18T19:00:00Z"),
+ },
+ {
+ id: "rush",
+ title: "Rush 2v2 - Full PvP",
+ description:
+ "Arches, potions, clutchs. Inscription par equipe sur Discord.",
+ eventDate: new Date("2026-05-25T19:00:00Z"),
+ },
+ {
+ id: "uhc-royale",
+ title: "UHC Royale",
+ description:
+ "Format 50 joueurs, shrink progressif, recompenses exclusives.",
+ eventDate: new Date("2026-06-01T19:00:00Z"),
+ },
+];
diff --git a/package-lock.json b/package-lock.json
index 8bd86d8..82fa9c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,13 @@
"name": "binouz",
"version": "0.1.0",
"dependencies": {
+ "@next-auth/prisma-adapter": "^1.0.7",
+ "@prisma/client": "^5.22.0",
"next": "16.2.4",
+ "next-auth": "^4.24.7",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "stripe": "^14.25.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -19,6 +23,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
+ "prisma": "^5.22.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -229,6 +234,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1049,6 +1063,16 @@
"@tybys/wasm-util": "^0.10.0"
}
},
+ "node_modules/@next-auth/prisma-adapter": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz",
+ "integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "@prisma/client": ">=2.26.0 || >=3",
+ "next-auth": "^4"
+ }
+ },
"node_modules/@next/env": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
@@ -1241,6 +1265,84 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1564,7 +1666,6 @@
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2544,7 +2645,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2558,7 +2658,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2658,6 +2757,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2829,7 +2937,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2941,7 +3048,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2951,7 +3057,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2989,7 +3094,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3260,6 +3364,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3616,11 +3721,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3681,7 +3800,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3706,7 +3824,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -3794,7 +3911,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3866,7 +3982,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3895,7 +4010,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4430,6 +4544,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "4.15.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
+ "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4878,7 +5001,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5032,6 +5154,39 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "4.24.14",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz",
+ "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==",
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "@panva/hkdf": "^1.0.2",
+ "cookie": "^0.7.0",
+ "jose": "^4.15.5",
+ "oauth": "^0.9.15",
+ "openid-client": "^5.4.0",
+ "preact": "^10.6.3",
+ "preact-render-to-string": "^5.1.19",
+ "uuid": "^8.3.2"
+ },
+ "peerDependencies": {
+ "@auth/core": "0.34.3",
+ "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
+ "nodemailer": "^7.0.7",
+ "react": "^17.0.2 || ^18 || ^19",
+ "react-dom": "^17.0.2 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@auth/core": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5086,6 +5241,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth": {
+ "version": "0.9.15",
+ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
+ "license": "MIT"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5096,11 +5257,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5209,6 +5378,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oidc-token-hash": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
+ "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^10.13.0 || >=12.0.0"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
+ "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^4.15.9",
+ "lru-cache": "^6.0.0",
+ "object-hash": "^2.2.0",
+ "oidc-token-hash": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/openid-client/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/openid-client/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5375,6 +5586,29 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/preact": {
+ "version": "10.29.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+ "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "5.2.6",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
+ "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
+ "license": "MIT",
+ "dependencies": {
+ "pretty-format": "^3.8.0"
+ },
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5385,6 +5619,33 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
+ "license": "MIT"
+ },
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5407,6 +5668,21 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5786,7 +6062,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5806,7 +6081,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5823,7 +6097,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5842,7 +6115,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -6024,6 +6296,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "14.25.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
+ "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=8.1.0",
+ "qs": "^6.11.0"
+ },
+ "engines": {
+ "node": ">=12.*"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6354,7 +6639,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6433,6 +6717,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 73d3281..8b9b632 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,22 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "prisma:generate": "prisma generate",
+ "prisma:migrate": "prisma migrate dev",
+ "prisma:deploy": "prisma migrate deploy",
+ "db:push": "prisma db push",
+ "db:seed": "prisma db seed"
+ },
+ "prisma": {
+ "seed": "node prisma/seed.mjs"
},
"dependencies": {
"next": "16.2.4",
+ "next-auth": "^4.24.7",
+ "@next-auth/prisma-adapter": "^1.0.7",
+ "@prisma/client": "^5.22.0",
+ "stripe": "^14.25.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@@ -20,6 +32,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
+ "prisma": "^5.22.0",
"tailwindcss": "^4",
"typescript": "^5"
}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..b57ad60
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,112 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id String @id @default(cuid())
+ name String?
+ email String? @unique
+ emailVerified DateTime?
+ image String?
+ discordId String? @unique
+ discordUsername String?
+ discordAvatar String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ accounts Account[]
+ sessions Session[]
+ purchases Purchase[]
+ admin Admin?
+}
+
+model Account {
+ id String @id @default(cuid())
+ userId String
+ type String
+ provider String
+ providerAccountId String
+ refresh_token String? @db.Text
+ access_token String? @db.Text
+ expires_at Int?
+ token_type String?
+ scope String?
+ id_token String? @db.Text
+ session_state String?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([provider, providerAccountId])
+}
+
+model Session {
+ id String @id @default(cuid())
+ sessionToken String @unique
+ userId String
+ expires DateTime
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
+model VerificationToken {
+ identifier String
+ token String @unique
+ expires DateTime
+
+ @@unique([identifier, token])
+}
+
+model Admin {
+ id String @id @default(cuid())
+ userId String @unique
+ discordId String @unique
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
+model Grade {
+ id String @id @default(cuid())
+ name String
+ price Int
+ description String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ purchases Purchase[]
+}
+
+model Event {
+ id String @id @default(cuid())
+ title String
+ description String
+ eventDate DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+enum PurchaseStatus {
+ PENDING
+ PAID
+ FAILED
+ REFUNDED
+}
+
+model Purchase {
+ id String @id @default(cuid())
+ userId String
+ gradeId String
+ amount Int
+ status PurchaseStatus @default(PENDING)
+ stripeSessionId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ grade Grade @relation(fields: [gradeId], references: [id], onDelete: Restrict)
+}
diff --git a/prisma/seed.mjs b/prisma/seed.mjs
new file mode 100644
index 0000000..4caa035
--- /dev/null
+++ b/prisma/seed.mjs
@@ -0,0 +1,74 @@
+import { PrismaClient } from "@prisma/client";
+
+const db = new PrismaClient();
+
+const grades = [
+ {
+ name: "Eclaireur",
+ price: 9,
+ description:
+ "Acces prioritaire, prefix colore, particules discretes et slots reserves.",
+ },
+ {
+ name: "Gladiateur",
+ price: 19,
+ description:
+ "Kit cosmetique PvP, tags exclusifs, bonus d'events et loot personnalise.",
+ },
+ {
+ name: "Titan",
+ price: 39,
+ description:
+ "Rang ultime, aura lumineuse, salons prives et avantages boutique VIP.",
+ },
+];
+
+const events = [
+ {
+ title: "UHC Arena - Duel Night",
+ description:
+ "Tournoi 1v1 avec bracket rapide. Score bonus pour les eliminations propres.",
+ eventDate: new Date("2026-05-18T19:00:00Z"),
+ },
+ {
+ title: "Rush 2v2 - Full PvP",
+ description:
+ "Arches, potions, clutchs. Inscription par equipe sur Discord.",
+ eventDate: new Date("2026-05-25T19:00:00Z"),
+ },
+ {
+ title: "UHC Royale",
+ description:
+ "Format 50 joueurs, shrink progressif, recompenses exclusives.",
+ eventDate: new Date("2026-06-01T19:00:00Z"),
+ },
+];
+
+const main = async () => {
+ for (const grade of grades) {
+ const existing = await db.grade.findFirst({
+ where: { name: grade.name },
+ });
+ if (!existing) {
+ await db.grade.create({ data: grade });
+ }
+ }
+
+ for (const event of events) {
+ const existing = await db.event.findFirst({
+ where: { title: event.title },
+ });
+ if (!existing) {
+ await db.event.create({ data: event });
+ }
+ }
+};
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await db.$disconnect();
+ });
diff --git a/public/hero.svg b/public/hero.svg
new file mode 100644
index 0000000..117b83e
--- /dev/null
+++ b/public/hero.svg
@@ -0,0 +1,34 @@
+
diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts
new file mode 100644
index 0000000..e809845
--- /dev/null
+++ b/types/next-auth.d.ts
@@ -0,0 +1,25 @@
+import "next-auth";
+
+declare module "next-auth" {
+ interface Session {
+ user: {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ image?: string | null;
+ discordId?: string;
+ discordUsername?: string;
+ discordAvatar?: string;
+ isAdmin?: boolean;
+ };
+ }
+}
+
+declare module "next-auth/jwt" {
+ interface JWT {
+ discordId?: string;
+ discordUsername?: string;
+ discordAvatar?: string;
+ isAdmin?: boolean;
+ }
+}