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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark + +
+ + +
+ +
+ {[ + { + 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

+
{ + event.preventDefault(); + handleAddAdmin(new FormData(event.currentTarget)); + event.currentTarget.reset(); + }} + className="mt-4 flex flex-col gap-3 md:flex-row" + > + + +
+
+ {admins.map((admin) => ( + + setAdmins((prev) => prev.filter((item) => item.id !== id)) + } + /> + ))} +
+
+
+

Create grade

+
{ + event.preventDefault(); + handleAddGrade(new FormData(event.currentTarget)); + event.currentTarget.reset(); + }} + className="mt-4 space-y-3" + > + + +