This commit is contained in:
gpatruno
2026-06-11 17:30:13 +02:00
parent 36b4acb355
commit e45e9f154e
108 changed files with 9163 additions and 167 deletions
@@ -0,0 +1,5 @@
import { AdminGallery } from "@/components/admin/AdminGallery";
export default function AdminGalleryPage() {
return <AdminGallery />;
}
@@ -0,0 +1,5 @@
import { AdminIngredients } from "@/components/admin/AdminIngredients";
export default function AdminIngredientsPage() {
return <AdminIngredients />;
}
+14
View File
@@ -0,0 +1,14 @@
import { AdminNav } from "@/components/admin/AdminNav";
export default function AdminDashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-cream">
<AdminNav />
{children}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminDashboard } from "@/components/admin/AdminDashboard";
export default function AdminPage() {
return <AdminDashboard />;
}
@@ -0,0 +1,5 @@
import { AdminTruckSchedule } from "@/components/admin/AdminTruckSchedule";
export default function AdminSchedulePage() {
return <AdminTruckSchedule />;
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminSiteContent } from "@/components/admin/AdminSiteContent";
export default function AdminSitePage() {
return <AdminSiteContent />;
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
import { getClientIp, rateLimitFailedLogin } from "@/lib/rate-limit";
import {
clearLegacyRootSessionCookie,
sessionCookieBase,
} from "@/lib/session-cookie";
export async function POST(request: Request) {
const ip = getClientIp(request);
let body: { email?: string; password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const email = String(body.email ?? "")
.trim()
.toLowerCase();
const password = String(body.password ?? "");
if (!email || !password) {
return NextResponse.json(
{ error: "Email et mot de passe requis" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({ where: { email } });
const valid =
!!user && (await verifyPassword(password, user.passwordHash));
if (!valid) {
const limited = await rateLimitFailedLogin(ip);
if (!limited.ok) {
return NextResponse.json(
{
error: "Trop de tentatives. Réessayez plus tard.",
retryAfterSec: limited.retryAfterSec,
},
{
status: 429,
headers: { "Retry-After": String(limited.retryAfterSec) },
}
);
}
return NextResponse.json({ error: "Identifiants invalides" }, { status: 401 });
}
const token = await createSessionToken(user.id);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
...sessionCookieBase(),
maxAge: 60 * 60 * 24 * 7,
});
clearLegacyRootSessionCookie(res);
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { clearAllSessionCookieVariants } from "@/lib/session-cookie";
export async function POST() {
const res = NextResponse.json({ ok: true });
clearAllSessionCookieVariants(res);
return res;
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ user: null });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!user) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user: { email: user.email } });
}
+66
View File
@@ -0,0 +1,66 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import {
getCustomWaffleConfig,
parseCustomWafflePayload,
} from "@/lib/custom-waffle-content";
import { DEFAULT_PUBLIC_SITE_COPY, SITE_CONTENT_ID } from "@/lib/site-content-defaults";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
return NextResponse.json(await getCustomWaffleConfig());
}
export async function PUT(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const parsed = parseCustomWafflePayload(body as Record<string, unknown>);
if (parsed.error || !parsed.data) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
const { data } = parsed;
await prisma.siteContent.upsert({
where: { id: SITE_CONTENT_ID },
create: {
id: SITE_CONTENT_ID,
...DEFAULT_PUBLIC_SITE_COPY,
customWaffleTitle: data.title,
customWaffleDescription: data.description,
customWaffleBasePrice: data.basePrice,
customWaffleImageUrl: data.imageUrl,
customWaffleVisible: data.visible,
},
update: {
customWaffleTitle: data.title,
customWaffleDescription: data.description,
customWaffleBasePrice: data.basePrice,
customWaffleImageUrl: data.imageUrl,
customWaffleVisible: data.visible,
},
});
revalidatePath("/");
return NextResponse.json({ ok: true });
}
+81
View File
@@ -0,0 +1,81 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: { imageUrl?: string; alt?: string; sortOrder?: number };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const imageUrl = normalizeImageUrl(body.imageUrl);
const alt = String(body.alt ?? "").trim();
const sortOrder = Number.isFinite(Number(body.sortOrder))
? Number(body.sortOrder)
: 0;
if (!imageUrl || !alt) {
return NextResponse.json(
{ error: "Image et texte alternatif requis" },
{ status: 400 }
);
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
try {
const previous = await prisma.galleryImage.findUnique({
where: { id },
select: { imageUrl: true },
});
const row = await prisma.galleryImage.update({
where: { id },
data: { imageUrl, alt, sortOrder },
});
if (previous?.imageUrl && previous.imageUrl !== row.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.galleryImage.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const images = await prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(images);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: { imageUrl?: string; alt?: string; sortOrder?: number };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const imageUrl = normalizeImageUrl(body.imageUrl);
const alt = String(body.alt ?? "").trim();
const sortOrder = Number.isFinite(Number(body.sortOrder))
? Number(body.sortOrder)
: 0;
if (!imageUrl || !alt) {
return NextResponse.json(
{ error: "Image et texte alternatif requis" },
{ status: 400 }
);
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const row = await prisma.galleryImage.create({
data: { imageUrl, alt, sortOrder },
});
revalidatePath("/");
return NextResponse.json(row);
}
@@ -0,0 +1,76 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: { name?: string; price?: number; imageUrl?: string | null };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const price = Number(body.price);
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
try {
const previous = await prisma.ingredient.findUnique({
where: { id },
select: { imageUrl: true },
});
const ingredient = await prisma.ingredient.update({
where: { id },
data: { name, price, imageUrl },
});
if (previous?.imageUrl && previous.imageUrl !== ingredient.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(ingredient);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.ingredient.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const ingredients = await prisma.ingredient.findMany({
orderBy: { name: "asc" },
});
return NextResponse.json(ingredients);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: { name?: string; price?: number; imageUrl?: string | null };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const price = Number(body.price);
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const ingredient = await prisma.ingredient.create({
data: { name, price, imageUrl },
});
revalidatePath("/");
return NextResponse.json(ingredient);
}
+123
View File
@@ -0,0 +1,123 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import {
DEFAULT_PUBLIC_SITE_COPY,
SITE_CONTENT_ID,
} from "@/lib/site-content-defaults";
import { parseHoursPayload } from "@/lib/site-content";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const row = await prisma.siteContent.findUnique({
where: { id: SITE_CONTENT_ID },
});
if (!row) {
return NextResponse.json({
...DEFAULT_PUBLIC_SITE_COPY,
heroTextOverImage: DEFAULT_PUBLIC_SITE_COPY.heroTextOverImage,
uploadImageWidth: 1920,
uploadImageHeight: 1080,
});
}
return NextResponse.json({
logoUrl: row.logoUrl ?? DEFAULT_PUBLIC_SITE_COPY.logoUrl,
heroEyebrow: row.heroEyebrow,
heroTitle: row.heroTitle,
heroSubtitle: row.heroSubtitle,
heroBackgroundImageUrl:
row.heroBackgroundImageUrl ?? DEFAULT_PUBLIC_SITE_COPY.heroBackgroundImageUrl,
heroTextOverImage: row.heroTextOverImage ?? true,
menuIntro: row.menuIntro,
galleryIntro: row.galleryIntro,
scheduleIntro: row.scheduleIntro ?? "",
uploadImageWidth: row.uploadImageWidth ?? 1920,
uploadImageHeight: row.uploadImageHeight ?? 1080,
hours: row.hours,
});
}
export async function PUT(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const b = body as Record<string, unknown>;
const hours = parseHoursPayload(b.hours);
if (hours === null) {
return NextResponse.json(
{ error: "Horaires : tableau de { days, hours } attendu" },
{ status: 400 }
);
}
const rawW = typeof b.uploadImageWidth === "number"
? b.uploadImageWidth
: Number(String(b.uploadImageWidth ?? ""));
const rawH = typeof b.uploadImageHeight === "number"
? b.uploadImageHeight
: Number(String(b.uploadImageHeight ?? ""));
const uploadImageWidth = Number.isFinite(rawW) ? Math.trunc(rawW) : 1920;
const uploadImageHeight = Number.isFinite(rawH) ? Math.trunc(rawH) : 1080;
if (
uploadImageWidth < 320 ||
uploadImageWidth > 8000 ||
uploadImageHeight < 240 ||
uploadImageHeight > 8000
) {
return NextResponse.json(
{ error: "Résolution upload invalide (ex: 1920×1080)" },
{ status: 400 }
);
}
const data = {
logoUrl:
normalizeImageUrl(String(b.logoUrl ?? "")) ??
DEFAULT_PUBLIC_SITE_COPY.logoUrl,
heroEyebrow: String(b.heroEyebrow ?? "").trim(),
heroTitle: String(b.heroTitle ?? "").trim(),
heroSubtitle: String(b.heroSubtitle ?? "").trim(),
heroBackgroundImageUrl:
normalizeImageUrl(String(b.heroBackgroundImageUrl ?? "")) ??
DEFAULT_PUBLIC_SITE_COPY.heroBackgroundImageUrl,
heroTextOverImage: b.heroTextOverImage !== false,
menuIntro: String(b.menuIntro ?? "").trim(),
galleryIntro: String(b.galleryIntro ?? "").trim(),
scheduleIntro: String(b.scheduleIntro ?? "").trim(),
uploadImageWidth,
uploadImageHeight,
hours,
};
await prisma.siteContent.upsert({
where: { id: SITE_CONTENT_ID },
create: {
id: SITE_CONTENT_ID,
...data,
},
update: data,
});
revalidatePath("/");
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,64 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckEventPayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckEventPayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Date (AAAA-MM-JJ) et lieu requis" },
{ status: 400 }
);
}
try {
const row = await prisma.truckEvent.update({
where: { id },
data,
});
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
await prisma.truckEvent.delete({ where: { id } });
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckEventPayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const events = await prisma.truckEvent.findMany({
orderBy: [{ eventDate: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(events);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckEventPayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Date (AAAA-MM-JJ) et lieu requis" },
{ status: 400 }
);
}
const row = await prisma.truckEvent.create({ data });
revalidatePath("/");
return NextResponse.json(row);
}
@@ -0,0 +1,64 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckRecurrencePayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckRecurrencePayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Jour de la semaine et lieu requis" },
{ status: 400 }
);
}
try {
const row = await prisma.truckEventRecurrence.update({
where: { id },
data,
});
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
await prisma.truckEventRecurrence.delete({ where: { id } });
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
@@ -0,0 +1,47 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckRecurrencePayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const rows = await prisma.truckEventRecurrence.findMany({
orderBy: [{ dayOfWeek: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(rows);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckRecurrencePayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Jour de la semaine et lieu requis" },
{ status: 400 }
);
}
const row = await prisma.truckEventRecurrence.create({ data });
revalidatePath("/");
return NextResponse.json(row);
}
+106
View File
@@ -0,0 +1,106 @@
import { randomBytes } from "crypto";
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { detectImageMime } from "@/lib/image-magic";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
import { SITE_CONTENT_ID } from "@/lib/site-content-defaults";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
["image/gif", ".gif"],
]);
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json(
{ error: "Fichier trop volumineux (max 10 Mo)" },
{ status: 400 }
);
}
const ab = await file.arrayBuffer();
const originalBuf: Buffer = Buffer.from(new Uint8Array(ab));
const detected = detectImageMime(originalBuf);
if (!detected || !ALLOWED.has(detected)) {
return NextResponse.json(
{
error:
"Fichier non reconnu comme image (JPEG, PNG, WebP ou GIF attendu)",
},
{ status: 400 }
);
}
const ext = ALLOWED.get(detected)!;
const settings = await prisma.siteContent.findUnique({
where: { id: SITE_CONTENT_ID },
select: { uploadImageWidth: true, uploadImageHeight: true },
});
const width =
typeof settings?.uploadImageWidth === "number" && settings.uploadImageWidth > 0
? settings.uploadImageWidth
: 1920;
const height =
typeof settings?.uploadImageHeight === "number" && settings.uploadImageHeight > 0
? settings.uploadImageHeight
: 1080;
let buf: Buffer = originalBuf;
if (detected !== "image/gif") {
try {
let pipeline = sharp(originalBuf, { failOn: "none" }).rotate();
pipeline = pipeline.resize({
width,
height,
fit: "cover",
withoutEnlargement: true,
});
if (detected === "image/jpeg") {
buf = await pipeline.jpeg({ quality: 82 }).toBuffer();
} else if (detected === "image/png") {
buf = await pipeline.png({ compressionLevel: 9 }).toBuffer();
} else if (detected === "image/webp") {
buf = await pipeline.webp({ quality: 82 }).toBuffer();
} else {
buf = await pipeline.toBuffer();
}
} catch {
return NextResponse.json(
{ error: "Impossible de traiter cette image" },
{ status: 400 }
);
}
}
const name = `${randomBytes(16).toString("hex")}${ext}`;
const dir = join(process.cwd(), "public", "uploads");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, name), buf);
const url = `/uploads/${name}`;
return NextResponse.json({ url });
}
+123
View File
@@ -0,0 +1,123 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { WAFFLE_CATEGORIES } from "@/lib/constants";
import { normalizeImageUrl } from "@/lib/image-url";
import { parseStrictBoolean } from "@/lib/json-fields";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
import {
parseIngredientIds,
validateIngredientIds,
waffleIngredientInclude,
} from "@/lib/waffle-ingredients";
const CATEGORIES: Set<string> = new Set(Object.values(WAFFLE_CATEGORIES));
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: {
name?: string;
ingredients?: string;
price?: number;
isNew?: boolean;
category?: string;
imageUrl?: string | null;
ingredientIds?: unknown;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const ingredients = String(body.ingredients ?? "").trim();
const price = Number(body.price);
const isNewParsed = parseStrictBoolean(body.isNew, "isNew", false);
if (!isNewParsed.ok) {
return NextResponse.json({ error: isNewParsed.error }, { status: 400 });
}
const isNew = isNewParsed.value;
const category = String(body.category ?? "").trim();
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || !ingredients || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (!CATEGORIES.has(category)) {
return NextResponse.json({ error: "Catégorie invalide" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const parsedIds = parseIngredientIds(body.ingredientIds);
if (parsedIds !== null && !(await validateIngredientIds(parsedIds))) {
return NextResponse.json({ error: "Ingrédient inconnu" }, { status: 400 });
}
try {
const previous = await prisma.waffle.findUnique({
where: { id },
select: { imageUrl: true },
});
const waffle = await prisma.waffle.update({
where: { id },
data: {
name,
ingredients,
price,
isNew,
category,
imageUrl,
...(parsedIds !== null
? {
ingredientItems: {
set: parsedIds.map((ingredientId) => ({ id: ingredientId })),
},
}
: {}),
},
include: waffleIngredientInclude,
});
if (previous?.imageUrl && previous.imageUrl !== waffle.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(waffle);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.waffle.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+96
View File
@@ -0,0 +1,96 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { WAFFLE_CATEGORIES } from "@/lib/constants";
import { normalizeImageUrl } from "@/lib/image-url";
import { parseStrictBoolean } from "@/lib/json-fields";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
import {
parseIngredientIds,
validateIngredientIds,
waffleIngredientInclude,
} from "@/lib/waffle-ingredients";
const CATEGORIES: Set<string> = new Set(Object.values(WAFFLE_CATEGORIES));
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const waffles = await prisma.waffle.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
include: waffleIngredientInclude,
});
return NextResponse.json(waffles);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: {
name?: string;
ingredients?: string;
price?: number;
isNew?: boolean;
category?: string;
imageUrl?: string | null;
ingredientIds?: unknown;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const ingredients = String(body.ingredients ?? "").trim();
const price = Number(body.price);
const isNewParsed = parseStrictBoolean(body.isNew, "isNew", false);
if (!isNewParsed.ok) {
return NextResponse.json({ error: isNewParsed.error }, { status: 400 });
}
const isNew = isNewParsed.value;
const category = String(body.category ?? "").trim();
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || !ingredients || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (!CATEGORIES.has(category)) {
return NextResponse.json({ error: "Catégorie invalide" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const ingredientIds = parseIngredientIds(body.ingredientIds) ?? [];
if (!(await validateIngredientIds(ingredientIds))) {
return NextResponse.json({ error: "Ingrédient inconnu" }, { status: 400 });
}
const waffle = await prisma.waffle.create({
data: {
name,
ingredients,
price,
isNew,
category,
imageUrl,
...(ingredientIds.length > 0
? { ingredientItems: { connect: ingredientIds.map((id) => ({ id })) } }
: {}),
},
include: waffleIngredientInclude,
});
revalidatePath("/");
return NextResponse.json(waffle);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
import { ADMIN_API } from "@/lib/constants";
function formatLockoutRemaining(totalSec: number): string {
if (totalSec < 60) return `${totalSec} s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (s === 0) return `${m} min`;
return `${m} min ${s} s`;
}
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/** Compte à rebours anti force brute (secondes restantes), null si pas bloqué. */
const [lockoutRemainingSec, setLockoutRemainingSec] = useState<number | null>(
null
);
useEffect(() => {
fetch(`${ADMIN_API}/auth/me`, { credentials: "include" })
.then((r) => r.json())
.then((d) => {
if (d.user) router.replace("/admin");
})
.catch(() => {});
}, [router]);
useEffect(() => {
if (lockoutRemainingSec == null || lockoutRemainingSec <= 0) return;
const t = setTimeout(() => {
setLockoutRemainingSec((r) =>
r != null && r > 1 ? r - 1 : null
);
}, 1000);
return () => clearTimeout(t);
}, [lockoutRemainingSec]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`${ADMIN_API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 429) {
const fromBody =
typeof data.retryAfterSec === "number" &&
Number.isFinite(data.retryAfterSec)
? Math.max(0, Math.ceil(data.retryAfterSec))
: null;
const headerRaw = res.headers.get("Retry-After");
const fromHeader = headerRaw
? Math.max(0, parseInt(headerRaw, 10))
: NaN;
const sec =
fromBody ??
(Number.isFinite(fromHeader) ? fromHeader : null);
if (sec != null && sec > 0) {
setLockoutRemainingSec(sec);
setError(null);
} else {
setLockoutRemainingSec(null);
setError(
typeof data.error === "string"
? data.error
: "Trop de tentatives. Réessayez plus tard."
);
}
} else {
setLockoutRemainingSec(null);
setError(
typeof data.error === "string"
? data.error
: "Connexion impossible"
);
}
return;
}
setLockoutRemainingSec(null);
window.location.href = "/admin";
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-cream px-4 py-12">
<div className="w-full max-w-md rounded-[2rem] border border-chocolat/10 bg-white p-8 shadow-[0_20px_50px_-15px_rgba(61,43,31,0.2)]">
<h1 className="text-center text-2xl font-bold text-chocolat">
Administration
</h1>
<p className="mt-2 text-center text-sm text-muted">
Connexion réservée au gérant
</p>
<form onSubmit={onSubmit} className="mt-8 flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm font-medium text-chocolat">
Email
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-3 text-foreground outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm font-medium text-chocolat">
Mot de passe
<span className="relative">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-2xl border border-chocolat/15 bg-cream/50 py-3 pl-4 pr-12 text-foreground outline-none ring-gaufre/40 focus:ring-2"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-lg p-1 text-chocolat/50 transition hover:text-chocolat"
aria-label={
showPassword
? "Masquer le mot de passe"
: "Afficher le mot de passe"
}
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</span>
</label>
{lockoutRemainingSec != null && lockoutRemainingSec > 0 && (
<div
className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"
role="status"
>
<p className="font-semibold">Connexion temporairement bloquée</p>
<p className="mt-1 text-amber-900/90">
Trop de tentatives infructueuses. Temps restant avant un nouvel
essai :{" "}
<span className="font-mono font-semibold tabular-nums">
{formatLockoutRemaining(lockoutRemainingSec)}
</span>
</p>
</div>
)}
{error && (
<p className="rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={
loading ||
(lockoutRemainingSec != null && lockoutRemainingSec > 0)
}
className="mt-2 rounded-full bg-gaufre py-3.5 text-base font-semibold text-chocolat shadow-lg shadow-chocolat/15 transition hover:brightness-105 disabled:opacity-60"
>
{loading ? "Connexion…" : "Se connecter"}
</button>
</form>
<p className="mt-6 text-center text-xs text-muted">
<Link href="/" className="underline hover:text-chocolat">
Retour au site
</Link>
</p>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/** Données vitrine uniquement (pas de métadonnées internes). */
export async function GET() {
const rows = await prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
imageUrl: true,
alt: true,
sortOrder: true,
},
});
return NextResponse.json(rows);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { waffleIngredientInclude } from "@/lib/waffle-ingredients";
/** Données vitrine uniquement (pas de métadonnées internes). */
export async function GET() {
const rows = await prisma.waffle.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
ingredients: true,
price: true,
isNew: true,
category: true,
imageUrl: true,
ingredientItems: waffleIngredientInclude.ingredientItems,
},
});
return NextResponse.json(rows);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+13 -12
View File
@@ -1,26 +1,27 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #fff8f0;
--foreground: #3d2b1f;
--muted: #6b5344;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-muted: var(--muted);
--color-gaufre: #f39c12;
--color-chocolat: #3d2b1f;
--color-cream: #fff8f0;
--font-sans: var(--font-outfit);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), system-ui, sans-serif;
}
html {
scroll-behavior: smooth;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+9 -14
View File
@@ -1,20 +1,17 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Outfit } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const outfit = Outfit({
variable: "--font-outfit",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Gaufrement Bon",
description:
"Gaufres sucrées et salées artisanales. Horaires, adresse et menu à Lille.",
};
export default function RootLayout({
@@ -23,10 +20,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="fr">
<body className={`${outfit.variable} font-sans antialiased`}>
{children}
</body>
</html>
+70 -97
View File
@@ -1,103 +1,76 @@
import Image from "next/image";
import { Footer } from "@/components/site/Footer";
import { GallerySection } from "@/components/site/GallerySection";
import { ScheduleSection } from "@/components/site/ScheduleSection";
import { Hero } from "@/components/site/Hero";
import { HeroIntro } from "@/components/site/HeroIntro";
import { MenuSection } from "@/components/site/MenuSection";
import { SiteHeader } from "@/components/site/SiteHeader";
import { prisma } from "@/lib/prisma";
import { getCustomWaffleConfig } from "@/lib/custom-waffle-content";
import { getPublicSiteContent } from "@/lib/site-content";
import { getPublicTruckEvents } from "@/lib/truck-event";
import { getPublicTruckRecurrences } from "@/lib/truck-recurrence";
import { waffleIngredientInclude } from "@/lib/waffle-ingredients";
/** Page mise en cache (ISR) ; invalidation via `revalidatePath("/")` après modifs admin. */
export const revalidate = 60;
export default async function Home() {
const [
waffles,
ingredientCatalog,
galleryImages,
site,
customWaffle,
truckEvents,
truckRecurrences,
] = await Promise.all([
prisma.waffle.findMany({ include: waffleIngredientInclude }),
prisma.ingredient.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, price: true, imageUrl: true },
}),
prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}),
getPublicSiteContent(),
getCustomWaffleConfig(),
getPublicTruckEvents(),
getPublicTruckRecurrences(),
]);
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
<>
<SiteHeader logoUrl={site.logoUrl} />
<main>
<Hero
eyebrow={site.heroEyebrow}
title={site.heroTitle}
subtitle={site.heroSubtitle}
backgroundImageUrl={site.heroBackgroundImageUrl}
textOverImage={site.heroTextOverImage}
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
{!site.heroTextOverImage ? (
<HeroIntro
eyebrow={site.heroEyebrow}
title={site.heroTitle}
subtitle={site.heroSubtitle}
/>
) : null}
<MenuSection
waffles={waffles}
intro={site.menuIntro}
ingredientCatalog={ingredientCatalog}
customWaffle={customWaffle}
/>
<ScheduleSection
events={truckEvents}
recurrences={truckRecurrences}
intro={site.scheduleIntro}
/>
<GallerySection images={galleryImages} intro={site.galleryIntro} />
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<Footer hours={site.hours} />
</>
);
}