mirror of
https://github.com/arthur-pbty/binouz.git
synced 2026-06-03 23:36:27 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user