feat: add authentication and user management features

- Implemented AuthButton component for Discord sign-in and sign-out functionality.
- Created CopyButton component for copying server IP addresses.
- Developed EventCard and GradeCard components for displaying events and grades.
- Added Footer and Navbar components for site navigation and information.
- Introduced PurchaseButton for handling grade purchases with Stripe integration.
- Created SectionHeader component for consistent section titles.
- Implemented session management with SessionProvider for NextAuth.
- Set up PostgreSQL database with Docker and Prisma for data management.
- Added admin guard functionality to restrict access to certain routes.
- Configured NextAuth with Discord provider for user authentication.
- Defined Prisma schema for user, admin, grade, event, and purchase models.
- Seeded database with initial grades and events data.
- Added SVG hero image for the landing page.
- Extended NextAuth types to include additional user properties.
This commit is contained in:
Puechberty Arthur
2026-04-28 21:09:55 +02:00
parent 87deccb662
commit b7010a1704
43 changed files with 2794 additions and 126 deletions
+535
View File
@@ -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 <T,>(
url: string,
options: RequestInit
): Promise<T> => {
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<AdminUser[]>(initialAdmins);
const [grades, setGrades] = useState<Grade[]>(initialGrades);
const [events, setEvents] = useState<Event[]>(initialEvents);
const [notice, setNotice] = useState<string | null>(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<AdminUser>("/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<Grade>("/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<Event>("/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 (
<div className="space-y-10">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h2 className="text-lg font-semibold text-white">Admin access</h2>
<p className="mt-2 text-sm text-slate-300">
Manage admins, grades, and events. Changes are applied live.
</p>
{notice ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-xs text-cyan-200">
{notice}
</div>
) : null}
</div>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Admins</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddAdmin(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 flex flex-col gap-3 md:flex-row"
>
<input
name="discordId"
placeholder="Discord ID"
className="flex-1 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add admin
</button>
</form>
<div className="mt-4 space-y-3">
{admins.map((admin) => (
<AdminRow
key={admin.id}
admin={admin}
onDelete={(id) =>
setAdmins((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Create grade</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddGrade(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 space-y-3"
>
<input
name="name"
placeholder="Name"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
name="price"
type="number"
placeholder="Price"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
name="description"
placeholder="Description"
className="min-h-30 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="w-full rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add grade
</button>
</form>
</div>
</section>
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Grades</h3>
<div className="mt-4 space-y-4">
{grades.map((grade) => (
<GradeRow
key={grade.id}
grade={grade}
onUpdate={(updated) =>
setGrades((prev) =>
prev.map((item) =>
item.id === updated.id ? updated : item
)
)
}
onDelete={(id) =>
setGrades((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1fr_1.1fr]">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Create event</h3>
<form
onSubmit={(event) => {
event.preventDefault();
handleAddEvent(new FormData(event.currentTarget));
event.currentTarget.reset();
}}
className="mt-4 space-y-3"
>
<input
name="title"
placeholder="Title"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
name="eventDate"
type="datetime-local"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
name="description"
placeholder="Description"
className="min-h-30 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<button
type="submit"
className="w-full rounded-2xl border border-white/10 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white"
disabled={isPending}
>
Add event
</button>
</form>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<h3 className="text-base font-semibold text-white">Events</h3>
<div className="mt-4 space-y-4">
{events.map((event) => (
<EventRow
key={event.id}
event={event}
onUpdate={(updated) =>
setEvents((prev) =>
prev.map((item) =>
item.id === updated.id ? updated : item
)
)
}
onDelete={(id) =>
setEvents((prev) => prev.filter((item) => item.id !== id))
}
/>
))}
</div>
</div>
</section>
</div>
);
}
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 (
<div className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-slate-950/40 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-white">
{admin.user?.discordUsername ?? admin.user?.name ?? "Unknown"}
</p>
<p className="text-xs text-slate-400">{admin.discordId}</p>
</div>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Remove
</button>
</div>
);
}
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<Grade>(
`/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 (
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="grid gap-3 md:grid-cols-[1fr_160px]">
<input
value={draft.name}
onChange={(event) =>
setDraft((prev) => ({ ...prev, name: event.target.value }))
}
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
type="number"
value={draft.price}
onChange={(event) =>
setDraft((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
</div>
<textarea
value={draft.description}
onChange={(event) =>
setDraft((prev) => ({ ...prev, description: event.target.value }))
}
className="mt-3 min-h-25 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Save
</button>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Delete
</button>
</div>
</div>
);
}
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<Event>(
`/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 (
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<input
value={draft.title}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, title: eventValue.target.value }))
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<input
type="datetime-local"
value={draft.eventDate}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, eventDate: eventValue.target.value }))
}
className="mt-3 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<textarea
value={draft.description}
onChange={(eventValue) =>
setDraft((prev) => ({ ...prev, description: eventValue.target.value }))
}
className="mt-3 min-h-25 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-2 text-sm text-white"
/>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Save
</button>
<button
type="button"
onClick={handleDelete}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.3em] text-white/80"
disabled={isPending}
>
Delete
</button>
</div>
</div>
);
}
+87
View File
@@ -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 (
<div
className={`inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs text-slate-200 ${className}`}
>
<span className="h-2 w-2 animate-pulse rounded-full bg-cyan-300" />
<span>Loading</span>
</div>
);
}
if (!session?.user) {
return (
<button
type="button"
onClick={() => signIn("discord", { callbackUrl: "/" })}
className={`inline-flex items-center justify-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-white/10 ${className}`}
>
<span className="h-2 w-2 rounded-full bg-cyan-300" />
<span>{label}</span>
</button>
);
}
const avatarUrl = session.user.discordAvatar && session.user.discordId
? `https://cdn.discordapp.com/avatars/${session.user.discordId}/${session.user.discordAvatar}.png`
: session.user.image ?? "";
return (
<div
className={`flex items-center gap-2 ${compact ? "" : "rounded-full border border-white/10 bg-white/5 px-3 py-2"} ${className}`}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={session.user.discordUsername ?? "Discord avatar"}
className="h-8 w-8 rounded-full border border-white/20 object-cover"
/>
) : (
<div className="h-8 w-8 rounded-full border border-white/20 bg-white/10" />
)}
<div className="hidden sm:flex sm:flex-col">
<span className="text-xs font-semibold text-white">
{session.user.discordUsername ?? session.user.name ?? "Player"}
</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-slate-400">
{session.user.isAdmin ? "Admin" : "Connected"}
</span>
</div>
<div className="flex items-center gap-2">
{session.user.isAdmin ? (
<Link
href="/admin"
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.25em] text-cyan-200/90 transition hover:border-cyan-300/40"
>
Admin
</Link>
) : null}
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-full border border-white/10 px-3 py-1 text-[10px] uppercase tracking-[0.25em] text-white/80 transition hover:border-white/30"
>
Sign out
</button>
</div>
</div>
);
}
+43
View File
@@ -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 (
<button
type="button"
onClick={handleCopy}
className={`group inline-flex items-center justify-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:border-white/30 hover:bg-white/10 ${className}`}
aria-label="Copy server IP"
>
<span className="font-mono text-xs tracking-[0.3em] text-white/70">
{value}
</span>
<span className="text-xs text-cyan-200/90">
{copied ? "Copied" : label}
</span>
</button>
);
}
+22
View File
@@ -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 (
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/80">
{formatEventDate(event.eventDate)}
</p>
<h3 className="mt-3 text-xl font-semibold text-white">{event.title}</h3>
<p className="mt-3 text-sm text-slate-300">{event.description}</p>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { siteConfig } from "@/lib/site";
export default function Footer() {
return (
<footer className="border-t border-white/5 bg-slate-950/90">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-10 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">
BinouzUHC
</p>
<p className="text-sm text-slate-300">
{siteConfig.serverAddress} - Minecraft {siteConfig.version}
</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs uppercase tracking-[0.2em]">
<a href="#presentation" className="transition hover:text-white">
Presentation
</a>
<a href="#grades" className="transition hover:text-white">
Grades
</a>
<a href="#events" className="transition hover:text-white">
Events
</a>
<a href="#discord" className="transition hover:text-white">
Discord
</a>
</div>
<p className="text-xs text-slate-500">
(c) 2026 BinouzUHC. All rights reserved.
</p>
</div>
</footer>
);
}
+33
View File
@@ -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 (
<div className="flex h-full flex-col justify-between rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/80">
{grade.name}
</p>
<p className="mt-4 text-3xl font-semibold text-white">
{formatPrice(grade.price)}
</p>
<p className="mt-3 text-sm text-slate-300">{grade.description}</p>
</div>
<div className="mt-6 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.3em] text-slate-400">
Instant access
</span>
<PurchaseButton gradeId={grade.id} />
</div>
</div>
);
}
+75
View File
@@ -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 (
<section className="relative overflow-hidden pb-20 pt-16 md:pb-28">
<div className="absolute inset-0 bg-[url('/hero.svg')] bg-cover bg-center opacity-80" />
<div className="absolute inset-0 bg-linear-to-b from-slate-950/30 via-slate-950/70 to-slate-950" />
<div className="absolute left-1/2 top-16 h-64 w-130 -translate-x-1/2 rounded-full bg-violet-600/20 blur-[120px]" />
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-10 px-6 md:flex-row md:items-center">
<div className="max-w-xl space-y-6">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-[10px] uppercase tracking-[0.4em] text-cyan-200/80">
BinouzUHC - Minecraft 1.8.X
</div>
<h1 className="text-4xl font-semibold text-white md:text-6xl">
Immerse yourself in elite UHC PvP.
</h1>
<p className="text-base text-slate-300">
{siteConfig.description}
</p>
<div className="flex flex-wrap items-center gap-4">
<CopyButton value={siteConfig.serverAddress} label="Copy" />
<a
href="#grades"
className="inline-flex items-center justify-center rounded-full border border-white/15 bg-white/10 px-5 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-white transition hover:bg-white/20"
>
Voir les grades
</a>
</div>
<div className="flex flex-wrap gap-6 text-xs uppercase tracking-[0.3em] text-slate-400">
<span>{siteConfig.serverAddress}</span>
<span>1.8.X</span>
<span>Discord ready</span>
</div>
</div>
<div className="grid w-full gap-4 md:max-w-md">
{quickStats.map((item) => (
<div
key={item.title}
className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-slate-200 backdrop-blur"
>
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/80">
{item.title}
</p>
<p className="mt-3 text-base text-white">{item.detail}</p>
</div>
))}
<div className="rounded-2xl border border-white/10 bg-linear-to-br from-violet-600/30 via-indigo-500/20 to-cyan-400/20 p-6 text-sm text-slate-200 backdrop-blur">
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/80">
Drop in
</p>
<p className="mt-3 text-base text-white">
Ranked, balanced, and tuned for competitive squads.
</p>
</div>
</div>
</div>
</section>
);
}
+49
View File
@@ -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 (
<header className="sticky top-0 z-40 w-full border-b border-white/5 bg-slate-950/70 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-linear-to-br from-violet-600 via-indigo-500 to-cyan-400 text-sm font-bold text-slate-950 shadow-[0_12px_30px_rgba(59,130,246,0.4)]">
BU
</span>
<div className="flex flex-col">
<span className="text-sm font-semibold uppercase tracking-[0.3em] text-white">
BinouzUHC
</span>
<span className="text-xs text-slate-400">UHC PvP 1.8.X</span>
</div>
</Link>
<nav className="hidden items-center gap-6 text-xs uppercase tracking-[0.25em] text-slate-300 md:flex">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="transition hover:text-white"
>
{link.label}
</a>
))}
</nav>
<div className="flex items-center gap-3">
<a
href="#grades"
className="hidden rounded-full border border-white/10 px-4 py-2 text-[10px] uppercase tracking-[0.3em] text-slate-300 transition hover:border-white/30 hover:text-white lg:inline-flex"
>
Boutique
</a>
<AuthButton compact />
</div>
</div>
</header>
);
}
+128
View File
@@ -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<PurchaseState>("idle");
const [error, setError] = useState<string | null>(null);
const openModal = () => {
setIsOpen(true);
setError(null);
};
const closeModal = () => {
setIsOpen(false);
setState("idle");
setError(null);
};
const handleCheckout = async (event: React.FormEvent<HTMLFormElement>) => {
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 (
<>
<button
type="button"
onClick={openModal}
className="inline-flex items-center justify-center gap-2 rounded-full bg-linear-to-r from-violet-600 via-indigo-500 to-cyan-400 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-950 transition hover:brightness-110"
aria-label="Open checkout"
>
<span>Acheter</span>
</button>
{isOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-6"
onClick={closeModal}
>
<div
className="w-full max-w-md rounded-3xl border border-white/10 bg-slate-950/90 p-6 text-white shadow-[0_30px_80px_rgba(2,6,23,0.6)]"
onClick={(event) => event.stopPropagation()}
>
<p className="text-xs uppercase tracking-[0.4em] text-cyan-200/80">
Checkout
</p>
<h3 className="mt-3 text-2xl font-semibold">
Enter Minecraft username
</h3>
<p className="mt-2 text-sm text-slate-300">
No account needed. We will send you to Stripe.
</p>
<form onSubmit={handleCheckout} className="mt-5 space-y-4">
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Minecraft username"
className="w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white"
/>
{error ? (
<p className="text-xs text-rose-300">{error}</p>
) : null}
<div className="flex flex-wrap gap-3">
<button
type="submit"
className="flex-1 rounded-2xl bg-linear-to-r from-violet-600 via-indigo-500 to-cyan-400 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-slate-950"
disabled={state === "loading"}
>
{state === "loading" ? "Redirecting" : "Go to Stripe"}
</button>
<button
type="button"
onClick={closeModal}
className="rounded-2xl border border-white/10 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-white/80"
>
Cancel
</button>
</div>
</form>
</div>
</div>
) : null}
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
type SectionHeaderProps = {
eyebrow: string;
title: string;
description?: string;
};
export default function SectionHeader({
eyebrow,
title,
description,
}: SectionHeaderProps) {
return (
<div className="mx-auto mb-10 max-w-2xl text-center">
<p className="text-xs uppercase tracking-[0.4em] text-cyan-200/80">
{eyebrow}
</p>
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
{title}
</h2>
{description ? (
<p className="mt-3 text-sm text-slate-300">{description}</p>
) : null}
</div>
);
}
+16
View File
@@ -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 <SessionProvider session={session}>{children}</SessionProvider>;
}