update
This commit is contained in:
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminSiteContent } from "@/components/admin/AdminSiteContent";
|
||||
|
||||
export default function AdminSitePage() {
|
||||
return <AdminSiteContent />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image 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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.create({
|
||||
data: { name, price, imageUrl },
|
||||
});
|
||||
revalidatePath("/");
|
||||
return NextResponse.json(ingredient);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
+9
-14
@@ -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
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user