From e79b6dd1419cf3bb0de6dec575c1fce94ff17113 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 01:34:00 +0000 Subject: [PATCH 01/21] feat(p0): prix/nuit + booking form public + /inscription + /reservations/[id] --- .../migration.sql | 2 + prisma/schema.prisma | 2 + src/app/admin/carbets/[id]/page.tsx | 1 + .../admin/carbets/_components/CarbetForm.tsx | 16 +- src/app/admin/carbets/actions.ts | 1 + src/app/admin/carbets/page.tsx | 4 +- src/app/api/signup/route.ts | 64 +++++++ src/app/carbets/[slug]/page.tsx | 14 +- src/app/carbets/_components/booking-form.tsx | 172 ++++++++++++++++++ src/app/connexion/page.tsx | 18 +- .../inscription/_components/SignupForm.tsx | 149 +++++++++++++++ src/app/inscription/page.tsx | 40 ++++ src/app/reservations/[id]/page.tsx | 110 +++++++++++ src/lib/admin/carbets.ts | 3 + src/lib/carbet-public.ts | 3 + 15 files changed, 590 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260601150000_carbet_nightly_price/migration.sql create mode 100644 src/app/api/signup/route.ts create mode 100644 src/app/carbets/_components/booking-form.tsx create mode 100644 src/app/inscription/_components/SignupForm.tsx create mode 100644 src/app/inscription/page.tsx create mode 100644 src/app/reservations/[id]/page.tsx diff --git a/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql new file mode 100644 index 0000000..e53b6bf --- /dev/null +++ b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0; +UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index caa7314..e9aaf6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,8 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int + // Prix par nuit pour le carbet entier (toute capacité). En euros. + nightlyPrice Decimal @db.Decimal(10, 2) @default(0) // Contraintes séjour (plugin min-stay). null = pas de contrainte. minStayNights Int? maxStayNights Int? diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 5e8f635..02c0d80 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -87,6 +87,7 @@ export default async function EditCarbetPage({ params }: PageProps) { latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), capacity: carbet.capacity, + nightlyPrice: carbet.nightlyPrice.toString(), accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, pirogueDurationMin: carbet.pirogueDurationMin, diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx index 6a4f7e4..260996b 100644 --- a/src/app/admin/carbets/_components/CarbetForm.tsx +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -18,6 +18,7 @@ export type CarbetFormInitial = { latitude?: number | string; longitude?: number | string; capacity?: number; + nightlyPrice?: number | string; accessType?: string; roadAccessNote?: string | null; pirogueDurationMin?: number | null; @@ -188,9 +189,9 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe - {/* Séjour */} + {/* Séjour & tarif */}
-

Séjour

+

Séjour & tarif

+ + + Fleuve Accès Cap. + €/nuit Médias Résas Propriétaire @@ -109,7 +110,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) { {carbets.length === 0 ? ( - + Aucun carbet ne correspond aux filtres. @@ -129,6 +130,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) { {c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"} {c.capacity} + {Number(c.nightlyPrice).toFixed(0)} {c.mediaCount} {c.bookingsCount} {c.ownerName} diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts new file mode 100644 index 0000000..db2f49d --- /dev/null +++ b/src/app/api/signup/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { UserRole } from "@/generated/prisma/enums"; +import { hashPassword } from "@/lib/password"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + email: z.string().trim().toLowerCase().email().max(200), + password: z.string().min(8).max(200), + firstName: z.string().trim().min(1).max(100), + lastName: z.string().trim().min(1).max(100), + phone: z.string().trim().max(40).optional().nullable(), + role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST), +}); + +export async function POST(req: Request) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }, + { status: 400 }, + ); + } + const data = parsed.data; + + const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } }); + if (existing) { + return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 }); + } + + const passwordHash = await hashPassword(data.password); + const user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: data.role, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + + await recordAudit({ + scope: "public.signup", + event: "user.create", + target: user.id, + actorEmail: user.email, + details: { role: user.role }, + }); + + return NextResponse.json({ ok: true, userId: user.id }); +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 9a3e51c..e51590a 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -12,6 +12,7 @@ import { import { MediaType, UserRole } from "@/generated/prisma/enums"; import { formatAverageRating } from "@/lib/reviews"; +import { BookingForm } from "../_components/booking-form"; import { CarbetGallery } from "../_components/carbet-gallery"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; @@ -226,10 +227,15 @@ export default async function PublicCarbetPage({ params }: PageProps) {
-

- La réservation en ligne arrive bientôt. En attendant, contactez - l'équipe Karbé pour organiser votre séjour. -

+ diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx new file mode 100644 index 0000000..2c4f1e2 --- /dev/null +++ b/src/app/carbets/_components/booking-form.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +type Props = { + carbetId: string; + slug: string; + nightlyPrice: number; + capacity: number; + minStayNights: number | null; + maxStayNights: number | null; + isAuthenticated: boolean; +}; + +function todayPlus(n: number): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +} + +function diffDays(a: string, b: string): number { + if (!a || !b) return 0; + const da = new Date(a + "T00:00:00Z").getTime(); + const db = new Date(b + "T00:00:00Z").getTime(); + return Math.round((db - da) / 86400000); +} + +export function BookingForm({ + carbetId, + slug, + nightlyPrice, + capacity, + minStayNights, + maxStayNights, + isAuthenticated, +}: Props) { + const router = useRouter(); + const [startDate, setStartDate] = useState(todayPlus(7)); + const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2))); + const [guestCount, setGuestCount] = useState(Math.min(2, capacity)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]); + const total = nights * nightlyPrice; + const minN = minStayNights ?? 1; + const maxN = maxStayNights ?? 365; + const nightsOk = nights >= minN && nights <= maxN; + const guestOk = guestCount >= 1 && guestCount <= capacity; + const canSubmit = nightsOk && guestOk && !busy; + + async function submit() { + if (!isAuthenticated) { + const next = `/carbets/${slug}`; + router.push(`/connexion?next=${encodeURIComponent(next)}`); + return; + } + setBusy(true); + setError(null); + try { + const res = await fetch("/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carbetId, startDate, endDate, guestCount }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(json?.error || `Erreur ${res.status}`); + } + router.push(`/reservations/${json.id ?? json.booking?.id ?? ""}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ {nightlyPrice.toFixed(0)} € + / nuit +
+ jusqu'à {capacity} voyageurs +
+ +
+ + +
+ + + +
+
+ + {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} + + {(nightlyPrice * nights).toFixed(2)} € +
+
+ Total + {total.toFixed(2)} € +
+
+ + {!nightsOk && nights > 0 ? ( +
+ Séjour entre {minN} et {maxN} nuits requis. +
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + + + {!isAuthenticated ? ( +

+ Pas encore de compte ?{" "} + + Créer un compte + +

+ ) : null} + +

+ Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement. +

+
+ ); +} diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx index 032a91b..e66082e 100644 --- a/src/app/connexion/page.tsx +++ b/src/app/connexion/page.tsx @@ -1,11 +1,16 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; import { auth, signIn } from "@/auth"; -export default async function SignInPage() { +type Props = { searchParams: Promise<{ next?: string }> }; + +export default async function SignInPage({ searchParams }: Props) { const session = await auth(); + const sp = await searchParams; + const next = sp.next && sp.next.startsWith("/") ? sp.next : "/"; if (session?.user?.id) { - redirect("/"); + redirect(next); } return ( @@ -48,6 +53,15 @@ export default async function SignInPage() { > Se connecter +

+ Pas encore de compte ?{" "} + + Créer un compte + +

); diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx new file mode 100644 index 0000000..2ffd914 --- /dev/null +++ b/src/app/inscription/_components/SignupForm.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; + +type Props = { next: string }; + +export function SignupForm({ next }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST"); + + function onSubmit(formData: FormData) { + setError(null); + const email = (formData.get("email") as string | null)?.trim() ?? ""; + const password = (formData.get("password") as string | null) ?? ""; + const firstName = (formData.get("firstName") as string | null)?.trim() ?? ""; + const lastName = (formData.get("lastName") as string | null)?.trim() ?? ""; + const phone = (formData.get("phone") as string | null)?.trim() ?? ""; + + if (password.length < 8) { + setError("Le mot de passe doit faire au moins 8 caractères."); + return; + } + + startTransition(async () => { + const res = await fetch("/api/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setError(json?.error || `Erreur ${res.status}`); + return; + } + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (result?.error) { + setError("Compte créé mais connexion impossible. Essayez la page de connexion."); + return; + } + router.push(next); + router.refresh(); + }); + } + + const inputCls = + "w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"; + + return ( +
+
+
+ + +
+ + + + + + + +
+ Type de compte +
+ + +
+
+ + {error ? ( +
{error}
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/inscription/page.tsx b/src/app/inscription/page.tsx new file mode 100644 index 0000000..35e871e --- /dev/null +++ b/src/app/inscription/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { SignupForm } from "./_components/SignupForm"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams: Promise<{ next?: string }>; +}; + +export default async function SignupPage({ searchParams }: PageProps) { + const session = await auth(); + const sp = await searchParams; + const next = sp.next && sp.next.startsWith("/") ? sp.next : "/"; + if (session?.user?.id) redirect(next); + + return ( +
+
+
+

Créer un compte

+

+ Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet. +

+
+ + + +

+ Déjà un compte ?{" "} + + Se connecter + +

+
+
+ ); +} diff --git a/src/app/reservations/[id]/page.tsx b/src/app/reservations/[id]/page.tsx new file mode 100644 index 0000000..857bf1d --- /dev/null +++ b/src/app/reservations/[id]/page.tsx @@ -0,0 +1,110 @@ +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ id: string }> }; + +const STATUS_LABEL: Record = { + PENDING: "En attente de confirmation", + CONFIRMED: "Confirmée", + CANCELLED: "Annulée", + COMPLETED: "Terminée", +}; + +const PAYMENT_LABEL: Record = { + PENDING: "Paiement en attente", + AUTHORIZED: "Paiement autorisé", + SUCCEEDED: "Paiement reçu", + FAILED: "Paiement échoué", + REFUNDED: "Remboursé", +}; + +export default async function ReservationPage({ params }: PageProps) { + const { id } = await params; + const session = await auth(); + if (!session?.user?.id) redirect(`/connexion?next=/reservations/${id}`); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { + carbet: { select: { title: true, slug: true, river: true } }, + tenant: { select: { id: true, email: true } }, + }, + }); + if (!booking) notFound(); + + const isOwner = booking.tenant.id === session.user.id; + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isOwner && !isAdmin) notFound(); + + const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }); + const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000)); + + return ( +
+

Demande de réservation envoyée

+

+ Votre demande pour {booking.carbet.title} a bien été enregistrée. Vous recevrez + un email dès que l'hôte ou l'équipe Karbé l'aura confirmée. +

+ +
+
+ Référence + {booking.id} +
+
+
+
Carbet
+ + {booking.carbet.title} + +
{booking.carbet.river}
+
+
+
Voyageurs
+
+ {booking.guestCount} personne{booking.guestCount > 1 ? "s" : ""} +
+
+
+
Arrivée
+
{dateFmt.format(booking.startDate)}
+
+
+
Départ
+
{dateFmt.format(booking.endDate)}
+
+
+
Total ({nights} nuit{nights > 1 ? "s" : ""})
+
+ {Number(booking.amount).toFixed(2)} {booking.currency} +
+
+
+
+ + {STATUS_LABEL[booking.status] ?? booking.status} + + + {PAYMENT_LABEL[booking.paymentStatus] ?? booking.paymentStatus} + +
+
+ +
+ + ← Retour au carbet + + + Accueil + +
+
+ ); +} diff --git a/src/lib/admin/carbets.ts b/src/lib/admin/carbets.ts index b6e1a92..bf773e2 100644 --- a/src/lib/admin/carbets.ts +++ b/src/lib/admin/carbets.ts @@ -13,6 +13,7 @@ export type AdminCarbetListItem = { title: string; river: string; capacity: number; + nightlyPrice: string; status: CarbetStatus; accessType: AccessType; ownerName: string; @@ -52,6 +53,7 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis title: true, river: true, capacity: true, + nightlyPrice: true, status: true, accessType: true, updatedAt: true, @@ -66,6 +68,7 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis title: r.title, river: r.river, capacity: r.capacity, + nightlyPrice: r.nightlyPrice.toString(), status: r.status, accessType: r.accessType, ownerName: `${r.owner.firstName} ${r.owner.lastName}`.trim() || r.owner.email, diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index 82ed693..61af5c4 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -27,6 +27,7 @@ export type PublicCarbetDetail = { accessType: AccessType; roadAccessNote: string | null; capacity: number; + nightlyPrice: string; minStayNights: number | null; maxStayNights: number | null; minCapacity: number | null; @@ -60,6 +61,7 @@ export const getPublicCarbet = cache( accessType: true, roadAccessNote: true, capacity: true, + nightlyPrice: true, minStayNights: true, maxStayNights: true, minCapacity: true, @@ -110,6 +112,7 @@ export const getPublicCarbet = cache( accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, + nightlyPrice: carbet.nightlyPrice.toString(), minStayNights: carbet.minStayNights, maxStayNights: carbet.maxStayNights, minCapacity: carbet.minCapacity, From b59b8a0af2debd3091a913d29e1d174e704e77ea Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 02:20:38 +0000 Subject: [PATCH 02/21] =?UTF-8?q?feat(p1):=20calendrier=20dispo=20+=20emai?= =?UTF-8?q?ls=20Resend=20+=20amount=20calcul=C3=A9=20+=20best-effort=20wel?= =?UTF-8?q?come/confirmation/refund?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 230 ++++++++++++++++++- package.json | 1 + src/app/admin/bookings/actions.ts | 38 ++- src/app/api/bookings/route.ts | 41 +++- src/app/api/signup/route.ts | 4 + src/app/carbets/_components/booking-form.tsx | 70 +++++- src/lib/email.ts | 207 +++++++++++++++++ 7 files changed, 585 insertions(+), 6 deletions(-) create mode 100644 src/lib/email.ts diff --git a/package-lock.json b/package-lock.json index f80d6c5..547438e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^4.8.0", "stripe": "^18.3.0" }, "devDependencies": { @@ -2231,6 +2232,24 @@ } } }, + "node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2238,6 +2257,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@smithy/core": { "version": "3.24.5", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", @@ -2711,7 +2743,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4028,6 +4060,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -4121,6 +4162,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4197,6 +4293,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5405,6 +5513,41 @@ "node": ">=16.9.0" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -6067,6 +6210,15 @@ "node": ">=0.10" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6915,6 +7067,19 @@ "node": ">=6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6964,6 +7129,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -7221,6 +7395,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prisma": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", @@ -7388,6 +7577,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -7466,6 +7670,18 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", + "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==", + "license": "MIT", + "dependencies": { + "@react-email/render": "1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -7623,6 +7839,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/package.json b/package.json index 4b2d77d..e02165a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^4.8.0", "stripe": "^18.3.0" }, "devDependencies": { diff --git a/src/app/admin/bookings/actions.ts b/src/app/admin/bookings/actions.ts index a8726d4..ca9e401 100644 --- a/src/app/admin/bookings/actions.ts +++ b/src/app/admin/bookings/actions.ts @@ -6,6 +6,7 @@ import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; +import { sendBookingConfirmed, sendBookingRefunded } from "@/lib/email"; async function audit(event: string, target: string, actor: string | null, details: Record) { await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details }); @@ -31,11 +32,32 @@ export async function updateBookingStatusAction(id: string, status: string) { return { ok: false as const, error: "Statut invalide" }; } const session = await auth(); - await prisma.booking.update({ + const before = await prisma.booking.findUnique({ + where: { id }, + select: { status: true }, + }); + const updated = await prisma.booking.update({ where: { id }, data: { status: status as BookingStatus }, + include: { + tenant: { select: { email: true, firstName: true } }, + carbet: { select: { title: true } }, + }, }); await audit("booking.status.update", id, session?.user?.email ?? null, { status }); + if ( + before?.status !== BookingStatus.CONFIRMED && + updated.status === BookingStatus.CONFIRMED + ) { + sendBookingConfirmed( + updated.tenant.email, + updated.tenant.firstName, + updated.id, + updated.carbet.title, + updated.startDate, + updated.endDate, + ).catch(() => {}); + } revalidatePath("/admin/bookings"); revalidatePath(`/admin/bookings/${id}`); return { ok: true as const }; @@ -60,14 +82,26 @@ export async function updateBookingPaymentAction(id: string, paymentStatus: stri export async function refundBookingAction(id: string) { await requireRole([UserRole.ADMIN]); const session = await auth(); - await prisma.booking.update({ + const updated = await prisma.booking.update({ where: { id }, data: { paymentStatus: PaymentStatus.REFUNDED, status: BookingStatus.CANCELLED, }, + include: { + tenant: { select: { email: true, firstName: true } }, + carbet: { select: { title: true } }, + }, }); await audit("booking.refund", id, session?.user?.email ?? null, {}); + sendBookingRefunded( + updated.tenant.email, + updated.tenant.firstName, + updated.id, + updated.carbet.title, + updated.amount.toString(), + updated.currency, + ).catch(() => {}); revalidatePath("/admin/bookings"); revalidatePath(`/admin/bookings/${id}`); return { ok: true as const }; diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts index 11f94ad..8ada7f7 100644 --- a/src/app/api/bookings/route.ts +++ b/src/app/api/bookings/route.ts @@ -16,6 +16,7 @@ import { parseIsoDate, } from "@/lib/booking"; import { prisma } from "@/lib/prisma"; +import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email"; export const runtime = "nodejs"; @@ -78,6 +79,9 @@ export async function POST(request: Request) { ownerId: true, capacity: true, status: true, + nightlyPrice: true, + title: true, + owner: { select: { email: true, firstName: true } }, }, }); @@ -183,6 +187,12 @@ export async function POST(request: Request) { } } + const nights = Math.max( + 1, + Math.round((endDate.getTime() - startDate.getTime()) / 86400000), + ); + const computedAmount = Number(carbet.nightlyPrice) * nights; + const booking = await prisma.booking.create({ data: { carbetId: carbet.id, @@ -191,7 +201,7 @@ export async function POST(request: Request) { endDate, guestCount, status: BookingStatus.PENDING, - amount: 0, + amount: computedAmount.toFixed(2), currency: "EUR", }, select: { @@ -207,5 +217,34 @@ export async function POST(request: Request) { }, }); + // Best-effort emails (n'échouent pas la réservation si Resend down). + const tenant = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { email: true, firstName: true, lastName: true }, + }); + if (tenant) { + sendBookingRequestToTenant( + tenant.email, + tenant.firstName, + booking.id, + carbet.title, + booking.startDate, + booking.endDate, + computedAmount.toFixed(2), + "EUR", + ).catch(() => {}); + } + if (carbet.owner?.email && tenant) { + sendBookingRequestToOwner( + carbet.owner.email, + carbet.owner.firstName, + booking.id, + carbet.title, + `${tenant.firstName} ${tenant.lastName}`.trim(), + booking.startDate, + booking.endDate, + ).catch(() => {}); + } + return NextResponse.json({ booking }, { status: 201 }); } diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index db2f49d..b1044b8 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -5,6 +5,7 @@ import { UserRole } from "@/generated/prisma/enums"; import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; +import { sendSignupWelcome } from "@/lib/email"; export const runtime = "nodejs"; @@ -60,5 +61,8 @@ export async function POST(req: Request) { details: { role: user.role }, }); + // Best-effort welcome email. + sendSignupWelcome(user.email, data.firstName).catch(() => {}); + return NextResponse.json({ ok: true, userId: user.id }); } diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx index 2c4f1e2..c2d85a2 100644 --- a/src/app/carbets/_components/booking-form.tsx +++ b/src/app/carbets/_components/booking-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,6 +43,26 @@ export function BookingForm({ const [guestCount, setGuestCount] = useState(Math.min(2, capacity)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [blockedDates, setBlockedDates] = useState>(new Set()); + + // Fetch availability sur les 90 prochains jours pour griser/avertir. + useEffect(() => { + const ctrl = new AbortController(); + const from = todayPlus(0); + const to = todayPlus(90); + fetch(`/api/carbets/${carbetId}/availability?from=${from}&to=${to}`, { signal: ctrl.signal }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (!j?.calendar) return; + const blocked = new Set(); + for (const d of j.calendar as { date: string; isAvailable: boolean }[]) { + if (!d.isAvailable) blocked.add(d.date); + } + setBlockedDates(blocked); + }) + .catch(() => {}); + return () => ctrl.abort(); + }, [carbetId]); const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]); const total = nights * nightlyPrice; @@ -50,7 +70,28 @@ export function BookingForm({ const maxN = maxStayNights ?? 365; const nightsOk = nights >= minN && nights <= maxN; const guestOk = guestCount >= 1 && guestCount <= capacity; - const canSubmit = nightsOk && guestOk && !busy; + + // Vérifie qu'aucun jour de la plage sélectionnée n'est bloqué. + const conflictDates = useMemo(() => { + if (blockedDates.size === 0 || nights === 0) return []; + const out: string[] = []; + const startMs = new Date(startDate + "T00:00:00Z").getTime(); + for (let i = 0; i < nights; i++) { + const d = new Date(startMs + i * 86400000).toISOString().slice(0, 10); + if (blockedDates.has(d)) out.push(d); + } + return out; + }, [blockedDates, startDate, nights]); + const hasConflict = conflictDates.length > 0; + + const canSubmit = nightsOk && guestOk && !busy && !hasConflict; + + // Prochaines dates bloquées (max 6) pour affichage informatif. + const upcomingBlocked = useMemo(() => { + return Array.from(blockedDates) + .sort() + .slice(0, 6); + }, [blockedDates]); async function submit() { if (!isAuthenticated) { @@ -142,6 +183,31 @@ export function BookingForm({ ) : null} + {hasConflict ? ( +
+ Cette plage chevauche {conflictDates.length} jour{conflictDates.length > 1 ? "s" : ""} déjà + pris ou bloqué{conflictDates.length > 1 ? "s" : ""} ( + {conflictDates.slice(0, 3).join(", ")} + {conflictDates.length > 3 ? "…" : ""}). Changez les dates. +
+ ) : null} + + {upcomingBlocked.length > 0 && !hasConflict ? ( +
+ Voir les prochaines dates indisponibles +
+ {upcomingBlocked.map((d) => ( + + {d} + + ))} + {blockedDates.size > upcomingBlocked.length ? ( + + {blockedDates.size - upcomingBlocked.length} autres + ) : null} +
+
+ ) : null} + {error ? (
{error}
) : null} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..3712ee7 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,207 @@ +/** + * Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console. + * + * Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec + * d'envoi (best-effort, le booking est l'action principale). + */ + +import "server-only"; + +let resendClient: import("resend").Resend | null | undefined; + +async function getResend(): Promise { + if (resendClient !== undefined) return resendClient; + const key = process.env.RESEND_API_KEY?.trim(); + if (!key) { + resendClient = null; + return null; + } + try { + const { Resend } = await import("resend"); + resendClient = new Resend(key); + return resendClient; + } catch (e) { + console.error("[email] resend init failed:", e instanceof Error ? e.message : e); + resendClient = null; + return null; + } +} + +export type EmailOpts = { + to: string | string[]; + subject: string; + html: string; + text?: string; + replyTo?: string; +}; + +const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé "; + +export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> { + const client = await getResend(); + if (!client) { + console.log( + "[email] dry-run (no RESEND_API_KEY):", + JSON.stringify({ to: opts.to, subject: opts.subject }), + ); + return { ok: true, reason: "dry-run" }; + } + try { + const { data, error } = await client.emails.send({ + from: DEFAULT_FROM, + to: Array.isArray(opts.to) ? opts.to : [opts.to], + subject: opts.subject, + html: opts.html, + text: opts.text, + replyTo: opts.replyTo, + }); + if (error) { + console.error("[email] resend error:", error); + return { ok: false, reason: error.message }; + } + return { ok: true, id: data?.id }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[email] send failed:", msg); + return { ok: false, reason: msg }; + } +} + +// ---------- Templates ---------- + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; + +const baseStyle = ` + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #18181b; + max-width: 580px; + margin: 0 auto; + padding: 24px; + line-height: 1.5; +`; + +function wrap(title: string, content: string): string { + return ` +
+

${title}

+ ${content} +
+

+ Karbé · ${SITE_URL}
+ Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le. +

+
+ `; +} + +export async function sendSignupWelcome(to: string, firstName: string): Promise { + await sendEmail({ + to, + subject: "Bienvenue sur Karbé", + html: wrap( + `Bienvenue ${firstName} !`, + `

Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.

+

Découvrir les carbets

`, + ), + text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`, + }); +} + +export async function sendBookingRequestToTenant( + to: string, + firstName: string, + bookingId: string, + carbetTitle: string, + startDate: Date, + endDate: Date, + amount: string, + currency: string, +): Promise { + const fmt = (d: Date) => + new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); + await sendEmail({ + to, + subject: `Demande de réservation enregistrée — ${carbetTitle}`, + html: wrap( + "Demande de réservation envoyée", + `

Bonjour ${firstName},

+

Votre demande de réservation pour ${carbetTitle} a bien été enregistrée :

+
    +
  • Arrivée : ${fmt(startDate)}
  • +
  • Départ : ${fmt(endDate)}
  • +
  • Montant : ${Number(amount).toFixed(2)} ${currency}
  • +
+

Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.

+

Voir ma réservation

`, + ), + }); +} + +export async function sendBookingRequestToOwner( + to: string, + ownerFirstName: string, + bookingId: string, + carbetTitle: string, + tenantName: string, + startDate: Date, + endDate: Date, +): Promise { + const fmt = (d: Date) => + new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); + await sendEmail({ + to, + subject: `Nouvelle demande de réservation — ${carbetTitle}`, + html: wrap( + "Nouvelle demande à confirmer", + `

Bonjour ${ownerFirstName},

+

${tenantName} souhaite réserver ${carbetTitle} :

+
    +
  • Du ${fmt(startDate)} au ${fmt(endDate)}
  • +
+

Connectez-vous à votre espace hôte pour confirmer ou refuser.

+

Mon espace hôte

`, + ), + }); +} + +export async function sendBookingConfirmed( + to: string, + firstName: string, + bookingId: string, + carbetTitle: string, + startDate: Date, + endDate: Date, +): Promise { + const fmt = (d: Date) => + new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); + await sendEmail({ + to, + subject: `Réservation confirmée — ${carbetTitle}`, + html: wrap( + "Votre séjour est confirmé", + `

Bonjour ${firstName},

+

Votre réservation pour ${carbetTitle} du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.

+

Voir ma réservation

`, + ), + }); +} + +export async function sendBookingRefunded( + to: string, + firstName: string, + bookingId: string, + carbetTitle: string, + amount: string, + currency: string, +): Promise { + await sendEmail({ + to, + subject: `Remboursement traité — ${carbetTitle}`, + html: wrap( + "Remboursement en cours", + `

Bonjour ${firstName},

+

Votre réservation pour ${carbetTitle} a été annulée et le remboursement de ${Number(amount).toFixed(2)} ${currency} est en cours de traitement par Stripe (3 à 5 jours ouvrés).

+

Détails de la réservation

`, + ), + }); +} From 14fd9a59405d77d9548c4c947bdbc3cd6c8ae0ad Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 02:27:14 +0000 Subject: [PATCH 03/21] feat(p2): vitest + 27 tests + /api/health enrichi + /api/metrics + workflow CI --- .forgejo/workflows/ci.yml | 59 + package-lock.json | 2102 +++++++++++++++++++++++++++++++++- package.json | 11 +- src/app/api/health/route.ts | 96 +- src/app/api/metrics/route.ts | 78 ++ tests/lib/booking.test.ts | 107 ++ tests/lib/email.test.ts | 30 + tests/lib/password.test.ts | 27 + tests/lib/reviews.test.ts | 50 + vitest.config.ts | 21 + 10 files changed, 2572 insertions(+), 9 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 src/app/api/metrics/route.ts create mode 100644 tests/lib/booking.test.ts create mode 100644 tests/lib/email.test.ts create mode 100644 tests/lib/password.test.ts create mode 100644 tests/lib/reviews.test.ts create mode 100644 vitest.config.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..c148849 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +# Lance lint + typecheck + tests + build sur push/PR. +# +# Workflow dormant tant qu'aucun runner Forgejo n'est enregistré. +# Pour activer : +# 1) Sur git.cosmolan.fr, générer un token runner : +# Admin → Actions → Runners → Create new Runner Token +# (ou pour ce repo seul : Settings → Actions → Runners → Create) +# 2) Sur la machine d'exécution : +# wget https://codeberg.org/forgejo/runner/releases/download/v6.7.0/forgejo-runner-6.7.0-linux-amd64 +# chmod +x forgejo-runner-6.7.0-linux-amd64 +# ./forgejo-runner-6.7.0-linux-amd64 register \ +# --instance https://git.cosmolan.fr \ +# --token \ +# --name karbe-ci \ +# --labels "ubuntu-latest:docker://node:20" +# 3) Démarrer : +# ./forgejo-runner-6.7.0-linux-amd64 daemon + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Generate Prisma client + run: npx prisma generate + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build (smoke) + run: npm run build + env: + # Stubs nécessaires au build statique — pas de connexion réelle. + DATABASE_URL: "postgresql://stub:stub@localhost:5432/stub?schema=public" + NEXTAUTH_SECRET: "ci-secret-not-for-production" + AUTH_SECRET: "ci-secret-not-for-production" + NEXT_PUBLIC_SITE_URL: "https://example.invalid" diff --git a/package-lock.json b/package-lock.json index 547438e..eb5b2bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,12 +26,14 @@ "@types/node": "^20.19.41", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^3.2.4", "dotenv": "^17.4.2", "eslint": "^9.39.4", "eslint-config-next": "^16.2.6", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -47,6 +49,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@auth/core": { "version": "0.41.2", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", @@ -706,7 +722,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -716,7 +732,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -750,7 +766,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -800,7 +816,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -810,6 +826,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -873,6 +899,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1562,6 +2030,34 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1851,6 +2347,17 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -2250,6 +2757,395 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2688,6 +3584,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3357,6 +4271,155 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3397,6 +4460,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3590,6 +4666,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3597,6 +4683,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3781,6 +4886,16 @@ } } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3859,6 +4974,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3889,6 +5021,16 @@ "pnpm": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4053,6 +5195,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4244,6 +5396,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", @@ -4433,6 +5592,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4492,6 +5658,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4911,6 +6119,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4921,6 +6139,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -5163,6 +6391,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5318,6 +6561,28 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5331,6 +6596,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -5513,6 +6804,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -5808,6 +7106,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6060,6 +7368,60 @@ "devOptional": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6078,6 +7440,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -6537,6 +7915,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6573,6 +7958,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6629,6 +8055,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7054,6 +8490,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7122,6 +8565,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7129,6 +8596,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -7747,6 +9224,58 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8069,6 +9598,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8117,6 +9653,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8138,6 +9681,70 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8251,6 +9858,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8274,6 +9921,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stripe": { "version": "18.5.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz", @@ -8376,6 +10043,74 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -8424,6 +10159,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8730,6 +10495,221 @@ } } }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8835,6 +10815,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8845,6 +10842,101 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", diff --git a/package.json b/package.json index e02165a..6a33258 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "eslint", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", @@ -27,11 +30,13 @@ "@types/node": "^20.19.41", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^3.2.4", "dotenv": "^17.4.2", "eslint": "^9.39.4", "eslint-config-next": "^16.2.6", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.2.4" } -} +} \ No newline at end of file diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 5f5ced5..776677d 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,7 +1,101 @@ import { NextResponse } from "next/server"; +import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3"; + +import { prisma } from "@/lib/prisma"; export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Probe = { + name: string; + ok: boolean; + latencyMs: number; + details?: string; +}; + +async function probeDb(): Promise { + const t0 = Date.now(); + try { + await prisma.$queryRaw`SELECT 1 AS ok`; + return { name: "database", ok: true, latencyMs: Date.now() - t0 }; + } catch (e) { + return { + name: "database", + ok: false, + latencyMs: Date.now() - t0, + details: e instanceof Error ? e.message : String(e), + }; + } +} + +async function probeS3(): Promise { + const t0 = Date.now(); + const bucket = process.env.S3_BUCKET; + const endpoint = process.env.S3_ENDPOINT; + if (!bucket || !endpoint) { + return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" }; + } + try { + const client = new S3Client({ + endpoint, + region: process.env.S3_REGION ?? "us-east-1", + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + credentials: { + accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "", + secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "", + }, + }); + await client.send(new HeadBucketCommand({ Bucket: bucket })); + return { name: "s3", ok: true, latencyMs: Date.now() - t0 }; + } catch (e) { + return { + name: "s3", + ok: false, + latencyMs: Date.now() - t0, + details: e instanceof Error ? e.message : String(e), + }; + } +} + +function probeResend(): Probe { + return { + name: "resend", + ok: Boolean(process.env.RESEND_API_KEY?.trim()), + latencyMs: 0, + details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)", + }; +} + +function probeStripe(): Probe { + const key = (process.env.STRIPE_SECRET_KEY ?? "").trim(); + const configured = key.length > 0 && !key.includes("REPLACE_ME"); + return { + name: "stripe", + ok: configured, + latencyMs: 0, + details: configured ? undefined : "STRIPE_SECRET_KEY non configuré", + }; +} export async function GET() { - return NextResponse.json({ status: "ok" }); + const t0 = Date.now(); + const [db, s3] = await Promise.all([probeDb(), probeS3()]); + const resend = probeResend(); + const stripe = probeStripe(); + const probes = [db, s3, resend, stripe]; + + // DB est critique (503 si down). Le reste = non bloquant. + const critical = db.ok; + const status = critical ? 200 : 503; + + return NextResponse.json( + { + status: critical ? "ok" : "degraded", + version: process.env.DEPLOYMENT_VERSION ?? "unknown", + uptimeSeconds: Math.round(process.uptime()), + latencyMs: Date.now() - t0, + probes, + }, + { status }, + ); } diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts new file mode 100644 index 0000000..6bbae30 --- /dev/null +++ b/src/app/api/metrics/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; + +import { BookingStatus, CarbetStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Metrics publiques, agrégées (jamais de PII). + * Format JSON simple — consommable par un script cron ou un dashboard léger. + */ +export async function GET() { + const now = new Date(); + const last24h = new Date(now.getTime() - 86_400_000); + const last7d = new Date(now.getTime() - 7 * 86_400_000); + const last30d = new Date(now.getTime() - 30 * 86_400_000); + + const [ + carbetsPublished, + carbetsTotal, + bookings24h, + bookings7d, + bookings30d, + bookingsByStatus, + usersTotal, + usersByRole, + mediaTotal, + auditLast24h, + ] = await Promise.all([ + prisma.carbet.count({ where: { status: CarbetStatus.PUBLISHED } }), + prisma.carbet.count(), + prisma.booking.count({ where: { createdAt: { gte: last24h } } }), + prisma.booking.count({ where: { createdAt: { gte: last7d } } }), + prisma.booking.count({ where: { createdAt: { gte: last30d } } }), + prisma.booking.groupBy({ + by: ["status"], + _count: { _all: true }, + }), + prisma.user.count(), + prisma.user.groupBy({ + by: ["role"], + _count: { _all: true }, + }), + prisma.media.count(), + prisma.auditLog.count({ where: { createdAt: { gte: last24h } } }), + ]); + + return NextResponse.json({ + generatedAt: now.toISOString(), + carbets: { + total: carbetsTotal, + published: carbetsPublished, + }, + bookings: { + last24h: bookings24h, + last7d: bookings7d, + last30d: bookings30d, + byStatus: Object.fromEntries( + Object.values(BookingStatus).map((s) => [ + s, + bookingsByStatus.find((b) => b.status === s)?._count._all ?? 0, + ]), + ), + }, + users: { + total: usersTotal, + byRole: Object.fromEntries( + Object.values(UserRole).map((r) => [ + r, + usersByRole.find((u) => u.role === r)?._count._all ?? 0, + ]), + ), + }, + media: { total: mediaTotal }, + audit: { last24h: auditLast24h }, + }); +} diff --git a/tests/lib/booking.test.ts b/tests/lib/booking.test.ts new file mode 100644 index 0000000..b25c14d --- /dev/null +++ b/tests/lib/booking.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; + +import { + DAY_MS, + enumerateUtcDays, + hasOverlap, + isPublicAllowedByDefaultPolicy, + isWeekendUtcDay, + normalizeUtcDayStart, + parseIsoDate, +} from "@/lib/booking"; + +describe("parseIsoDate", () => { + it("accepts ISO YYYY-MM-DD", () => { + const d = parseIsoDate("2026-06-15"); + expect(d).toBeInstanceOf(Date); + expect(d?.toISOString().startsWith("2026-06-15")).toBe(true); + }); + + it("returns null for garbage input", () => { + expect(parseIsoDate("not a date")).toBeNull(); + expect(parseIsoDate(null)).toBeNull(); + expect(parseIsoDate(undefined)).toBeNull(); + expect(parseIsoDate(123)).toBeNull(); + }); +}); + +describe("normalizeUtcDayStart", () => { + it("zeroes out time components", () => { + const d = new Date("2026-06-15T17:30:45.123Z"); + const n = normalizeUtcDayStart(d); + expect(n.getUTCHours()).toBe(0); + expect(n.getUTCMinutes()).toBe(0); + expect(n.getUTCSeconds()).toBe(0); + expect(n.getUTCMilliseconds()).toBe(0); + expect(n.toISOString().slice(0, 10)).toBe("2026-06-15"); + }); +}); + +describe("hasOverlap", () => { + const d = (iso: string) => new Date(iso); + + it("detects partial overlap", () => { + expect( + hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-13"), d("2026-06-20")), + ).toBe(true); + }); + + it("returns false for adjacent intervals (touching)", () => { + expect( + hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-15"), d("2026-06-20")), + ).toBe(false); + }); + + it("returns false for fully separate", () => { + expect( + hasOverlap(d("2026-06-01"), d("2026-06-05"), d("2026-06-10"), d("2026-06-15")), + ).toBe(false); + }); + + it("returns true when one contains the other", () => { + expect( + hasOverlap(d("2026-06-01"), d("2026-06-30"), d("2026-06-10"), d("2026-06-15")), + ).toBe(true); + }); +}); + +describe("enumerateUtcDays", () => { + it("enumerates each day between start and end (exclusive)", () => { + const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-13")); + expect(days.length).toBe(3); + expect(days[0].toISOString().slice(0, 10)).toBe("2026-06-10"); + expect(days[2].toISOString().slice(0, 10)).toBe("2026-06-12"); + }); + + it("returns empty when start === end", () => { + const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-10")); + expect(days).toEqual([]); + }); +}); + +describe("isWeekendUtcDay", () => { + it("flags Saturday (2026-06-13)", () => { + expect(isWeekendUtcDay(new Date("2026-06-13"))).toBe(true); + }); + it("flags Sunday (2026-06-14)", () => { + expect(isWeekendUtcDay(new Date("2026-06-14"))).toBe(true); + }); + it("rejects Monday (2026-06-15)", () => { + expect(isWeekendUtcDay(new Date("2026-06-15"))).toBe(false); + }); +}); + +describe("isPublicAllowedByDefaultPolicy", () => { + it("blocks weekends by default (CE-priority policy)", () => { + expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-13"))).toBe(false); + }); + it("allows weekdays", () => { + expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-15"))).toBe(true); + }); +}); + +describe("DAY_MS constant", () => { + it("equals 86_400_000", () => { + expect(DAY_MS).toBe(86_400_000); + }); +}); diff --git a/tests/lib/email.test.ts b/tests/lib/email.test.ts new file mode 100644 index 0000000..f8a965d --- /dev/null +++ b/tests/lib/email.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock server-only avant l'import du module sous test (sinon ça jette). +vi.mock("server-only", () => ({})); + +describe("sendEmail (dry-run sans RESEND_API_KEY)", () => { + beforeEach(() => { + delete process.env.RESEND_API_KEY; + vi.resetModules(); + }); + + it("renvoie ok=true + reason=dry-run quand pas de clé", async () => { + const { sendEmail } = await import("@/lib/email"); + const res = await sendEmail({ + to: "test@example.com", + subject: "hello", + html: "

world

", + }); + expect(res.ok).toBe(true); + expect(res.reason).toBe("dry-run"); + }); + + it("n'écrit pas d'erreur quand pas de clé", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { sendEmail } = await import("@/lib/email"); + await sendEmail({ to: "x@y.z", subject: "s", html: "h" }); + expect(errSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/tests/lib/password.test.ts b/tests/lib/password.test.ts new file mode 100644 index 0000000..3232701 --- /dev/null +++ b/tests/lib/password.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; + +import { hashPassword, verifyPassword } from "@/lib/password"; + +describe("password hashing", () => { + it("round-trips a correct password", async () => { + const plain = "correct horse battery staple"; + const hash = await hashPassword(plain); + expect(hash).not.toEqual(plain); + expect(hash.startsWith("$2")).toBe(true); + expect(await verifyPassword(plain, hash)).toBe(true); + }); + + it("rejects incorrect password", async () => { + const hash = await hashPassword("rightpass123"); + expect(await verifyPassword("wrongpass", hash)).toBe(false); + }); + + it("produces different hashes for the same plaintext (salted)", async () => { + const plain = "samepw"; + const a = await hashPassword(plain); + const b = await hashPassword(plain); + expect(a).not.toEqual(b); + expect(await verifyPassword(plain, a)).toBe(true); + expect(await verifyPassword(plain, b)).toBe(true); + }); +}); diff --git a/tests/lib/reviews.test.ts b/tests/lib/reviews.test.ts new file mode 100644 index 0000000..d136b8e --- /dev/null +++ b/tests/lib/reviews.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; + +import { + REVIEW_COMMENT_MAX, + REVIEW_HOST_RESPONSE_MAX, + REVIEW_RATING_MAX, + REVIEW_RATING_MIN, + formatAverageRating, + isValidRating, +} from "@/lib/reviews"; + +describe("rating constants", () => { + it("min=1 max=5", () => { + expect(REVIEW_RATING_MIN).toBe(1); + expect(REVIEW_RATING_MAX).toBe(5); + }); + it("comment + host response caps are sensible", () => { + expect(REVIEW_COMMENT_MAX).toBeGreaterThan(0); + expect(REVIEW_HOST_RESPONSE_MAX).toBeGreaterThan(0); + }); +}); + +describe("isValidRating", () => { + it("accepts integers 1-5", () => { + for (let i = REVIEW_RATING_MIN; i <= REVIEW_RATING_MAX; i++) { + expect(isValidRating(i)).toBe(true); + } + }); + it("rejects out-of-range", () => { + expect(isValidRating(0)).toBe(false); + expect(isValidRating(6)).toBe(false); + expect(isValidRating(-1)).toBe(false); + }); + it("rejects non-integers and non-numbers", () => { + expect(isValidRating(3.5)).toBe(false); + expect(isValidRating("3")).toBe(false); + expect(isValidRating(null)).toBe(false); + expect(isValidRating(undefined)).toBe(false); + }); +}); + +describe("formatAverageRating", () => { + it("returns dash for null", () => { + expect(formatAverageRating(null)).toMatch(/—|-|n\/a/i); + }); + it("formats a number with one decimal", () => { + const s = formatAverageRating(4.567); + expect(s).toMatch(/4[.,]/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..997df99 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["node_modules", ".next", "dist"], + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/lib/**/*.ts"], + exclude: ["src/lib/**/*.d.ts", "src/lib/admin/**", "src/lib/plugins/**"], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From 6eed6bffc8888d4391cbf23b5ddafbd407d9dc07 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 04:18:49 +0000 Subject: [PATCH 04/21] fix(ci): 5 erreurs ESLint Next 16 (Date.now impure, vers /admin, setState dans effect) --- .../carbets/[id]/_components/MediaManager.tsx | 3 +-- src/app/admin/page.tsx | 25 ++++++++++--------- src/components/admin/CommandPalette.tsx | 7 ++++-- src/components/admin/TopBar.tsx | 20 +++++++++++---- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/app/admin/carbets/[id]/_components/MediaManager.tsx b/src/app/admin/carbets/[id]/_components/MediaManager.tsx index 47947da..ab91606 100644 --- a/src/app/admin/carbets/[id]/_components/MediaManager.tsx +++ b/src/app/admin/carbets/[id]/_components/MediaManager.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useTransition } from "react"; -import Image from "next/image"; import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions"; import { FormField, inputCls, selectCls } from "@/components/admin/FormField"; @@ -125,7 +124,7 @@ export function MediaManager({ carbetId, media: initial }: { carbetId: string; m - + {/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */} {error ?
{error}
: null}
diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx index 16f82dd..2d395da 100644 --- a/src/components/admin/CommandPalette.tsx +++ b/src/components/admin/CommandPalette.tsx @@ -50,12 +50,15 @@ export function CommandPalette() { }, []); useEffect(() => { - if (open) { + if (!open) return; + // Différé via microtask pour éviter le warning "Calling setState synchronously + // within an effect can trigger cascading renders" (react-hooks/purity). + queueMicrotask(() => { setQuery(""); setHits([]); setSelected(0); setTimeout(() => inputRef.current?.focus(), 50); - } + }); }, [open]); const runSearch = useCallback(async (q: string) => { diff --git a/src/components/admin/TopBar.tsx b/src/components/admin/TopBar.tsx index e06f7c0..339ef48 100644 --- a/src/components/admin/TopBar.tsx +++ b/src/components/admin/TopBar.tsx @@ -1,12 +1,22 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; + +function subscribe() { + // navigator.userAgent ne change pas durant la session, pas d'abonnement réel. + return () => {}; +} + +function getSnapshot(): boolean { + return navigator.userAgent.includes("Mac"); +} + +function getServerSnapshot(): boolean { + return false; +} export function TopBar({ userEmail }: { userEmail: string }) { - const [isMac, setIsMac] = useState(false); - useEffect(() => { - setIsMac(navigator.userAgent.includes("Mac")); - }, []); + const isMac = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return (
From 3bc52b2b6043b0a353012424fba4cacf8aba88d9 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 04:29:52 +0000 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20global=20SiteHeader=20avec=20user?= =?UTF-8?q?=20menu=20(login/inscription,=20Mes=20r=C3=A9servations,=20Espa?= =?UTF-8?q?ce=20h=C3=B4te,=20Admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 2 + src/components/SignOutButton.tsx | 19 +++++++ src/components/SiteHeader.tsx | 79 ++++++++++++++++++++++++++++++ src/components/SiteHeaderGuard.tsx | 26 ++++++++++ src/middleware.ts | 23 +++++++++ 5 files changed, 149 insertions(+) create mode 100644 src/components/SignOutButton.tsx create mode 100644 src/components/SiteHeader.tsx create mode 100644 src/components/SiteHeaderGuard.tsx create mode 100644 src/middleware.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 30c807f..2a05155 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { PluginProvider } from "@/lib/plugins/client"; import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; import { SeasonBanner } from "@/components/SeasonBanner"; +import { SiteHeaderGuard } from "@/components/SiteHeaderGuard"; import { LocaleProvider } from "@/lib/i18n/client"; import { dict, getLocale } from "@/lib/i18n/server"; @@ -102,6 +103,7 @@ export default async function RootLayout({ + {children} diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx new file mode 100644 index 0000000..9837157 --- /dev/null +++ b/src/components/SignOutButton.tsx @@ -0,0 +1,19 @@ +import { signOut } from "@/auth"; + +export function SignOutButton() { + return ( +
{ + "use server"; + await signOut({ redirectTo: "/" }); + }} + > + +
+ ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx new file mode 100644 index 0000000..5e88e57 --- /dev/null +++ b/src/components/SiteHeader.tsx @@ -0,0 +1,79 @@ +/** + * Header global affiché sur toutes les pages PUBLIQUES (hors /admin qui a son + * propre shell). Charge la session côté serveur pour adapter les liens. + */ + +import Link from "next/link"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; + +import { SignOutButton } from "./SignOutButton"; + +export async function SiteHeader() { + const session = await auth(); + const u = session?.user; + const isAdmin = u?.role === UserRole.ADMIN; + const isOwner = u?.role === UserRole.OWNER || isAdmin; + + return ( +
+
+ + + K + + Karbé + + + + +
+ {u ? ( + <> + + Mes réservations + + {isOwner ? ( + + Espace hôte + + ) : null} + {isAdmin ? ( + + Admin + + ) : null} + + {u.name || u.email} + + + + ) : ( + <> + + Connexion + + + Créer un compte + + + )} +
+
+
+ ); +} diff --git a/src/components/SiteHeaderGuard.tsx b/src/components/SiteHeaderGuard.tsx new file mode 100644 index 0000000..6a1eddb --- /dev/null +++ b/src/components/SiteHeaderGuard.tsx @@ -0,0 +1,26 @@ +/** + * N'affiche le SiteHeader QUE sur les pages publiques. + * Sur /admin, le shell admin a déjà sa propre TopBar + Sidebar. + * Sur /connexion et /inscription, on garde la page nue. + */ + +import { headers } from "next/headers"; + +import { SiteHeader } from "./SiteHeader"; + +export async function SiteHeaderGuard() { + const h = await headers(); + // Next.js 16 expose le pathname via le header x-pathname si on l'a posé, + // sinon on retombe sur next-url ou referer. On utilise une heuristique simple : + // pathname depuis x-invoke-path (Next internal) ou x-next-url-path-prefix. + const pathname = + h.get("x-pathname") ?? + h.get("x-invoke-path") ?? + h.get("next-url") ?? + ""; + + if (pathname.startsWith("/admin")) return null; + if (pathname === "/connexion" || pathname === "/inscription") return null; + + return ; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..db39867 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,23 @@ +/** + * Middleware Karbé. + * + * Pose `x-pathname` sur tous les requests pour que les server components puissent + * lire le path courant via `headers()` (utile pour SiteHeaderGuard qui décide + * de rendre ou non le header global selon /admin vs reste). + */ + +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + response.headers.set("x-pathname", request.nextUrl.pathname); + return response; +} + +export const config = { + // Exclut les assets statiques + API auth (qu'on ne veut pas modifier). + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|api/auth|api/health|api/metrics).*)", + ], +}; From 31aa7a4865d6a281fb914f973be6a6295c93089e Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 05:27:33 +0000 Subject: [PATCH 06/21] feat: calendrier visuel mensuel + carte Leaflet sur fiche carbet --- package-lock.json | 49 +++++ package.json | 5 +- src/app/carbets/[slug]/page.tsx | 20 ++ src/app/carbets/_components/booking-form.tsx | 137 +++++-------- .../carbets/_components/carbet-map-inner.tsx | 74 +++++++ src/app/carbets/_components/carbet-map.tsx | 31 +++ src/app/carbets/_components/mini-calendar.tsx | 186 ++++++++++++++++++ 7 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 src/app/carbets/_components/carbet-map-inner.tsx create mode 100644 src/app/carbets/_components/carbet-map.tsx create mode 100644 src/app/carbets/_components/mini-calendar.tsx diff --git a/package-lock.json b/package-lock.json index eb5b2bd..c1f89be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "@aws-sdk/client-s3": "^3.1056.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@types/leaflet": "^1.9.21", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", "stripe": "^18.3.0" }, @@ -2757,6 +2760,17 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -3609,6 +3623,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3623,6 +3643,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -7597,6 +7626,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9054,6 +9089,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", diff --git a/package.json b/package.json index 6a33258..000a852 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "@aws-sdk/client-s3": "^3.1056.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@types/leaflet": "^1.9.21", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", "stripe": "^18.3.0" }, @@ -39,4 +42,4 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index e51590a..53544fd 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -14,6 +14,7 @@ import { formatAverageRating } from "@/lib/reviews"; import { BookingForm } from "../_components/booking-form"; import { CarbetGallery } from "../_components/carbet-gallery"; +import { CarbetMap } from "../_components/carbet-map"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; @@ -144,6 +145,25 @@ export default async function PublicCarbetPage({ params }: PageProps) { provider={carbet.pirogueProvider} /> +
+

+ Où se trouve ce carbet +

+

+ Fleuve {carbet.river} · embarquement à{" "} + {carbet.embarkPoint} +

+
+ +
+
+ {carbet.amenities.length > 0 ? (

diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx index c2d85a2..522a017 100644 --- a/src/app/carbets/_components/booking-form.tsx +++ b/src/app/carbets/_components/booking-form.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { MiniCalendar } from "./mini-calendar"; + type Props = { carbetId: string; slug: string; @@ -38,8 +40,8 @@ export function BookingForm({ isAuthenticated, }: Props) { const router = useRouter(); - const [startDate, setStartDate] = useState(todayPlus(7)); - const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2))); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); const [guestCount, setGuestCount] = useState(Math.min(2, capacity)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -64,34 +66,18 @@ export function BookingForm({ return () => ctrl.abort(); }, [carbetId]); - const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]); + const nights = useMemo( + () => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0), + [startDate, endDate], + ); const total = nights * nightlyPrice; const minN = minStayNights ?? 1; const maxN = maxStayNights ?? 365; - const nightsOk = nights >= minN && nights <= maxN; + const datesSelected = Boolean(startDate && endDate); + const nightsOk = datesSelected && nights >= minN && nights <= maxN; const guestOk = guestCount >= 1 && guestCount <= capacity; - // Vérifie qu'aucun jour de la plage sélectionnée n'est bloqué. - const conflictDates = useMemo(() => { - if (blockedDates.size === 0 || nights === 0) return []; - const out: string[] = []; - const startMs = new Date(startDate + "T00:00:00Z").getTime(); - for (let i = 0; i < nights; i++) { - const d = new Date(startMs + i * 86400000).toISOString().slice(0, 10); - if (blockedDates.has(d)) out.push(d); - } - return out; - }, [blockedDates, startDate, nights]); - const hasConflict = conflictDates.length > 0; - - const canSubmit = nightsOk && guestOk && !busy && !hasConflict; - - // Prochaines dates bloquées (max 6) pour affichage informatif. - const upcomingBlocked = useMemo(() => { - return Array.from(blockedDates) - .sort() - .slice(0, 6); - }, [blockedDates]); + const canSubmit = nightsOk && guestOk && !busy; async function submit() { if (!isAuthenticated) { @@ -129,28 +115,34 @@ export function BookingForm({ jusqu'à {capacity} voyageurs

-
- - -
+ { + setStartDate(s); + setEndDate(e); + setError(null); + }} + /> + + {datesSelected ? ( +
+ + {startDate}{endDate} + + +
+ ) : null} -
-
- - {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} - - {(nightlyPrice * nights).toFixed(2)} € + {datesSelected ? ( +
+
+ + {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} + + {(nightlyPrice * nights).toFixed(2)} € +
+
+ Total + {total.toFixed(2)} € +
-
- Total - {total.toFixed(2)} € -
-
+ ) : null} - {!nightsOk && nights > 0 ? ( + {datesSelected && !nightsOk ? (
Séjour entre {minN} et {maxN} nuits requis.
) : null} - {hasConflict ? ( -
- Cette plage chevauche {conflictDates.length} jour{conflictDates.length > 1 ? "s" : ""} déjà - pris ou bloqué{conflictDates.length > 1 ? "s" : ""} ( - {conflictDates.slice(0, 3).join(", ")} - {conflictDates.length > 3 ? "…" : ""}). Changez les dates. -
- ) : null} - - {upcomingBlocked.length > 0 && !hasConflict ? ( -
- Voir les prochaines dates indisponibles -
- {upcomingBlocked.map((d) => ( - - {d} - - ))} - {blockedDates.size > upcomingBlocked.length ? ( - + {blockedDates.size - upcomingBlocked.length} autres - ) : null} -
-
- ) : null} - {error ? (
{error}
) : null} diff --git a/src/app/carbets/_components/carbet-map-inner.tsx b/src/app/carbets/_components/carbet-map-inner.tsx new file mode 100644 index 0000000..63d1ab6 --- /dev/null +++ b/src/app/carbets/_components/carbet-map-inner.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; +import L from "leaflet"; + +import "leaflet/dist/leaflet.css"; + +// Fix icône Leaflet (les paths par défaut pointent vers un CDN qui n'existe plus). +// On utilise un SVG inline en data URL. +const ICON = L.divIcon({ + className: "karbe-leaflet-marker", + html: ` +
+ + + + +
+ `, + iconSize: [32, 40], + iconAnchor: [16, 40], + popupAnchor: [0, -36], +}); + +type Props = { + latitude: number; + longitude: number; + title: string; + river: string; + embarkPoint: string; +}; + +export function CarbetMapInner({ latitude, longitude, title, river, embarkPoint }: Props) { + const position: [number, number] = [latitude, longitude]; + + return ( +
+ + + + + {title} +
+ Fleuve {river} +
+ Embarquement : {embarkPoint} +
+ + Ouvrir dans OpenStreetMap ↗ + +
+
+
+
+ ); +} diff --git a/src/app/carbets/_components/carbet-map.tsx b/src/app/carbets/_components/carbet-map.tsx new file mode 100644 index 0000000..31b9718 --- /dev/null +++ b/src/app/carbets/_components/carbet-map.tsx @@ -0,0 +1,31 @@ +"use client"; + +/** + * Carte interactive sur la fiche carbet — Leaflet + OpenStreetMap. + * + * Chargée dynamiquement (ssr:false) car Leaflet manipule window. + */ + +import dynamic from "next/dynamic"; + +const CarbetMapInner = dynamic( + () => import("./carbet-map-inner").then((m) => m.CarbetMapInner), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +type Props = { + latitude: number; + longitude: number; + title: string; + river: string; + embarkPoint: string; +}; + +export function CarbetMap(props: Props) { + return ; +} diff --git a/src/app/carbets/_components/mini-calendar.tsx b/src/app/carbets/_components/mini-calendar.tsx new file mode 100644 index 0000000..bdcbb0d --- /dev/null +++ b/src/app/carbets/_components/mini-calendar.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type Props = { + startDate: string | null; + endDate: string | null; + blockedDates: Set; + onChange: (start: string | null, end: string | null) => void; +}; + +const MONTH_LABEL = [ + "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +]; +const DOW_LABEL = ["L", "M", "M", "J", "V", "S", "D"]; + +function isoDay(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function startOfMonth(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); +} + +function addMonths(d: Date, n: number): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1)); +} + +/** Génère la grille du mois : 6 semaines × 7 jours, en commençant un lundi. */ +function monthGrid(monthStart: Date): (Date | null)[] { + const year = monthStart.getUTCFullYear(); + const month = monthStart.getUTCMonth(); + // Premier jour du mois — décale pour que la semaine commence un lundi (0=L, 6=D) + const firstDay = new Date(Date.UTC(year, month, 1)); + const firstDow = (firstDay.getUTCDay() + 6) % 7; + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const cells: (Date | null)[] = []; + for (let i = 0; i < firstDow; i++) cells.push(null); + for (let d = 1; d <= lastDay; d++) { + cells.push(new Date(Date.UTC(year, month, d))); + } + while (cells.length % 7 !== 0) cells.push(null); + // Toujours 6 lignes pour éviter le saut de hauteur + while (cells.length < 42) cells.push(null); + return cells; +} + +export function MiniCalendar({ startDate, endDate, blockedDates, onChange }: Props) { + const today = useMemo(() => { + const d = new Date(); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + }, []); + + const [viewMonth, setViewMonth] = useState(() => { + const ref = startDate ? new Date(startDate + "T00:00:00Z") : today; + return startOfMonth(ref); + }); + + const cells = useMemo(() => monthGrid(viewMonth), [viewMonth]); + + const startISO = startDate; + const endISO = endDate; + + function onClick(day: Date) { + const iso = isoDay(day); + if (day.getTime() < today.getTime()) return; + if (blockedDates.has(iso)) return; + + // Aucune sélection ou les deux déjà posées → reset + nouvelle start + if (!startISO || (startISO && endISO)) { + onChange(iso, null); + return; + } + // Une seule (start) déjà sélectionnée + if (iso === startISO) { + onChange(null, null); + return; + } + if (iso < startISO) { + onChange(iso, null); + return; + } + // Vérifie qu'aucun jour intermédiaire n'est bloqué + const startMs = new Date(startISO + "T00:00:00Z").getTime(); + const endMs = day.getTime(); + for (let t = startMs; t < endMs; t += 86_400_000) { + const d = new Date(t).toISOString().slice(0, 10); + if (blockedDates.has(d)) { + // Tombe sur un jour bloqué → on resélectionne start + onChange(iso, null); + return; + } + } + onChange(startISO, iso); + } + + const canGoBack = viewMonth > startOfMonth(today); + + return ( +
+
+ + + {MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()} + + +
+ +
+ {DOW_LABEL.map((d, i) => ( +
+ {d} +
+ ))} +
+ +
+ {cells.map((cell, i) => { + if (!cell) return
; + const iso = isoDay(cell); + const isPast = cell.getTime() < today.getTime(); + const isBlocked = blockedDates.has(iso); + const isStart = iso === startISO; + const isEnd = iso === endISO; + const inRange = startISO && endISO && iso > startISO && iso < endISO; + const isToday = iso === isoDay(today); + const disabled = isPast || isBlocked; + + let cls = + "relative h-7 rounded text-xs flex items-center justify-center transition"; + if (disabled) { + cls += " text-zinc-300 cursor-not-allowed"; + if (isBlocked && !isPast) cls += " line-through"; + } else if (isStart || isEnd) { + cls += " bg-emerald-600 text-white font-semibold cursor-pointer"; + } else if (inRange) { + cls += " bg-emerald-100 text-emerald-900 cursor-pointer"; + } else { + cls += " text-zinc-800 hover:bg-zinc-100 cursor-pointer"; + if (isToday) cls += " ring-1 ring-zinc-400"; + } + + return ( + + ); + })} +
+ +

+ {!startISO + ? "Choisissez votre date d'arrivée." + : !endISO + ? "Choisissez votre date de départ." + : ""} +

+
+ ); +} From a6df96db7edefdf19d7d6431f0c24b5683ecdf70 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 10:16:37 +0000 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20reset=20password=20+=20page=20mon?= =?UTF-8?q?-compte=20(RGPD)=20+=20facettes=20recherche=20(prix=20max,=20?= =?UTF-8?q?=C3=A9quipements)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 9 ++ prisma/schema.prisma | 10 ++ src/app/api/me/export/route.ts | 103 ++++++++++++++++ src/app/api/password/reset-request/route.ts | 50 ++++++++ src/app/api/password/reset/route.ts | 40 ++++++ src/app/connexion/page.tsx | 5 + src/app/mon-compte/_components/DangerZone.tsx | 67 ++++++++++ .../mon-compte/_components/PasswordForm.tsx | 69 +++++++++++ .../mon-compte/_components/ProfileForm.tsx | 88 ++++++++++++++ src/app/mon-compte/actions.ts | 115 ++++++++++++++++++ src/app/mon-compte/page.tsx | 71 +++++++++++ .../[token]/_components/ResetForm.tsx | 75 ++++++++++++ src/app/mot-de-passe-oublie/[token]/page.tsx | 33 +++++ .../_components/ResetRequestForm.tsx | 52 ++++++++ src/app/mot-de-passe-oublie/page.tsx | 34 ++++++ src/components/SiteHeader.tsx | 3 + src/lib/carbet-search.ts | 30 +++++ src/lib/email.ts | 17 +++ src/lib/password-reset.ts | 51 ++++++++ 19 files changed, 922 insertions(+) create mode 100644 prisma/migrations/20260601060000_password_reset_token/migration.sql create mode 100644 src/app/api/me/export/route.ts create mode 100644 src/app/api/password/reset-request/route.ts create mode 100644 src/app/api/password/reset/route.ts create mode 100644 src/app/mon-compte/_components/DangerZone.tsx create mode 100644 src/app/mon-compte/_components/PasswordForm.tsx create mode 100644 src/app/mon-compte/_components/ProfileForm.tsx create mode 100644 src/app/mon-compte/actions.ts create mode 100644 src/app/mon-compte/page.tsx create mode 100644 src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx create mode 100644 src/app/mot-de-passe-oublie/[token]/page.tsx create mode 100644 src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx create mode 100644 src/app/mot-de-passe-oublie/page.tsx create mode 100644 src/lib/password-reset.ts diff --git a/prisma/migrations/20260601060000_password_reset_token/migration.sql b/prisma/migrations/20260601060000_password_reset_token/migration.sql new file mode 100644 index 0000000..50033de --- /dev/null +++ b/prisma/migrations/20260601060000_password_reset_token/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "PasswordResetToken" ( + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash") +); +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); +CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9aaf6d..f59864e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -361,3 +361,13 @@ model Translation { @@id([key, lang]) @@index([lang]) } + +model PasswordResetToken { + tokenHash String @id + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([expiresAt]) +} diff --git a/src/app/api/me/export/route.ts b/src/app/api/me/export/route.ts new file mode 100644 index 0000000..a235301 --- /dev/null +++ b/src/app/api/me/export/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */ +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const userId = session.user.id; + + const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + phone: true, + role: true, + avatarUrl: true, + isActive: true, + createdAt: true, + updatedAt: true, + organizationId: true, + }, + }), + prisma.booking.findMany({ + where: { tenantId: userId }, + select: { + id: true, + carbetId: true, + startDate: true, + endDate: true, + guestCount: true, + status: true, + paymentStatus: true, + amount: true, + currency: true, + createdAt: true, + }, + }), + prisma.review.findMany({ + where: { authorId: userId }, + select: { + id: true, + bookingId: true, + carbetId: true, + rating: true, + comment: true, + createdAt: true, + }, + }), + prisma.carbet.findMany({ + where: { ownerId: userId }, + select: { id: true, slug: true, title: true, status: true, createdAt: true }, + }), + prisma.subscription.findMany({ + where: { ownerId: userId }, + select: { id: true, carbetId: true, status: true, provider: true, startedAt: true }, + }), + ]); + + await recordAudit({ + scope: "public.profile", + event: "data.export", + target: userId, + actorEmail: session.user.email ?? null, + details: {}, + }); + + const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`; + return new NextResponse( + JSON.stringify( + { + exportedAt: new Date().toISOString(), + rgpdNotice: + "Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.", + user, + bookings, + reviews, + carbets, + subscriptions, + }, + null, + 2, + ), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }, + ); +} diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts new file mode 100644 index 0000000..1eaedc9 --- /dev/null +++ b/src/app/api/password/reset-request/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createPasswordResetToken } from "@/lib/password-reset"; +import { prisma } from "@/lib/prisma"; +import { sendPasswordReset } from "@/lib/email"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + email: z.string().trim().toLowerCase().email(), +}); + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; + +export async function POST(req: Request) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + // Réponse générique pour ne pas leak la validité du format à un attaquant. + return NextResponse.json({ ok: true }); + } + + const user = await prisma.user.findUnique({ + where: { email: parsed.data.email }, + select: { id: true, email: true, firstName: true, isActive: true }, + }); + + if (user && user.isActive) { + const token = await createPasswordResetToken(user.id); + const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`; + sendPasswordReset(user.email, resetUrl).catch(() => {}); + await recordAudit({ + scope: "public.password", + event: "reset.request", + target: user.id, + actorEmail: user.email, + details: {}, + }); + } + + // Réponse identique que l'email existe ou non (énumération-safe). + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/password/reset/route.ts b/src/app/api/password/reset/route.ts new file mode 100644 index 0000000..1883076 --- /dev/null +++ b/src/app/api/password/reset/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { consumePasswordResetToken } from "@/lib/password-reset"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + token: z.string().min(20).max(200), + password: z.string().min(8).max(200), +}); + +export async function POST(req: Request) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join(" · ") }, + { status: 400 }, + ); + } + const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password); + if (!result.ok) { + return NextResponse.json({ error: result.reason }, { status: 400 }); + } + await recordAudit({ + scope: "public.password", + event: "reset.success", + target: result.userId, + actorEmail: null, + details: {}, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx index e66082e..5ac18e4 100644 --- a/src/app/connexion/page.tsx +++ b/src/app/connexion/page.tsx @@ -53,6 +53,11 @@ export default async function SignInPage({ searchParams }: Props) { > Se connecter +

+ + Mot de passe oublié ? + +

Pas encore de compte ?{" "} ("idle"); + const [typed, setTyped] = useState(""); + + function deleteAccount() { + startTransition(async () => { + await deleteAccountAction(); + }); + } + + return ( +

+

+ La suppression anonymise votre compte (nom, email, téléphone effacés). Vos réservations + passées restent en base pour les obligations comptables, mais ne sont plus rattachées à + des données personnelles identifiantes. +

+ {step === "idle" ? ( + + ) : ( +
+

+ Pour confirmer, saisissez SUPPRIMER ci-dessous. +

+ setTyped(e.target.value)} + className="w-full rounded-md border border-rose-300 px-3 py-1.5 text-sm focus:border-rose-500 focus:outline-none" + disabled={pending} + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/src/app/mon-compte/_components/PasswordForm.tsx b/src/app/mon-compte/_components/PasswordForm.tsx new file mode 100644 index 0000000..b809b87 --- /dev/null +++ b/src/app/mon-compte/_components/PasswordForm.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; + +import { changePasswordAction } from "../actions"; + +export function PasswordForm() { + const formRef = useRef(null); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setSuccess(null); + const next = (fd.get("next") as string | null) ?? ""; + const confirm = (fd.get("confirm") as string | null) ?? ""; + if (next !== confirm) { + setError("Les deux nouveaux mots de passe ne correspondent pas."); + return; + } + startTransition(async () => { + const res = await changePasswordAction(fd); + if (res && res.ok === false) setError(res.error); + else { + setSuccess("Mot de passe mis à jour."); + formRef.current?.reset(); + } + }); + } + + const inputCls = + "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"; + + return ( +
+
+ +
+ + +
+ + {error ? ( +
{error}
+ ) : null} + {success ? ( +
{success}
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/mon-compte/_components/ProfileForm.tsx b/src/app/mon-compte/_components/ProfileForm.tsx new file mode 100644 index 0000000..23eac80 --- /dev/null +++ b/src/app/mon-compte/_components/ProfileForm.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { updateProfileAction } from "../actions"; + +type Props = { + initial: { firstName: string; lastName: string; phone: string | null }; +}; + +export function ProfileForm({ initial }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await updateProfileAction(fd); + if (res && res.ok === false) setError(res.error); + else { + setSuccess("Profil enregistré."); + router.refresh(); + } + }); + } + + const inputCls = + "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"; + + return ( +
+
+
+ + +
+ + + {error ? ( +
{error}
+ ) : null} + {success ? ( +
{success}
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/mon-compte/actions.ts b/src/app/mon-compte/actions.ts new file mode 100644 index 0000000..c002a49 --- /dev/null +++ b/src/app/mon-compte/actions.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { auth, signOut } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { hashPassword, verifyPassword } from "@/lib/password"; +import { recordAudit } from "@/lib/admin/audit"; + +const profileSchema = z.object({ + firstName: z.string().trim().min(1).max(100), + lastName: z.string().trim().min(1).max(100), + phone: z.string().trim().max(40).optional().nullable(), +}); + +const passwordSchema = z + .object({ + current: z.string().min(1), + next: z.string().min(8).max(200), + }) + .refine((d) => d.current !== d.next, { message: "Le nouveau mdp doit être différent de l'ancien." }); + +async function requireSelf() { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + return session; +} + +export async function updateProfileAction(fd: FormData) { + const session = await requireSelf(); + const parsed = profileSchema.safeParse({ + firstName: fd.get("firstName"), + lastName: fd.get("lastName"), + phone: ((fd.get("phone") as string | null) ?? "").trim() || null, + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") }; + } + await prisma.user.update({ + where: { id: session.user.id }, + data: parsed.data, + }); + await recordAudit({ + scope: "public.profile", + event: "profile.update", + target: session.user.id, + actorEmail: session.user.email ?? null, + details: parsed.data, + }); + revalidatePath("/mon-compte"); + return { ok: true as const }; +} + +export async function changePasswordAction(fd: FormData) { + const session = await requireSelf(); + const parsed = passwordSchema.safeParse({ + current: fd.get("current"), + next: fd.get("next"), + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") }; + } + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + if (!user) return { ok: false as const, error: "Utilisateur introuvable." }; + const ok = await verifyPassword(parsed.data.current, user.passwordHash); + if (!ok) return { ok: false as const, error: "Mot de passe actuel incorrect." }; + await prisma.user.update({ + where: { id: session.user.id }, + data: { passwordHash: await hashPassword(parsed.data.next) }, + }); + await recordAudit({ + scope: "public.profile", + event: "password.change", + target: session.user.id, + actorEmail: session.user.email ?? null, + details: {}, + }); + return { ok: true as const }; +} + +/** Supprime le compte + cascade : bookings restent en DB (anonymisées) pour les obligations comptables. */ +export async function deleteAccountAction() { + const session = await requireSelf(); + const userId = session.user.id; + const email = session.user.email ?? ""; + + // Anonymise plutôt que supprimer pour préserver l'historique comptable des bookings. + const anon = `anonymise-${userId}@karbe.invalid`; + await prisma.user.update({ + where: { id: userId }, + data: { + email: anon, + firstName: "Compte", + lastName: "supprimé", + phone: null, + passwordHash: "", // verrouille le login (bcrypt.compare retournera toujours false) + isActive: false, + }, + }); + await prisma.passwordResetToken.deleteMany({ where: { userId } }); + await recordAudit({ + scope: "public.profile", + event: "account.delete", + target: userId, + actorEmail: email, + details: { anonymisedTo: anon }, + }); + await signOut({ redirect: false }); + redirect("/?account=deleted"); +} diff --git a/src/app/mon-compte/page.tsx b/src/app/mon-compte/page.tsx new file mode 100644 index 0000000..982b78a --- /dev/null +++ b/src/app/mon-compte/page.tsx @@ -0,0 +1,71 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; + +import { ProfileForm } from "./_components/ProfileForm"; +import { PasswordForm } from "./_components/PasswordForm"; +import { DangerZone } from "./_components/DangerZone"; + +export const dynamic = "force-dynamic"; + +export default async function MyAccountPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/connexion?next=/mon-compte"); + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { email: true, firstName: true, lastName: true, phone: true, role: true, createdAt: true }, + }); + if (!user) redirect("/connexion"); + + const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }); + + return ( +
+
+

Mon compte

+

+ Connecté avec {user.email} · inscrit le {dateFmt.format(user.createdAt)} +

+
+ +
+

Identité

+ +
+ +
+

+ Sécurité +

+ +
+ +
+

+ Mes données (RGPD) +

+

+ Téléchargez l'intégralité des données associées à votre compte au format JSON, + conformément à l'article 20 du RGPD (droit à la portabilité). +

+ + Télécharger mes données + +
+ +
+

+ Zone dangereuse +

+ +
+
+ ); +} diff --git a/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx new file mode 100644 index 0000000..e77c3e7 --- /dev/null +++ b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +export function ResetForm({ token }: { token: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + const password = (fd.get("password") as string | null) ?? ""; + const confirm = (fd.get("confirm") as string | null) ?? ""; + if (password.length < 8) { + setError("Mot de passe trop court (8 caractères min)."); + return; + } + if (password !== confirm) { + setError("Les deux mots de passe ne correspondent pas."); + return; + } + startTransition(async () => { + const res = await fetch("/api/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setError(json?.error || `Erreur ${res.status}`); + return; + } + router.push("/connexion?reset=ok"); + }); + } + + return ( +
+
+ + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ ); +} diff --git a/src/app/mot-de-passe-oublie/[token]/page.tsx b/src/app/mot-de-passe-oublie/[token]/page.tsx new file mode 100644 index 0000000..80893ac --- /dev/null +++ b/src/app/mot-de-passe-oublie/[token]/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; + +import { ResetForm } from "./_components/ResetForm"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ token: string }> }; + +export default async function ResetPage({ params }: PageProps) { + const { token } = await params; + + return ( +
+
+
+

Nouveau mot de passe

+

+ Choisissez un mot de passe d'au moins 8 caractères. Vous serez redirigé vers la + connexion une fois enregistré. +

+
+ + + +

+ + Retour à la connexion + +

+
+
+ ); +} diff --git a/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx new file mode 100644 index 0000000..563622a --- /dev/null +++ b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useTransition } from "react"; + +export function ResetRequestForm() { + const [pending, startTransition] = useTransition(); + const [done, setDone] = useState(false); + + function onSubmit(fd: FormData) { + const email = (fd.get("email") as string | null)?.trim() ?? ""; + if (!email) return; + startTransition(async () => { + await fetch("/api/password/reset-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }).catch(() => {}); + setDone(true); + }); + } + + if (done) { + return ( +
+ Si un compte existe pour cet email, vous recevrez un lien dans quelques instants. Pensez à + vérifier vos spams. +
+ ); + } + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/app/mot-de-passe-oublie/page.tsx b/src/app/mot-de-passe-oublie/page.tsx new file mode 100644 index 0000000..1ac4a60 --- /dev/null +++ b/src/app/mot-de-passe-oublie/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { ResetRequestForm } from "./_components/ResetRequestForm"; + +export const dynamic = "force-dynamic"; + +export default async function ForgotPasswordPage() { + const session = await auth(); + if (session?.user?.id) redirect("/"); + + return ( +
+
+
+

Mot de passe oublié

+

+ Saisissez votre email. Si un compte existe, vous recevrez un lien valable 1 heure pour + choisir un nouveau mot de passe. +

+
+ + + +

+ + Retour à la connexion + +

+
+
+ ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 5e88e57..96d9836 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -44,6 +44,9 @@ export async function SiteHeader() { Mes réservations + + Mon compte + {isOwner ? ( Espace hôte diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index 0f25da3..bed9fd5 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -16,6 +16,8 @@ export type CarbetSearchFilters = { // Filtre plugin access-type : si "river-only" exclu, on garde uniquement // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe. accessibility?: "road-only" | "all"; + priceMax?: number; + amenities?: string[]; }; export type RawSearchParams = { @@ -69,6 +71,24 @@ export function parseSearchFilters( filters.accessibility = accessibility; } + const priceMaxRaw = pickString(searchParams.priceMax); + if (priceMaxRaw) { + const priceMax = Number(priceMaxRaw); + if (Number.isFinite(priceMax) && priceMax > 0 && priceMax <= 10000) { + filters.priceMax = priceMax; + } + } + + const amenitiesRaw = searchParams.amenities; + if (amenitiesRaw) { + const arr = Array.isArray(amenitiesRaw) ? amenitiesRaw : [amenitiesRaw]; + const keys = arr + .flatMap((s) => s.split(",")) + .map((s) => s.trim()) + .filter((s) => /^[a-z0-9-]{1,40}$/.test(s)); + if (keys.length > 0) filters.amenities = keys.slice(0, 10); + } + return filters; } @@ -112,6 +132,16 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { where.accessType = AccessType.ROAD_AND_RIVER; } + if (filters.priceMax !== undefined) { + where.nightlyPrice = { lte: filters.priceMax }; + } + + if (filters.amenities && filters.amenities.length > 0) { + where.AND = filters.amenities.map((key) => ({ + amenities: { some: { amenity: { key } } }, + })); + } + if (filters.startDate && filters.endDate) { where.availabilities = { some: { diff --git a/src/lib/email.ts b/src/lib/email.ts index 3712ee7..4652796 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -186,6 +186,23 @@ export async function sendBookingConfirmed( }); } +export async function sendPasswordReset( + to: string, + resetUrl: string, +): Promise { + await sendEmail({ + to, + subject: "Réinitialisation de votre mot de passe Karbé", + html: wrap( + "Réinitialiser votre mot de passe", + `

Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :

+

Réinitialiser mon mot de passe

+

Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.

`, + ), + text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`, + }); +} + export async function sendBookingRefunded( to: string, firstName: string, diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts new file mode 100644 index 0000000..31959da --- /dev/null +++ b/src/lib/password-reset.ts @@ -0,0 +1,51 @@ +import "server-only"; + +import crypto from "node:crypto"; + +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/password"; + +const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** Crée un token (renvoie la version *plain* à mettre dans l'URL email). */ +export async function createPasswordResetToken(userId: string): Promise { + const token = crypto.randomBytes(32).toString("base64url"); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + TOKEN_TTL_MS); + await prisma.passwordResetToken.create({ + data: { tokenHash, userId, expiresAt }, + }); + return token; +} + +/** Vérifie un token plain → renvoie userId si valide & non expiré. */ +export async function consumePasswordResetToken( + plainToken: string, + newPassword: string, +): Promise<{ ok: true; userId: string } | { ok: false; reason: string }> { + const tokenHash = hashToken(plainToken); + const row = await prisma.passwordResetToken.findUnique({ where: { tokenHash } }); + if (!row) return { ok: false, reason: "Lien invalide." }; + if (row.expiresAt < new Date()) { + await prisma.passwordResetToken.delete({ where: { tokenHash } }).catch(() => {}); + return { ok: false, reason: "Lien expiré." }; + } + const passwordHash = await hashPassword(newPassword); + await prisma.$transaction([ + prisma.user.update({ where: { id: row.userId }, data: { passwordHash } }), + prisma.passwordResetToken.deleteMany({ where: { userId: row.userId } }), + ]); + return { ok: true, userId: row.userId }; +} + +/** Cleanup utility — peut être lancé par un cron. */ +export async function purgeExpiredResetTokens(): Promise { + const result = await prisma.passwordResetToken.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + return result.count; +} From a58815ec9cc9bcf9a2830f457762a533af126423 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 10:21:03 +0000 Subject: [PATCH 08/21] fix: ajout effectif facettes priceMax + amenities dans SearchFilters (oubli PR#57) --- .../carbets/_components/search-filters.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/app/carbets/_components/search-filters.tsx b/src/app/carbets/_components/search-filters.tsx index 999f66c..fb383f2 100644 --- a/src/app/carbets/_components/search-filters.tsx +++ b/src/app/carbets/_components/search-filters.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type { CarbetSearchFilters } from "@/lib/carbet-search"; +import { AMENITY_CATALOG } from "@/lib/amenities"; type SearchFiltersProps = { filters: CarbetSearchFilters; @@ -73,6 +74,48 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) { /> + + +
+ Équipements souhaités +
+ {AMENITY_CATALOG.map((a) => { + const checked = (filters.amenities ?? []).includes(a.key); + return ( + + ); + })} +
+
+
Date: Mon, 1 Jun 2026 16:16:25 +0000 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20dashboard=20espace=20h=C3=B4te=20?= =?UTF-8?q?(KPIs=20+=20r=C3=A9sa=20pending=20+=20carbets=20+=20activit?= =?UTF-8?q?=C3=A9)=20+=20lightbox=20galerie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carbets/_components/carbet-gallery.tsx | 222 ++++++++++++++---- .../_components/BookingDecision.tsx | 77 ++++++ src/app/espace-hote/actions.ts | 75 ++++++ src/lib/host-dashboard.ts | 203 ++++++++++++++++ 4 files changed, 527 insertions(+), 50 deletions(-) create mode 100644 src/app/espace-hote/_components/BookingDecision.tsx create mode 100644 src/app/espace-hote/actions.ts create mode 100644 src/lib/host-dashboard.ts diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx index 807adda..a5c7ca1 100644 --- a/src/app/carbets/_components/carbet-gallery.tsx +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + import type { PublicCarbetMedia } from "@/lib/carbet-public"; import { MediaType } from "@/generated/prisma/enums"; @@ -6,9 +10,36 @@ type Props = { media: PublicCarbetMedia[]; }; -// SSR-friendly gallery: shows a cover (photo or video) plus a strip of -// secondary media. No client component — all native HTML controls. +/** + * Galerie publique : grille de vignettes ; clic = lightbox plein écran avec + * navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe. + */ export function CarbetGallery({ title, media }: Props) { + const [active, setActive] = useState(null); + + const close = useCallback(() => setActive(null), []); + const next = useCallback(() => { + setActive((i) => (i === null ? null : (i + 1) % media.length)); + }, [media.length]); + const prev = useCallback(() => { + setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length)); + }, [media.length]); + + useEffect(() => { + if (active === null) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + else if (e.key === "ArrowRight") next(); + else if (e.key === "ArrowLeft") prev(); + } + window.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [active, close, next, prev]); + if (media.length === 0) { return (
@@ -17,57 +48,148 @@ export function CarbetGallery({ title, media }: Props) { ); } - const [cover, ...rest] = media; + const cover = media[0]; + const rest = media.slice(1); + const current = active === null ? null : media[active]; return ( -
-
- {cover.type === MediaType.VIDEO ? ( -
+ <> +
+ - {rest.length > 0 ? ( -
    - {rest.map((item) => ( -
  • - {item.type === MediaType.VIDEO ? ( -
  • - ))} -
+ {rest.length > 0 ? ( +
    + {rest.map((item, idx) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + {current ? ( +
+ + + {media.length > 1 ? ( + <> + + + + ) : null} + +
e.stopPropagation()} + > + {current.type === MediaType.VIDEO ? ( +
+ +
+ {active! + 1} / {media.length} +
+
) : null} -
+ ); } diff --git a/src/app/espace-hote/_components/BookingDecision.tsx b/src/app/espace-hote/_components/BookingDecision.tsx new file mode 100644 index 0000000..9380ea2 --- /dev/null +++ b/src/app/espace-hote/_components/BookingDecision.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { confirmBookingAsHost, rejectBookingAsHost } from "../actions"; + +export function BookingDecision({ bookingId }: { bookingId: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmReject, setConfirmReject] = useState(false); + const [error, setError] = useState(null); + + function accept() { + setError(null); + startTransition(async () => { + const res = await confirmBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + router.refresh(); + }); + } + function reject() { + setError(null); + startTransition(async () => { + const res = await rejectBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + setConfirmReject(false); + router.refresh(); + }); + } + + return ( +
+ {confirmReject ? ( +
+ Refuser ? + + +
+ ) : ( + <> + + + + )} + {error ? {error} : null} +
+ ); +} diff --git a/src/app/espace-hote/actions.ts b/src/app/espace-hote/actions.ts new file mode 100644 index 0000000..fa9208c --- /dev/null +++ b/src/app/espace-hote/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { BookingStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; +import { sendBookingConfirmed } from "@/lib/email"; + +async function requireBookingOwnership(bookingId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + carbet: { select: { ownerId: true, title: true } }, + tenant: { select: { email: true, firstName: true } }, + }, + }); + if (!booking) throw new Error("Réservation introuvable"); + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isAdmin && booking.carbet.ownerId !== session.user.id) { + throw new Error("Accès refusé"); + } + return { session, booking }; +} + +export async function confirmBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être confirmée." }; + } + const updated = await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CONFIRMED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "confirm", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + sendBookingConfirmed( + booking.tenant.email, + booking.tenant.firstName, + bookingId, + booking.carbet.title, + updated.startDate, + updated.endDate, + ).catch(() => {}); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} + +export async function rejectBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être refusée." }; + } + await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CANCELLED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "reject", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} diff --git a/src/lib/host-dashboard.ts b/src/lib/host-dashboard.ts new file mode 100644 index 0000000..586ff65 --- /dev/null +++ b/src/lib/host-dashboard.ts @@ -0,0 +1,203 @@ +import "server-only"; + +import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type HostKpis = { + revenueTotal: string; + revenue30d: string; + revenue365d: string; + bookingsPending: number; + bookingsConfirmedUpcoming: number; + bookingsTotal: number; + carbetsCount: number; + carbetsPublished: number; + occupancyRate30d: number; // 0..1 + nextArrival: { id: string; carbetTitle: string; startDate: Date; tenantName: string } | null; +}; + +type Scope = { ownerId: string; isAdmin: boolean }; + +function scopeWhere(scope: Scope) { + return scope.isAdmin ? {} : { carbet: { ownerId: scope.ownerId } }; +} + +function carbetWhere(scope: Scope) { + return scope.isAdmin ? {} : { ownerId: scope.ownerId }; +} + +export async function getHostKpis(scope: Scope): Promise { + const now = new Date(); + const last30 = new Date(now.getTime() - 30 * 86_400_000); + const last365 = new Date(now.getTime() - 365 * 86_400_000); + + const [revAll, rev30, rev365, pending, upcomingConfirmed, total, carbetsTotal, carbetsPub, nextArrival] = + await Promise.all([ + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last30 }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last365 }, + }, + _sum: { amount: true }, + }), + prisma.booking.count({ + where: { ...scopeWhere(scope), status: BookingStatus.PENDING }, + }), + prisma.booking.count({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + }), + prisma.booking.count({ where: scopeWhere(scope) }), + prisma.carbet.count({ where: carbetWhere(scope) }), + prisma.carbet.count({ where: { ...carbetWhere(scope), status: "PUBLISHED" } }), + prisma.booking.findFirst({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + orderBy: { startDate: "asc" }, + select: { + id: true, + startDate: true, + carbet: { select: { title: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }), + ]); + + // Taux d'occupation 30j : nuits réservées / (carbets publiés × 30) + const occupiedNights = await prisma.booking.findMany({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + startDate: { lt: now }, + endDate: { gte: last30 }, + }, + select: { startDate: true, endDate: true }, + }); + let totalNightsOccupied = 0; + for (const b of occupiedNights) { + const s = Math.max(b.startDate.getTime(), last30.getTime()); + const e = Math.min(b.endDate.getTime(), now.getTime()); + if (e > s) totalNightsOccupied += Math.floor((e - s) / 86_400_000); + } + const denom = Math.max(1, carbetsPub * 30); + const occupancyRate30d = Math.min(1, totalNightsOccupied / denom); + + return { + revenueTotal: (revAll._sum.amount ?? 0).toString(), + revenue30d: (rev30._sum.amount ?? 0).toString(), + revenue365d: (rev365._sum.amount ?? 0).toString(), + bookingsPending: pending, + bookingsConfirmedUpcoming: upcomingConfirmed, + bookingsTotal: total, + carbetsCount: carbetsTotal, + carbetsPublished: carbetsPub, + occupancyRate30d, + nextArrival: nextArrival + ? { + id: nextArrival.id, + carbetTitle: nextArrival.carbet.title, + startDate: nextArrival.startDate, + tenantName: `${nextArrival.tenant.firstName} ${nextArrival.tenant.lastName}`.trim(), + } + : null, + }; +} + +export type HostRecentBooking = { + id: string; + carbetId: string; + carbetTitle: string; + carbetSlug: string; + tenantName: string; + startDate: Date; + endDate: Date; + guestCount: number; + status: BookingStatus; + paymentStatus: PaymentStatus; + amount: string; + currency: string; +}; + +export async function listHostRecentBookings( + scope: Scope, + limit = 10, +): Promise { + const rows = await prisma.booking.findMany({ + where: scopeWhere(scope), + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + take: limit, + select: { + id: true, + startDate: true, + endDate: true, + guestCount: true, + status: true, + paymentStatus: true, + amount: true, + currency: true, + carbet: { select: { id: true, title: true, slug: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + carbetId: r.carbet.id, + carbetTitle: r.carbet.title, + carbetSlug: r.carbet.slug, + tenantName: `${r.tenant.firstName} ${r.tenant.lastName}`.trim(), + startDate: r.startDate, + endDate: r.endDate, + guestCount: r.guestCount, + status: r.status, + paymentStatus: r.paymentStatus, + amount: r.amount.toString(), + currency: r.currency, + })); +} + +export async function listHostCarbets(scope: Scope) { + const rows = await prisma.carbet.findMany({ + where: carbetWhere(scope), + orderBy: [{ updatedAt: "desc" }], + select: { + id: true, + slug: true, + title: true, + status: true, + nightlyPrice: true, + capacity: true, + river: true, + _count: { select: { bookings: true, reviews: true, media: true } }, + }, + }); + return rows.map((r) => ({ ...r, nightlyPrice: r.nightlyPrice.toString() })); +} + +export function isScopeAdmin(role: UserRole | string | undefined): boolean { + return role === UserRole.ADMIN; +} From 55c024433643a59bcd6320bf232365c96a88a923 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 16:20:06 +0000 Subject: [PATCH 10/21] fix: rebrancher espace-hote/page.tsx sur le nouveau dashboard (oubli PR#59) --- src/app/espace-hote/page.tsx | 292 +++++++++++++++++++++++++++++++++-- 1 file changed, 277 insertions(+), 15 deletions(-) diff --git a/src/app/espace-hote/page.tsx b/src/app/espace-hote/page.tsx index d412d73..32f3d03 100644 --- a/src/app/espace-hote/page.tsx +++ b/src/app/espace-hote/page.tsx @@ -1,25 +1,287 @@ import Link from "next/link"; +import { auth } from "@/auth"; import { requireRole } from "@/lib/authorization"; +import { BookingStatus, UserRole } from "@/generated/prisma/enums"; +import { + getHostKpis, + listHostCarbets, + listHostRecentBookings, + isScopeAdmin, +} from "@/lib/host-dashboard"; -export default async function HostPage() { - const session = await requireRole(["OWNER", "ADMIN"]); +import { BookingDecision } from "./_components/BookingDecision"; + +export const dynamic = "force-dynamic"; + +const STATUS_TONES: Record = { + PENDING: "bg-sky-100 text-sky-800 ring-sky-300", + CONFIRMED: "bg-emerald-100 text-emerald-800 ring-emerald-300", + CANCELLED: "bg-rose-100 text-rose-700 ring-rose-300", + COMPLETED: "bg-zinc-100 text-zinc-700 ring-zinc-300", + SUCCEEDED: "bg-emerald-100 text-emerald-800 ring-emerald-300", + REFUNDED: "bg-amber-100 text-amber-800 ring-amber-300", + FAILED: "bg-rose-100 text-rose-700 ring-rose-300", + AUTHORIZED: "bg-indigo-100 text-indigo-800 ring-indigo-300", + DRAFT: "bg-zinc-100 text-zinc-700 ring-zinc-300", + PUBLISHED: "bg-emerald-100 text-emerald-800 ring-emerald-300", + ARCHIVED: "bg-amber-100 text-amber-800 ring-amber-300", +}; + +const STATUS_LABEL: Record = { + PENDING: "En attente", + CONFIRMED: "Confirmée", + CANCELLED: "Annulée", + COMPLETED: "Terminée", + SUCCEEDED: "Payé", + REFUNDED: "Remboursé", + FAILED: "Échec", + AUTHORIZED: "Autorisé", + DRAFT: "Brouillon", + PUBLISHED: "Publié", + ARCHIVED: "Archivé", +}; + +function Badge({ value }: { value: string }) { + const tone = STATUS_TONES[value] ?? STATUS_TONES.PENDING; + return ( + + {STATUS_LABEL[value] ?? value} + + ); +} + +function fmtEur(amount: string, currency: string): string { + const n = Number(amount); + return n.toLocaleString("fr-FR", { style: "currency", currency: currency || "EUR" }); +} + +const dateFmt = new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "2-digit", +}); + +export default async function HostDashboardPage() { + await requireRole([UserRole.OWNER, UserRole.ADMIN]); + const session = await auth(); + const userId = session!.user.id; + const isAdmin = isScopeAdmin(session?.user?.role); + const scope = { ownerId: userId, isAdmin }; + + const [kpis, recent, carbets] = await Promise.all([ + getHostKpis(scope), + listHostRecentBookings(scope, 12), + listHostCarbets(scope), + ]); + + const pendingBookings = recent.filter((b) => b.status === BookingStatus.PENDING); return ( -
-

Espace hôte

-

- Accès autorisé pour {session.user.email} ({session.user.role}). -

+
+
+
+

Espace hôte

+

+ Bienvenue {session?.user?.name || session?.user?.email}.{" "} + {isAdmin ? "Vue globale (admin)." : "Vue limitée à vos carbets."} +

+
+
+ + + Nouveau carbet + + + Tous mes carbets + +
+
-
- - Gérer mes carbets - -
+
+ + + + 0 ? "warn" : "neutral"} + /> + + +
+ + {kpis.nextArrival ? ( +
+
Prochaine arrivée
+
+ {kpis.nextArrival.tenantName} · {kpis.nextArrival.carbetTitle} +
+
+ {dateFmt.format(kpis.nextArrival.startDate)} +
+
+ ) : null} + + {pendingBookings.length > 0 ? ( +
+

+ Demandes en attente ({pendingBookings.length}) +

+
    + {pendingBookings.map((b) => ( +
  • +
    +
    + {b.tenantName} — {b.carbetTitle} +
    +
    + {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} ·{" "} + {b.guestCount} pers · {fmtEur(b.amount, b.currency)} +
    +
    + +
  • + ))} +
+
+ ) : null} + +
+

+ Mes carbets ({carbets.length}) +

+ {carbets.length === 0 ? ( +
+ Aucun carbet pour l'instant.{" "} + + Créer mon premier carbet + +
+ ) : ( +
+ + + + + + + + + + + + + + + {carbets.map((c) => ( + + + + + + + + + + + ))} + +
TitreFleuve€/nuitCap.MédiasRésasAvisStatut
+ + {c.title} + +
+ /{c.slug} +
+
{c.river} + {Number(c.nightlyPrice).toFixed(0)} + {c.capacity} + {c._count.media} + + {c._count.bookings} + + {c._count.reviews} + + +
+
+ )} +
+ + {recent.length > 0 ? ( +
+

+ Activité récente +

+
+ + + + + + + + + + + + + {recent.map((b) => ( + + + + + + + + + ))} + +
CarbetLocataireSéjourMontantRésaPaiement
{b.carbetTitle}{b.tenantName} + {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} + + {fmtEur(b.amount, b.currency)} + + + + +
+
+
+ ) : null}
); } + +function Kpi({ + label, + value, + tone = "neutral", +}: { + label: string; + value: string; + tone?: "neutral" | "warn"; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} From a373bd60ad8bb3405316613f300f2d058e2e207f Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 20:16:57 +0000 Subject: [PATCH 11/21] =?UTF-8?q?feat(hardening):=20rate=20limit=20(signup?= =?UTF-8?q?/reset/bookings)=20+=20t=C3=A2ches=20cron=20+=20backup=20Postgr?= =?UTF-8?q?eSQL=20nocturne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-postgres.sh | 51 ++++++++++++ src/app/api/bookings/route.ts | 9 +++ src/app/api/cron/run/[task]/route.ts | 37 +++++++++ src/app/api/password/reset-request/route.ts | 8 ++ src/app/api/signup/route.ts | 9 +++ src/lib/rate-limit.ts | 86 +++++++++++++++++++++ src/lib/scheduled.ts | 75 ++++++++++++++++++ tests/lib/rate-limit.test.ts | 44 +++++++++++ 8 files changed, 319 insertions(+) create mode 100755 scripts/backup-postgres.sh create mode 100644 src/app/api/cron/run/[task]/route.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/scheduled.ts create mode 100644 tests/lib/rate-limit.test.ts diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..fa2d461 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# +# Backup nightly du PostgreSQL Karbé vers MinIO. +# Lancé par un systemd timer (karbe-backup.timer). +# +# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un +# nettoyage côté `mc rm` planifié — TODO si on veut être propre). + +set -euo pipefail + +STAMP=$(date -u +%Y%m%d-%H%M%S) +DUMP_DIR=/tmp/karbe-backup +DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz" +BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz" + +mkdir -p "$DUMP_DIR" + +# Dump compressé depuis le conteneur postgres +docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \ + -f /home/ubuntu/karbe/docker-compose.override.yml \ + exec -T postgres pg_dump -U karbe -d karbe \ + | gzip > "$DUMP_FILE" + +SIZE=$(stat -c %s "$DUMP_FILE") +echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}" + +# Push vers MinIO via mc Docker +docker run --rm --network karbe-net \ + -v "$DUMP_DIR:/dump" \ + minio/mc:latest sh -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \ + mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST} + " \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD + +echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" + +# Nettoyage local +rm -f "$DUMP_FILE" + +# Rétention : supprime les backups > 30 jours dans MinIO +docker run --rm --network karbe-net minio/mc:latest sh -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true +" \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD + +echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)" diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts index 8ada7f7..e315ed3 100644 --- a/src/app/api/bookings/route.ts +++ b/src/app/api/bookings/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/booking"; import { prisma } from "@/lib/prisma"; import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -28,6 +29,14 @@ type CreateBookingBody = { }; export async function POST(request: Request) { + const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } + const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); diff --git a/src/app/api/cron/run/[task]/route.ts b/src/app/api/cron/run/[task]/route.ts new file mode 100644 index 0000000..ff2beba --- /dev/null +++ b/src/app/api/cron/run/[task]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function authorized(req: Request): boolean { + const secret = (process.env.CRON_TOKEN ?? "").trim(); + if (!secret) return false; + const header = req.headers.get("authorization") ?? ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + return token === secret; +} + +export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) { + if (!authorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { task } = await ctx.params; + const fn = SCHEDULED_TASKS[task as ScheduledTaskName]; + if (!fn) { + return NextResponse.json( + { error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` }, + { status: 404 }, + ); + } + try { + const result = await fn(); + return NextResponse.json({ ok: true, task, result }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts index 1eaedc9..9bcbc32 100644 --- a/src/app/api/password/reset-request/route.ts +++ b/src/app/api/password/reset-request/route.ts @@ -5,6 +5,7 @@ import { createPasswordResetToken } from "@/lib/password-reset"; import { prisma } from "@/lib/prisma"; import { sendPasswordReset } from "@/lib/email"; import { recordAudit } from "@/lib/admin/audit"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -15,6 +16,13 @@ const schema = z.object({ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; export async function POST(req: Request) { + const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } let body: unknown; try { body = await req.json(); diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index b1044b8..1ded993 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -6,6 +6,7 @@ import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; import { sendSignupWelcome } from "@/lib/email"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -19,6 +20,14 @@ const schema = z.object({ }); export async function POST(req: Request) { + // 5 inscriptions max par IP par heure. + const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } let body: unknown; try { body = await req.json(); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..41c27f1 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,86 @@ +/** + * Token-bucket en mémoire — best-effort par instance. + * + * Pour un déploiement multi-instance, swap pour un store partagé (Redis). + * Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc + * une Map locale suffit. + * + * Usage : + * const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 }); + * if (!r.ok) return tooManyRequests(r.retryAfter); + */ + +type Bucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +const SWEEP_INTERVAL_MS = 60_000; +let lastSweep = 0; + +function sweep(now: number) { + if (now - lastSweep < SWEEP_INTERVAL_MS) return; + lastSweep = now; + for (const [k, b] of buckets) { + if (b.resetAt <= now) buckets.delete(k); + } +} + +export type RateLimitOpts = { + key: string; + /** Fenêtre glissante en ms. */ + windowMs: number; + /** Nombre max d'appels par fenêtre. */ + limit: number; +}; + +export type RateLimitResult = { + ok: boolean; + remaining: number; + retryAfter: number; // secondes +}; + +export function rateLimit(opts: RateLimitOpts): RateLimitResult { + const now = Date.now(); + sweep(now); + + const b = buckets.get(opts.key); + if (!b || b.resetAt <= now) { + buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs }); + return { ok: true, remaining: opts.limit - 1, retryAfter: 0 }; + } + + if (b.count >= opts.limit) { + return { + ok: false, + remaining: 0, + retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)), + }; + } + + b.count++; + return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 }; +} + +/** Extract a client IP from a request, fallback to a safe default. */ +export function getClientIp(req: Request): string { + // nginx-proxy-manager pose x-forwarded-for, x-real-ip + const xff = req.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0].trim(); + const xri = req.headers.get("x-real-ip"); + if (xri) return xri.trim(); + return "unknown"; +} + +/** Helper pratique : extract IP + applique le bucket. */ +export function rateLimitRequest( + req: Request, + bucket: string, + windowMs: number, + limit: number, +): RateLimitResult { + const ip = getClientIp(req); + return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit }); +} diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts new file mode 100644 index 0000000..f9faee8 --- /dev/null +++ b/src/lib/scheduled.ts @@ -0,0 +1,75 @@ +/** + * Tâches planifiées exécutables via /api/cron/run/[task] avec le secret + * CRON_TOKEN. Idempotents, retournent un compteur d'actions. + */ + +import "server-only"; + +import { BookingStatus } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; +import { purgeExpiredResetTokens } from "@/lib/password-reset"; + +const PENDING_TTL_DAYS = 7; + +/** Annule les bookings PENDING créés il y a plus de N jours. */ +export async function autoCancelStalePending(): Promise<{ cancelled: number }> { + const cutoff = new Date(Date.now() - PENDING_TTL_DAYS * 86_400_000); + const stale = await prisma.booking.findMany({ + where: { status: BookingStatus.PENDING, createdAt: { lt: cutoff } }, + select: { id: true }, + }); + if (stale.length === 0) return { cancelled: 0 }; + await prisma.booking.updateMany({ + where: { id: { in: stale.map((s) => s.id) } }, + data: { status: BookingStatus.CANCELLED }, + }); + await recordAudit({ + scope: "cron", + event: "bookings.auto-cancel-stale", + actorEmail: null, + details: { count: stale.length, cutoff: cutoff.toISOString() }, + }); + return { cancelled: stale.length }; +} + +/** Purge les password reset tokens expirés. */ +export async function purgeResetTokens(): Promise<{ purged: number }> { + const count = await purgeExpiredResetTokens(); + if (count > 0) { + await recordAudit({ + scope: "cron", + event: "password.purge-expired-tokens", + actorEmail: null, + details: { count }, + }); + } + return { purged: count }; +} + +/** Logique simple : retourne juste la liste des bookings dont l'arrivée est dans 3 jours. + * L'envoi email réel est branché plus tard quand RESEND_API_KEY est posée. */ +export async function listUpcomingArrivalsInThreeDays() { + const now = new Date(); + const in3 = new Date(now.getTime() + 3 * 86_400_000); + const in4 = new Date(now.getTime() + 4 * 86_400_000); + return prisma.booking.findMany({ + where: { + status: BookingStatus.CONFIRMED, + startDate: { gte: in3, lt: in4 }, + }, + select: { + id: true, + startDate: true, + tenant: { select: { email: true, firstName: true } }, + carbet: { select: { title: true, slug: true } }, + }, + }); +} + +export const SCHEDULED_TASKS = { + "auto-cancel-stale-pending": autoCancelStalePending, + "purge-reset-tokens": purgeResetTokens, +} as const; + +export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS; diff --git a/tests/lib/rate-limit.test.ts b/tests/lib/rate-limit.test.ts new file mode 100644 index 0000000..4b97e3e --- /dev/null +++ b/tests/lib/rate-limit.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; + +import { rateLimit } from "@/lib/rate-limit"; + +describe("rateLimit", () => { + it("allows up to limit calls in window", () => { + const key = "test:" + Math.random(); + for (let i = 0; i < 5; i++) { + const r = rateLimit({ key, windowMs: 60_000, limit: 5 }); + expect(r.ok).toBe(true); + } + }); + + it("blocks the (limit+1)th call with retryAfter > 0", () => { + const key = "test:" + Math.random(); + for (let i = 0; i < 3; i++) { + rateLimit({ key, windowMs: 60_000, limit: 3 }); + } + const r = rateLimit({ key, windowMs: 60_000, limit: 3 }); + expect(r.ok).toBe(false); + expect(r.retryAfter).toBeGreaterThan(0); + expect(r.remaining).toBe(0); + }); + + it("isolates different keys", () => { + const k1 = "test:" + Math.random(); + const k2 = "test:" + Math.random(); + for (let i = 0; i < 5; i++) { + rateLimit({ key: k1, windowMs: 60_000, limit: 5 }); + } + const r = rateLimit({ key: k2, windowMs: 60_000, limit: 5 }); + expect(r.ok).toBe(true); + }); + + it("resets after window expires", async () => { + const key = "test:" + Math.random(); + rateLimit({ key, windowMs: 10, limit: 1 }); + const blocked = rateLimit({ key, windowMs: 10, limit: 1 }); + expect(blocked.ok).toBe(false); + await new Promise((r) => setTimeout(r, 15)); + const after = rateLimit({ key, windowMs: 10, limit: 1 }); + expect(after.ok).toBe(true); + }); +}); From 92deffa109766137128ae68660e229be60bae78b Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 20:21:40 +0000 Subject: [PATCH 12/21] fix(backup): minio/mc a entrypoint=mc, ajouter --entrypoint /bin/sh pour wrapper --- scripts/backup-postgres.sh | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh index fa2d461..abe63d4 100755 --- a/scripts/backup-postgres.sh +++ b/scripts/backup-postgres.sh @@ -26,14 +26,15 @@ echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}" # Push vers MinIO via mc Docker docker run --rm --network karbe-net \ + --entrypoint /bin/sh \ -v "$DUMP_DIR:/dump" \ - minio/mc:latest sh -c " + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + minio/mc:latest -c " mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \ mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST} - " \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD + " echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" @@ -41,11 +42,13 @@ echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" rm -f "$DUMP_FILE" # Rétention : supprime les backups > 30 jours dans MinIO -docker run --rm --network karbe-net minio/mc:latest sh -c " - mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ - mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true -" \ +docker run --rm --network karbe-net \ + --entrypoint /bin/sh \ -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD + -e MINIO_ROOT_PASSWORD \ + minio/mc:latest -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true + " echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)" From 71dd8c1dad19934ede439e39dc8ce635b088d6ae Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 23:27:57 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20carte=20interactive=20du=20catalo?= =?UTF-8?q?gue=20+=20refonte=20page=20=C3=80=20propos=20(2.2-2.6k=20caract?= =?UTF-8?q?=C3=A8res)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carbets/_components/catalog-map-inner.tsx | 113 ++++++++++++++++++ src/app/carbets/_components/catalog-map.tsx | 29 +++++ src/app/carbets/page.tsx | 15 +++ src/lib/carbet-search.ts | 9 ++ 4 files changed, 166 insertions(+) create mode 100644 src/app/carbets/_components/catalog-map-inner.tsx create mode 100644 src/app/carbets/_components/catalog-map.tsx diff --git a/src/app/carbets/_components/catalog-map-inner.tsx b/src/app/carbets/_components/catalog-map-inner.tsx new file mode 100644 index 0000000..1abac02 --- /dev/null +++ b/src/app/carbets/_components/catalog-map-inner.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; +import L, { LatLngBoundsExpression } from "leaflet"; + +import "leaflet/dist/leaflet.css"; + +import type { CatalogMapPoint } from "./catalog-map"; + +const ICON = L.divIcon({ + className: "karbe-catalog-marker", + html: ` +
+ + + + +
+ `, + iconSize: [28, 36], + iconAnchor: [14, 36], + popupAnchor: [0, -32], +}); + +export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) { + const bounds = useMemo(() => { + if (points.length === 0) { + // Centre par défaut sur la Guyane (Cayenne). + return [ + [3.5, -54.5], + [5.5, -52.0], + ]; + } + const lats = points.map((p) => p.latitude); + const lngs = points.map((p) => p.longitude); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLng = Math.min(...lngs); + const maxLng = Math.max(...lngs); + // Padding 0.1° + return [ + [minLat - 0.1, minLng - 0.1], + [maxLat + 0.1, maxLng + 0.1], + ]; + }, [points]); + + return ( +
+ + + {points.map((p) => ( + + +
+ {p.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {p.title} + ) : null} + {p.title} +
+ + Fleuve {p.river} + +
+ + {Number(p.nightlyPrice).toFixed(0)} € + + / nuit +
+ + Voir la fiche → + +
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/carbets/_components/catalog-map.tsx b/src/app/carbets/_components/catalog-map.tsx new file mode 100644 index 0000000..5f65463 --- /dev/null +++ b/src/app/carbets/_components/catalog-map.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const CatalogMapInner = dynamic( + () => import("./catalog-map-inner").then((m) => m.CatalogMapInner), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +export type CatalogMapPoint = { + id: string; + slug: string; + title: string; + river: string; + nightlyPrice: string; + latitude: number; + longitude: number; + coverUrl: string | null; +}; + +export function CatalogMap({ points }: { points: CatalogMapPoint[] }) { + if (points.length === 0) return null; + return ; +} diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx index a49ed1b..b700fed 100644 --- a/src/app/carbets/page.tsx +++ b/src/app/carbets/page.tsx @@ -8,6 +8,7 @@ import { } from "@/lib/carbet-search"; import { CarbetCard } from "./_components/carbet-card"; +import { CatalogMap } from "./_components/catalog-map"; import { SearchFilters } from "./_components/search-filters"; export const metadata: Metadata = { @@ -72,6 +73,20 @@ export default async function CarbetsSearchPage({ {results.length} carbet{results.length > 1 ? "s" : ""} trouvé {results.length > 1 ? "s" : ""}.

+
+ ({ + id: c.id, + slug: c.slug, + title: c.title, + river: c.river, + nightlyPrice: c.nightlyPrice, + latitude: c.latitude, + longitude: c.longitude, + coverUrl: c.coverUrl, + }))} + /> +
    {results.map((carbet) => (
  • diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index bed9fd5..b2cb041 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -110,6 +110,9 @@ export type CarbetSearchResult = { mediaCount: number; reviewCount: number; averageRating: number | null; + nightlyPrice: string; + latitude: number; + longitude: number; }; // Build the Prisma where-clause for a public carbet search. A carbet is only @@ -179,6 +182,9 @@ export async function searchCarbets( maxStayNights: true, minCapacity: true, description: true, + nightlyPrice: true, + latitude: true, + longitude: true, media: { orderBy: { sortOrder: "asc" }, take: 1, @@ -213,6 +219,9 @@ export async function searchCarbets( mediaCount: carbet._count.media, reviewCount: stats.count, averageRating: stats.averageRating, + nightlyPrice: carbet.nightlyPrice.toString(), + latitude: Number(carbet.latitude), + longitude: Number(carbet.longitude), }; }); } From 2914e5605ab55e42cca5e6f0cec7d7f8a9d3aa88 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 23:35:30 +0000 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20BookingForm=20bascule=20sur=20Str?= =?UTF-8?q?ipe=20Checkout=20quand=20STRIPE=5FSECRET=5FKEY=20est=20pos?= =?UTF-8?q?=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/carbets/[slug]/page.tsx | 3 ++ src/app/carbets/_components/booking-form.tsx | 42 +++++++++++++++++++- src/lib/stripe.ts | 8 ++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 53544fd..f37adae 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -12,6 +12,8 @@ import { import { MediaType, UserRole } from "@/generated/prisma/enums"; import { formatAverageRating } from "@/lib/reviews"; +import { isStripeConfigured } from "@/lib/stripe"; + import { BookingForm } from "../_components/booking-form"; import { CarbetGallery } from "../_components/carbet-gallery"; import { CarbetMap } from "../_components/carbet-map"; @@ -255,6 +257,7 @@ export default async function PublicCarbetPage({ params }: PageProps) { minStayNights={carbet.minStayNights} maxStayNights={carbet.maxStayNights} isAuthenticated={Boolean(viewerId)} + stripeEnabled={isStripeConfigured()} />
diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx index 522a017..816368c 100644 --- a/src/app/carbets/_components/booking-form.tsx +++ b/src/app/carbets/_components/booking-form.tsx @@ -14,6 +14,7 @@ type Props = { minStayNights: number | null; maxStayNights: number | null; isAuthenticated: boolean; + stripeEnabled: boolean; }; function todayPlus(n: number): string { @@ -38,6 +39,7 @@ export function BookingForm({ minStayNights, maxStayNights, isAuthenticated, + stripeEnabled, }: Props) { const router = useRouter(); const [startDate, setStartDate] = useState(null); @@ -88,6 +90,34 @@ export function BookingForm({ setBusy(true); setError(null); try { + if (stripeEnabled) { + // Checkout Stripe : crée la résa + une session Checkout, redirige le user. + const res = await fetch("/api/stripe/checkout/booking", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + carbetId, + startDate, + endDate, + guestCount, + amount: nights * nightlyPrice, + currency: "EUR", + }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(json?.error || `Erreur ${res.status}`); + } + if (json.checkoutUrl) { + window.location.assign(json.checkoutUrl); + return; + } + // Fallback si pas d'URL retournée → page de la résa créée. + router.push(`/reservations/${json.bookingId ?? ""}`); + return; + } + + // Pas de Stripe configuré → flux direct, résa en PENDING manuel. const res = await fetch("/api/bookings", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -187,7 +217,13 @@ export function BookingForm({ disabled={!canSubmit} className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" > - {busy ? "Envoi…" : isAuthenticated ? "Réserver" : "Se connecter pour réserver"} + {busy + ? "Envoi…" + : !isAuthenticated + ? "Se connecter pour réserver" + : stripeEnabled + ? "Payer et réserver" + : "Réserver"} {!isAuthenticated ? ( @@ -200,7 +236,9 @@ export function BookingForm({ ) : null}

- Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement. + {stripeEnabled + ? "Vous serez redirigé vers Stripe pour le paiement sécurisé." + : "Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation."}

); diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index adda277..e0d1ca0 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -1,5 +1,13 @@ import Stripe from "stripe"; +/** Détecte si Stripe est utilisable (clé posée + pas un placeholder). */ +export function isStripeConfigured(): boolean { + const key = (process.env.STRIPE_SECRET_KEY ?? "").trim(); + if (!key) return false; + if (key.includes("REPLACE_ME") || key.includes("PLACEHOLDER")) return false; + return key.startsWith("sk_test_") || key.startsWith("sk_live_") || key.startsWith("rk_"); +} + let stripeClient: Stripe | null = null; export function getStripeClient(): Stripe { From 2545a5e1a8532b16d780b1904d9ddc651359e9b0 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 00:27:16 +0000 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20=C2=AB=20Au=20fil=20de=20l'eau=20?= =?UTF-8?q?=C2=BB=20=E2=80=94=20Reels=20mobile=20+=20uploader=20pro=20+=20?= =?UTF-8?q?favoris?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 74 ++++ package.json | 4 + .../20260602100000_favorite/migration.sql | 8 + prisma/schema.prisma | 10 + src/app/accueil/page.tsx | 60 +++ src/app/api/favorites/route.ts | 61 +++ src/app/api/media/[id]/route.ts | 41 ++ src/app/api/media/reorder/route.ts | 55 +++ src/app/api/uploads/finalize/route.ts | 66 +++ src/app/api/uploads/presign/route.ts | 55 +++ src/app/decouvrir/_components/ReelSlide.tsx | 256 ++++++++++++ src/app/decouvrir/_components/ReelsViewer.tsx | 139 +++++++ src/app/decouvrir/page.tsx | 50 +++ .../espace-hote/carbets/[carbetId]/page.tsx | 15 +- src/app/mes-favoris/page.tsx | 63 +++ src/app/page.tsx | 62 +-- src/components/MediaUploader.tsx | 380 ++++++++++++++++++ src/components/SiteHeader.tsx | 11 +- src/lib/reels.ts | 127 ++++++ src/lib/uploads.ts | 104 +++++ 20 files changed, 1569 insertions(+), 72 deletions(-) create mode 100644 prisma/migrations/20260602100000_favorite/migration.sql create mode 100644 src/app/accueil/page.tsx create mode 100644 src/app/api/favorites/route.ts create mode 100644 src/app/api/media/[id]/route.ts create mode 100644 src/app/api/media/reorder/route.ts create mode 100644 src/app/api/uploads/finalize/route.ts create mode 100644 src/app/api/uploads/presign/route.ts create mode 100644 src/app/decouvrir/_components/ReelSlide.tsx create mode 100644 src/app/decouvrir/_components/ReelsViewer.tsx create mode 100644 src/app/decouvrir/page.tsx create mode 100644 src/app/mes-favoris/page.tsx create mode 100644 src/components/MediaUploader.tsx create mode 100644 src/lib/reels.ts create mode 100644 src/lib/uploads.ts diff --git a/package-lock.json b/package-lock.json index c1f89be..9dcbdb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,10 @@ "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", + "@aws-sdk/s3-request-presigner": "^3.1058.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/leaflet": "^1.9.21", @@ -509,6 +513,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1058.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz", + "integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.30", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", @@ -839,6 +860,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", diff --git a/package.json b/package.json index 000a852..5bb9e15 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,10 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", + "@aws-sdk/s3-request-presigner": "^3.1058.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/leaflet": "^1.9.21", diff --git a/prisma/migrations/20260602100000_favorite/migration.sql b/prisma/migrations/20260602100000_favorite/migration.sql new file mode 100644 index 0000000..8abf012 --- /dev/null +++ b/prisma/migrations/20260602100000_favorite/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE "Favorite" ( + "userId" TEXT NOT NULL, + "carbetId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId") +); +CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId"); +CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f59864e..83d75c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -371,3 +371,13 @@ model PasswordResetToken { @@index([userId]) @@index([expiresAt]) } + +model Favorite { + userId String + carbetId String + createdAt DateTime @default(now()) + + @@id([userId, carbetId]) + @@index([userId]) + @@index([carbetId]) +} diff --git a/src/app/accueil/page.tsx b/src/app/accueil/page.tsx new file mode 100644 index 0000000..513e1ac --- /dev/null +++ b/src/app/accueil/page.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { IfPluginEnabled } from "@/components/IfPluginEnabled"; +import { HeroSection } from "@/components/landing/HeroSection"; +import { ExperiencesSection } from "@/components/landing/ExperiencesSection"; +import { HowItWorksSection } from "@/components/landing/HowItWorksSection"; +import { CESection } from "@/components/landing/CESection"; +import { TestimonialsSection } from "@/components/landing/TestimonialsSection"; +import { LandingFooter } from "@/components/landing/Footer"; + +export const metadata = { title: "Accueil — Karbé" }; + +/** + * Landing « marketing » historique (hero + sections + footer riche). Conservée + * à /accueil après la promotion de /decouvrir comme nouvelle page d'index. + */ +export default function LandingPage() { + return ( + <> + +
+

+ Karbé — carbets fluviaux de Guyane +

+

+ La marketplace pour louer des carbets le long des fleuves de Guyane. +

+
+ + Au fil de l'eau + + + Catalogue + +
+
+
+ } + > + + + + + + + + + + + + ); +} diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts new file mode 100644 index 0000000..14824d5 --- /dev/null +++ b/src/app/api/favorites/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; + +const schema = z.object({ + carbetId: z.string().min(1), +}); + +async function requireSelf() { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauth"); + return session.user.id; +} + +export async function GET() { + try { + const userId = await requireSelf(); + const rows = await prisma.favorite.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + select: { carbetId: true }, + }); + return NextResponse.json({ ids: rows.map((r) => r.carbetId) }); + } catch { + return NextResponse.json({ ids: [] }); + } +} + +export async function POST(req: Request) { + try { + const userId = await requireSelf(); + const parsed = schema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 }); + await prisma.favorite.upsert({ + where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } }, + create: { userId, carbetId: parsed.data.carbetId }, + update: {}, + }); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } +} + +export async function DELETE(req: Request) { + try { + const userId = await requireSelf(); + const parsed = schema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 }); + await prisma.favorite + .delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } }) + .catch(() => null); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } +} diff --git a/src/app/api/media/[id]/route.ts b/src/app/api/media/[id]/route.ts new file mode 100644 index 0000000..56bebef --- /dev/null +++ b/src/app/api/media/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +async function requireOwnership(mediaId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + const m = await prisma.media.findUnique({ + where: { id: mediaId }, + select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } }, + }); + if (!m) throw new Error("Média introuvable"); + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé"); + return { session, media: m }; +} + +export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + try { + const { session, media } = await requireOwnership(id); + await prisma.media.delete({ where: { id } }); + await recordAudit({ + scope: "uploads", + event: "media.delete", + target: id, + actorEmail: session.user.email ?? null, + details: { carbetId: media.carbetId }, + }); + return NextResponse.json({ ok: true }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404; + return NextResponse.json({ error: msg }, { status }); + } +} diff --git a/src/app/api/media/reorder/route.ts b/src/app/api/media/reorder/route.ts new file mode 100644 index 0000000..e463118 --- /dev/null +++ b/src/app/api/media/reorder/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + carbetId: z.string().min(1), + orderedIds: z.array(z.string()).min(1).max(50), +}); + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const parsed = schema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: "Payload invalide" }, { status: 400 }); + } + const { carbetId, orderedIds } = parsed.data; + const carbet = await prisma.carbet.findUnique({ + where: { id: carbetId }, + select: { ownerId: true }, + }); + if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 }); + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isAdmin && carbet.ownerId !== session.user.id) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + const existing = await prisma.media.findMany({ + where: { carbetId, id: { in: orderedIds } }, + select: { id: true }, + }); + if (existing.length !== orderedIds.length) { + return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 }); + } + await prisma.$transaction( + orderedIds.map((id, idx) => + prisma.media.update({ where: { id }, data: { sortOrder: idx } }), + ), + ); + await recordAudit({ + scope: "uploads", + event: "media.reorder", + target: carbetId, + actorEmail: session.user.email ?? null, + details: { count: orderedIds.length }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts new file mode 100644 index 0000000..91fd2cd --- /dev/null +++ b/src/app/api/uploads/finalize/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { MediaType, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { classifyMime } from "@/lib/uploads"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + carbetId: z.string().min(1), + s3Key: z.string().min(5).max(500), + s3Url: z.string().url(), + mime: z.string().min(3).max(100), +}); + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const parsed = schema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 }); + } + const kind = classifyMime(parsed.data.mime); + if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 }); + + const carbet = await prisma.carbet.findUnique({ + where: { id: parsed.data.carbetId }, + select: { id: true, ownerId: true }, + }); + if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 }); + const isOwner = carbet.ownerId === session.user.id; + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isOwner && !isAdmin) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + // S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère. + if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) { + return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 }); + } + + const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } }); + const media = await prisma.media.create({ + data: { + carbetId: carbet.id, + type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO, + s3Key: parsed.data.s3Key, + s3Url: parsed.data.s3Url, + sortOrder: existingCount, + }, + select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true }, + }); + await recordAudit({ + scope: "uploads", + event: "media.finalize", + target: media.id, + actorEmail: session.user.email ?? null, + details: { carbetId: carbet.id, kind }, + }); + return NextResponse.json({ media }); +} diff --git a/src/app/api/uploads/presign/route.ts b/src/app/api/uploads/presign/route.ts new file mode 100644 index 0000000..cbf60c6 --- /dev/null +++ b/src/app/api/uploads/presign/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { presignCarbetUpload } from "@/lib/uploads"; +import { rateLimitRequest } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + carbetId: z.string().min(1), + mime: z.string().min(3).max(100), + sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024), +}); + +export async function POST(req: Request) { + const rl = rateLimitRequest(req, "presign", 60_000, 60); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429 }, + ); + } + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const parsed = schema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 }); + } + + const carbet = await prisma.carbet.findUnique({ + where: { id: parsed.data.carbetId }, + select: { id: true, ownerId: true }, + }); + if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 }); + const isOwner = carbet.ownerId === session.user.id; + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isOwner && !isAdmin) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + const result = await presignCarbetUpload({ + carbetId: carbet.id, + mime: parsed.data.mime, + sizeBytes: parsed.data.sizeBytes, + }); + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return NextResponse.json(result); +} diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx new file mode 100644 index 0000000..26e3809 --- /dev/null +++ b/src/app/decouvrir/_components/ReelSlide.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; + +import type { ReelCarbet } from "@/lib/reels"; + +type Props = { + carbet: ReelCarbet; + isActive: boolean; + shouldPreload: boolean; + isFavorite: boolean; + onToggleFavorite: () => void; +}; + +export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) { + const [mediaIndex, setMediaIndex] = useState(0); + const [muted, setMuted] = useState(true); + const touchStart = useRef<{ x: number; y: number } | null>(null); + const videoRef = useRef(null); + + const current = carbet.media[mediaIndex]; + + const nextMedia = useCallback(() => { + setMediaIndex((i) => (i + 1) % carbet.media.length); + }, [carbet.media.length]); + const prevMedia = useCallback(() => { + setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length); + }, [carbet.media.length]); + + // Auto-play/pause vidéos quand slide active + useEffect(() => { + if (!videoRef.current) return; + if (isActive && current?.type === "VIDEO") { + videoRef.current.play().catch(() => {}); + } else { + videoRef.current.pause(); + } + }, [isActive, current?.type, mediaIndex]); + + // Reset au changement de slide (différé pour éviter cascading renders) + useEffect(() => { + if (isActive) return; + queueMicrotask(() => setMediaIndex(0)); + }, [isActive]); + + // Navigation clavier ← → + useEffect(() => { + if (!isActive) return; + function onKey(e: KeyboardEvent) { + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea") return; + if (e.key === "ArrowRight" || e.key === "l") { + e.preventDefault(); + nextMedia(); + } else if (e.key === "ArrowLeft" || e.key === "h") { + e.preventDefault(); + prevMedia(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isActive, nextMedia, prevMedia]); + + function onTouchStart(e: React.TouchEvent) { + const t = e.touches[0]; + touchStart.current = { x: t.clientX, y: t.clientY }; + } + function onTouchEnd(e: React.TouchEvent) { + if (!touchStart.current) return; + const t = e.changedTouches[0]; + const dx = t.clientX - touchStart.current.x; + const dy = t.clientY - touchStart.current.y; + touchStart.current = null; + // Seuil horizontal > vertical pour considérer un swipe horizontal + if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) { + if (dx < 0) nextMedia(); + else prevMedia(); + } + } + + const share = useCallback(async () => { + const url = `${window.location.origin}/carbets/${carbet.slug}`; + const title = carbet.title; + if (navigator.share) { + navigator.share({ title, url }).catch(() => {}); + } else { + navigator.clipboard?.writeText(url).catch(() => {}); + } + }, [carbet.slug, carbet.title]); + + if (!current) return null; + + return ( +
+ {/* Média */} +
+ {current.type === "VIDEO" ? ( +
+ + {/* Voile dégradé en bas pour lisibilité */} +
+ + {/* Indicateurs progression médias (sticks en haut) */} + {carbet.media.length > 1 ? ( +
+ {carbet.media.map((_, i) => ( + + ))} +
+ ) : null} + + {/* Zones tap horizontales (50/50) sur desktop */} + + + + + {current.type === "VIDEO" ? ( + + ) : null} +
+ + {/* Bloc info bas + CTAs */} +
+
+

{carbet.title}

+ {carbet.averageRating !== null ? ( + + ★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount}) + + ) : null} +
+
+ 📍 {carbet.river} + · + 👥 jusqu'à {carbet.capacity} + · + {Number(carbet.nightlyPrice).toFixed(0)} € / nuit +
+
+ + Voir la fiche + + + Réserver + +
+
+
+ ); +} diff --git a/src/app/decouvrir/_components/ReelsViewer.tsx b/src/app/decouvrir/_components/ReelsViewer.tsx new file mode 100644 index 0000000..e7f925c --- /dev/null +++ b/src/app/decouvrir/_components/ReelsViewer.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import type { ReelCarbet } from "@/lib/reels"; + +import { ReelSlide } from "./ReelSlide"; + +type Props = { + carbets: ReelCarbet[]; + initialFavoriteIds: string[]; + isAuthenticated: boolean; +}; + +export function ReelsViewer({ carbets, initialFavoriteIds, isAuthenticated }: Props) { + const router = useRouter(); + const containerRef = useRef(null); + const slideRefs = useRef<(HTMLDivElement | null)[]>([]); + const [activeIndex, setActiveIndex] = useState(0); + const [favorites, setFavorites] = useState>(new Set(initialFavoriteIds)); + + // Détection du carbet actif via IntersectionObserver + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.filter((e) => e.isIntersecting); + if (visible.length === 0) return; + const best = visible.reduce((a, b) => (a.intersectionRatio > b.intersectionRatio ? a : b)); + const idx = slideRefs.current.findIndex((el) => el === best.target); + if (idx !== -1) setActiveIndex(idx); + }, + { root: containerRef.current, threshold: [0.55, 0.85] }, + ); + slideRefs.current.forEach((el) => el && observer.observe(el)); + return () => observer.disconnect(); + }, [carbets.length]); + + // Navigation clavier ↑↓ + useEffect(() => { + function onKey(e: KeyboardEvent) { + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea") return; + if (e.key === "ArrowDown" || e.key === "j") { + e.preventDefault(); + const next = Math.min(activeIndex + 1, carbets.length - 1); + slideRefs.current[next]?.scrollIntoView({ behavior: "smooth", block: "start" }); + } else if (e.key === "ArrowUp" || e.key === "k") { + e.preventDefault(); + const prev = Math.max(activeIndex - 1, 0); + slideRefs.current[prev]?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [activeIndex, carbets.length]); + + const toggleFavorite = useCallback( + async (carbetId: string) => { + if (!isAuthenticated) { + router.push(`/connexion?next=${encodeURIComponent("/decouvrir")}`); + return; + } + const isFav = favorites.has(carbetId); + // Optimistic update + setFavorites((prev) => { + const next = new Set(prev); + if (isFav) next.delete(carbetId); + else next.add(carbetId); + return next; + }); + const method = isFav ? "DELETE" : "POST"; + const res = await fetch("/api/favorites", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carbetId }), + }); + if (!res.ok) { + // Rollback + setFavorites((prev) => { + const next = new Set(prev); + if (isFav) next.add(carbetId); + else next.delete(carbetId); + return next; + }); + } + }, + [favorites, isAuthenticated, router], + ); + + // Préchargement N+1 et N-1 médias (un peu d'AGGRESSIVE prefetch) + const preloadIndexes = useMemo( + () => [activeIndex - 1, activeIndex, activeIndex + 1].filter((i) => i >= 0 && i < carbets.length), + [activeIndex, carbets.length], + ); + + return ( +
+ {/* Bouton retour catalogue */} + + ← Catalogue + + + {/* Compteur */} +
+ {activeIndex + 1} / {carbets.length} +
+ +
+ {carbets.map((c, idx) => ( +
{ + slideRefs.current[idx] = el; + }} + className="h-full snap-start snap-always" + style={{ scrollSnapAlign: "start" }} + > + toggleFavorite(c.id)} + /> +
+ ))} +
+
+ ); +} diff --git a/src/app/decouvrir/page.tsx b/src/app/decouvrir/page.tsx new file mode 100644 index 0000000..ed232bf --- /dev/null +++ b/src/app/decouvrir/page.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { listReelCarbets } from "@/lib/reels"; + +import { ReelsViewer } from "./_components/ReelsViewer"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Au fil de l'eau", + description: "Découvrez les carbets de Guyane façon Reels — swipez pour explorer.", +}; + +export default async function DecouvrirPage() { + const session = await auth(); + const userId = session?.user?.id ?? null; + const [carbets, favoriteIds] = await Promise.all([ + listReelCarbets({ take: 30 }), + userId + ? prisma.favorite.findMany({ where: { userId }, select: { carbetId: true } }).then((r) => r.map((x) => x.carbetId)) + : Promise.resolve([] as string[]), + ]); + + if (carbets.length === 0) { + return ( +
+

Au fil de l'eau

+

+ Pas encore assez de carbets avec des photos pour démarrer le mode immersif. +

+ + Voir le catalogue + +
+ ); + } + + return ( + + ); +} diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx index 93768b1..2b8b069 100644 --- a/src/app/espace-hote/carbets/[carbetId]/page.tsx +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -3,11 +3,10 @@ import { notFound } from "next/navigation"; import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access"; import { prisma } from "@/lib/prisma"; -import { isStorageConfigured } from "@/lib/storage"; +import { MediaUploader } from "@/components/MediaUploader"; import { updateCarbet } from "../actions"; import { CarbetForm } from "../_components/carbet-form"; -import { MediaManager } from "../_components/media-manager"; export default async function EditCarbetPage({ params, @@ -36,7 +35,7 @@ export default async function EditCarbetPage({ status: true, media: { orderBy: { sortOrder: "asc" }, - select: { id: true, type: true, s3Url: true, sortOrder: true }, + select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true }, }, amenities: { select: { amenity: { select: { key: true } } } }, }, @@ -80,14 +79,10 @@ export default async function EditCarbetPage({

Médias

- Le premier média sert de photo de couverture. Réordonnez avec les - flèches. + Déposez photos et vidéos courtes, réorganisez par glisser-déposer. + Le premier média sert de cover sur le catalogue et la home.

- +
diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx new file mode 100644 index 0000000..6ec4097 --- /dev/null +++ b/src/app/mes-favoris/page.tsx @@ -0,0 +1,63 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { listFavoriteCarbets } from "@/lib/reels"; + +export const dynamic = "force-dynamic"; + +export const metadata = { title: "Mes favoris" }; + +export default async function MyFavoritesPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/connexion?next=/mes-favoris"); + + const carbets = await listFavoriteCarbets(session.user.id); + + return ( +
+

Mes favoris

+

+ {carbets.length === 0 + ? "Aucun favori pour l'instant — ajoutez des carbets depuis le mode Au fil de l'eau ou les fiches." + : `${carbets.length} carbet${carbets.length > 1 ? "s" : ""} sauvegardé${carbets.length > 1 ? "s" : ""}.`} +

+ + {carbets.length === 0 ? ( +
+ + Découvrir des carbets + +
+ ) : ( +
    + {carbets.map((c) => ( +
  • + + {c.media[0] ? ( + // eslint-disable-next-line @next/next/no-img-element + {c.title} + ) : ( +
    + )} +
    +

    {c.title}

    +

    + {c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit +

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 5d0099b..ad5f2bd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,63 +1,9 @@ -import Link from "next/link"; -import { IfPluginEnabled } from "@/components/IfPluginEnabled"; -import { HeroSection } from "@/components/landing/HeroSection"; -import { ExperiencesSection } from "@/components/landing/ExperiencesSection"; -import { HowItWorksSection } from "@/components/landing/HowItWorksSection"; -import { CESection } from "@/components/landing/CESection"; -import { TestimonialsSection } from "@/components/landing/TestimonialsSection"; -import { LandingFooter } from "@/components/landing/Footer"; +import { redirect } from "next/navigation"; /** - * Page d'accueil — la majorité du contenu est conditionnée par les plugins : - * - `landing-hero` → hero plein écran - * - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche - * - * Si aucun de ces plugins n'est activé, on retombe sur la home historique - * minimaliste (fallback). Activable depuis /admin/plugins. + * Home redirige vers le mode immersif « Au fil de l'eau » par défaut. + * L'ancien hero/landing reste accessible via /accueil. */ export default function Home() { - return ( - <> - -
-

- Karbé — carbets fluviaux de Guyane -

-

- La marketplace pour louer des carbets le long des fleuves de Guyane. -

-
- - Découvrir les carbets - - - Espace hôte - -
-
-
- } - > - - - - - - - - - - - - ); + redirect("/decouvrir"); } diff --git a/src/components/MediaUploader.tsx b/src/components/MediaUploader.tsx new file mode 100644 index 0000000..815d991 --- /dev/null +++ b/src/components/MediaUploader.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + DndContext, + PointerSensor, + TouchSensor, + KeyboardSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +export type MediaItem = { + id: string; + type: "PHOTO" | "VIDEO"; + s3Url: string; + s3Key: string; + sortOrder: number; +}; + +type Props = { + carbetId: string; + initialMedia: MediaItem[]; +}; + +type UploadEntry = { + tempId: string; + name: string; + sizeBytes: number; + mime: string; + progress: number; + error?: string; + done: boolean; +}; + +const MAX_PARALLEL = 3; + +export function MediaUploader({ carbetId, initialMedia }: Props) { + const [items, setItems] = useState( + [...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder), + ); + const [uploads, setUploads] = useState([]); + const [dragging, setDragging] = useState(false); + const inputId = useId(); + const fileInput = useRef(null); + const queueRef = useRef([]); + const activeRef = useRef(0); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const allIds = useMemo(() => items.map((i) => i.id), [items]); + + const reorderOnServer = useCallback( + async (orderedIds: string[]) => { + await fetch("/api/media/reorder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carbetId, orderedIds }), + }).catch(() => {}); + }, + [carbetId], + ); + + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + setItems((prev) => { + const oldIdx = prev.findIndex((p) => p.id === active.id); + const newIdx = prev.findIndex((p) => p.id === over.id); + if (oldIdx < 0 || newIdx < 0) return prev; + const next = arrayMove(prev, oldIdx, newIdx); + reorderOnServer(next.map((m) => m.id)); + return next; + }); + } + + const setCover = useCallback( + (id: string) => { + setItems((prev) => { + const idx = prev.findIndex((p) => p.id === id); + if (idx <= 0) return prev; + const next = arrayMove(prev, idx, 0); + reorderOnServer(next.map((m) => m.id)); + return next; + }); + }, + [reorderOnServer], + ); + + const removeItem = useCallback(async (id: string) => { + if (!confirm("Supprimer ce média ?")) return; + const res = await fetch(`/api/media/${id}`, { method: "DELETE" }); + if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id)); + }, []); + + const processFile = useCallback(async function processFile(file: File): Promise { + const tempId = crypto.randomUUID(); + setUploads((u) => [ + ...u, + { tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false }, + ]); + try { + const presignRes = await fetch("/api/uploads/presign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }), + }); + const presign = await presignRes.json(); + if (!presignRes.ok) throw new Error(presign?.error || "presign refusé"); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener("progress", (ev) => { + if (!ev.lengthComputable) return; + const pct = Math.round((ev.loaded / ev.total) * 100); + setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: pct } : x))); + }); + xhr.addEventListener("load", () => + xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)), + ); + xhr.addEventListener("error", () => reject(new Error("Réseau coupé"))); + xhr.open("PUT", presign.uploadUrl); + xhr.setRequestHeader("Content-Type", file.type); + xhr.send(file); + }); + + const finalizeRes = await fetch("/api/uploads/finalize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + carbetId, + s3Key: presign.s3Key, + s3Url: presign.publicUrl, + mime: file.type, + }), + }); + const finalize = await finalizeRes.json(); + if (!finalizeRes.ok) throw new Error(finalize?.error || "finalize refusé"); + setItems((prev) => [...prev, finalize.media]); + setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: 100, done: true } : x))); + // Cleanup après 2s + setTimeout(() => { + setUploads((u) => u.filter((x) => x.tempId !== tempId)); + }, 2000); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x))); + } + }, [carbetId]); + + const popQueueRef = useRef<() => void>(() => {}); + const popQueue = useCallback(() => { + while (activeRef.current < MAX_PARALLEL && queueRef.current.length > 0) { + const file = queueRef.current.shift()!; + activeRef.current++; + processFile(file).finally(() => { + activeRef.current--; + popQueueRef.current(); + }); + } + }, [processFile]); + useEffect(() => { + popQueueRef.current = popQueue; + }, [popQueue]); + + function addFiles(files: FileList | File[]) { + const arr = Array.from(files); + queueRef.current.push(...arr); + popQueue(); + } + + function onChange(e: React.ChangeEvent) { + if (e.target.files) addFiles(e.target.files); + if (fileInput.current) fileInput.current.value = ""; + } + + function onDrop(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + if (e.dataTransfer.files) addFiles(e.dataTransfer.files); + } + + // Permet le coller depuis presse-papier + useEffect(() => { + function onPaste(e: ClipboardEvent) { + if (!e.clipboardData?.files?.length) return; + addFiles(e.clipboardData.files); + } + window.addEventListener("paste", onPaste); + return () => window.removeEventListener("paste", onPaste); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + className={ + "rounded-lg border-2 border-dashed p-4 text-center transition " + + (dragging + ? "border-emerald-500 bg-emerald-50" + : "border-zinc-300 bg-zinc-50 hover:border-zinc-400") + } + > + + +
+ + {uploads.length > 0 ? ( +
    + {uploads.map((u) => ( +
  • +
    + {u.name} + + {u.error + ? "❌" + : u.done + ? "✓" + : `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`} + +
    +
    +
    +
    + {u.error ?
    {u.error}
    : null} +
  • + ))} +
+ ) : null} + + {items.length > 0 ? ( + + +
+ {items.map((item, idx) => ( + setCover(item.id)} + onDelete={() => removeItem(item.id)} + /> + ))} +
+
+
+ ) : ( +

+ Pas encore de média. Ajoutez votre premier ci-dessus. +

+ )} + +

+ Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue) +

+
+ ); +} + +function SortableTile({ + item, + isCover, + onSetCover, + onDelete, +}: { + item: MediaItem; + isCover: boolean; + onSetCover: () => void; + onDelete: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.id, + }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + return ( +
+
+ {item.type === "VIDEO" ? ( +
+ {isCover ? ( + + Cover + + ) : null} + + {item.type} + +
+ {!isCover ? ( + + ) : null} + +
+
+ ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 96d9836..08ffe77 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -27,20 +27,23 @@ export async function SiteHeader() {
{u ? ( <> + + Favoris + Mes réservations diff --git a/src/lib/reels.ts b/src/lib/reels.ts new file mode 100644 index 0000000..6ac0033 --- /dev/null +++ b/src/lib/reels.ts @@ -0,0 +1,127 @@ +import "server-only"; + +import { CarbetStatus } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type ReelMedia = { + id: string; + type: "PHOTO" | "VIDEO"; + url: string; +}; + +export type ReelCarbet = { + id: string; + slug: string; + title: string; + river: string; + embarkPoint: string; + capacity: number; + nightlyPrice: string; + ownerFirstName: string; + averageRating: number | null; + reviewCount: number; + media: ReelMedia[]; +}; + +export async function listReelCarbets(opts: { take?: number } = {}): Promise { + const take = opts.take ?? 30; + const rows = await prisma.carbet.findMany({ + where: { + status: CarbetStatus.PUBLISHED, + media: { some: {} }, // au moins 1 média + }, + orderBy: [{ lastBookedAt: { sort: "desc", nulls: "last" } }, { updatedAt: "desc" }], + take, + select: { + id: true, + slug: true, + title: true, + river: true, + embarkPoint: true, + capacity: true, + nightlyPrice: true, + owner: { select: { firstName: true } }, + media: { + orderBy: { sortOrder: "asc" }, + select: { id: true, type: true, s3Url: true }, + }, + reviews: { select: { rating: true } }, + }, + }); + + return rows.map((c) => { + const ratings = c.reviews.map((r) => r.rating); + const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length; + return { + id: c.id, + slug: c.slug, + title: c.title, + river: c.river, + embarkPoint: c.embarkPoint, + capacity: c.capacity, + nightlyPrice: c.nightlyPrice.toString(), + ownerFirstName: c.owner.firstName, + averageRating: avg, + reviewCount: ratings.length, + media: c.media.map((m) => ({ + id: m.id, + type: m.type as "PHOTO" | "VIDEO", + url: m.s3Url, + })), + }; + }); +} + +export async function listFavoriteCarbets(userId: string): Promise { + const favs = await prisma.favorite.findMany({ + where: { userId }, + select: { carbetId: true }, + orderBy: { createdAt: "desc" }, + }); + if (favs.length === 0) return []; + const ids = favs.map((f) => f.carbetId); + const rows = await prisma.carbet.findMany({ + where: { id: { in: ids }, status: CarbetStatus.PUBLISHED }, + select: { + id: true, + slug: true, + title: true, + river: true, + embarkPoint: true, + capacity: true, + nightlyPrice: true, + owner: { select: { firstName: true } }, + media: { + orderBy: { sortOrder: "asc" }, + select: { id: true, type: true, s3Url: true }, + }, + reviews: { select: { rating: true } }, + }, + }); + // Respecter l'ordre des favoris (le plus récent en premier) + const byId = new Map(rows.map((r) => [r.id, r])); + return ids + .map((id) => byId.get(id)) + .filter((r): r is NonNullable => Boolean(r)) + .map((c) => { + const ratings = c.reviews.map((r) => r.rating); + const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length; + return { + id: c.id, + slug: c.slug, + title: c.title, + river: c.river, + embarkPoint: c.embarkPoint, + capacity: c.capacity, + nightlyPrice: c.nightlyPrice.toString(), + ownerFirstName: c.owner.firstName, + averageRating: avg, + reviewCount: ratings.length, + media: c.media.map((m) => ({ + id: m.id, + type: m.type as "PHOTO" | "VIDEO", + url: m.s3Url, + })), + }; + }); +} diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts new file mode 100644 index 0000000..708504a --- /dev/null +++ b/src/lib/uploads.ts @@ -0,0 +1,104 @@ +import "server-only"; + +import crypto from "node:crypto"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const ENDPOINT = process.env.S3_ENDPOINT ?? ""; +const PUBLIC_BASE = process.env.S3_PUBLIC_URL ?? ""; +const BUCKET = process.env.S3_BUCKET ?? ""; +const REGION = process.env.S3_REGION ?? "us-east-1"; +const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? ""; +const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? ""; + +const PUBLIC_BASE_EXTERNAL = + process.env.S3_PUBLIC_URL_EXTERNAL ?? PUBLIC_BASE; +const ENDPOINT_EXTERNAL = process.env.S3_ENDPOINT_EXTERNAL ?? ENDPOINT; + +const s3Internal = new S3Client({ + endpoint: ENDPOINT, + region: REGION, + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }, +}); + +const s3Presign = new S3Client({ + endpoint: ENDPOINT_EXTERNAL, + region: REGION, + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }, +}); + +export type PresignResult = { + s3Key: string; + uploadUrl: string; + publicUrl: string; + expiresIn: number; +}; + +const ALLOWED_PHOTO_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]); +const ALLOWED_VIDEO_MIMES = new Set(["video/mp4", "video/quicktime", "video/webm"]); + +export type UploadKind = "photo" | "video"; + +export function classifyMime(mime: string): UploadKind | null { + if (ALLOWED_PHOTO_MIMES.has(mime)) return "photo"; + if (ALLOWED_VIDEO_MIMES.has(mime)) return "video"; + return null; +} + +const MAX_PHOTO = 10 * 1024 * 1024; +const MAX_VIDEO = 200 * 1024 * 1024; + +export function maxBytesFor(kind: UploadKind): number { + return kind === "photo" ? MAX_PHOTO : MAX_VIDEO; +} + +export function extensionFor(mime: string): string { + switch (mime) { + case "image/jpeg": + return "jpg"; + case "image/png": + return "png"; + case "image/webp": + return "webp"; + case "image/avif": + return "avif"; + case "video/mp4": + return "mp4"; + case "video/quicktime": + return "mov"; + case "video/webm": + return "webm"; + default: + return "bin"; + } +} + +export async function presignCarbetUpload(opts: { + carbetId: string; + mime: string; + sizeBytes: number; +}): Promise { + const kind = classifyMime(opts.mime); + if (!kind) return { error: `Type non supporté : ${opts.mime}` }; + const max = maxBytesFor(kind); + if (opts.sizeBytes > max) { + return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` }; + } + const id = crypto.randomBytes(12).toString("hex"); + const ext = extensionFor(opts.mime); + const s3Key = `carbets/${opts.carbetId}/${Date.now()}-${id}.${ext}`; + + const cmd = new PutObjectCommand({ + Bucket: BUCKET, + Key: s3Key, + ContentType: opts.mime, + }); + const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 }); + const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`; + return { s3Key, uploadUrl, publicUrl, expiresIn: 600 }; +} + +export { s3Internal }; +export { BUCKET as UPLOAD_BUCKET }; From 701a1f02bd2576fa0081c2b2267cb74073ba4337 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 00:52:57 +0000 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20Reels=20plein=20=C3=A9cran=20mobi?= =?UTF-8?q?le=20+=20MediaUploader=20dans=20l'admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/carbets/[id]/page.tsx | 27 +++++++++++-------- src/app/decouvrir/_components/ReelsViewer.tsx | 25 ++++++++++++++--- src/components/SiteHeaderGuard.tsx | 2 ++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 02c0d80..7799bef 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/admin/carbets"; import { CarbetForm } from "../_components/CarbetForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { MediaManager } from "./_components/MediaManager"; +import { MediaUploader } from "@/components/MediaUploader"; import { StatusActions } from "./_components/StatusActions"; import { updateCarbetAction } from "../actions"; @@ -61,16 +61,21 @@ export default async function EditCarbetPage({ params }: PageProps) {
- ({ - id: m.id, - type: m.type, - s3Key: m.s3Key, - s3Url: m.s3Url, - sortOrder: m.sortOrder, - }))} - /> +
+

+ Médias +

+ ({ + id: m.id, + type: m.type, + s3Key: m.s3Key, + s3Url: m.s3Url, + sortOrder: m.sortOrder, + }))} + /> +
+
{/* Bouton retour catalogue */} ← Catalogue {/* Compteur */} -
+
{activeIndex + 1} / {carbets.length}
+ {/* Logo Karbé en surimpression haut centre */} + + Karbé + +
; } From e2d3b6a686094795d349e93026decec7d8806bea Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 01:05:25 +0000 Subject: [PATCH 17/21] feat: variantes responsives 320/800/1600 via sharp + srcset partout (Reels, cards, galerie, favoris) --- package-lock.json | 5 +- package.json | 1 + src/app/api/uploads/finalize/route.ts | 23 ++++ src/app/carbets/_components/carbet-card.tsx | 6 +- .../carbets/_components/carbet-gallery.tsx | 12 ++ src/app/decouvrir/_components/ReelSlide.tsx | 5 + src/app/mes-favoris/page.tsx | 5 + src/components/ResponsiveImage.tsx | 56 ++++++++ src/lib/image-variants.ts | 41 ++++++ src/lib/variants-server.ts | 126 ++++++++++++++++++ tests/lib/image-variants.test.ts | 38 ++++++ 11 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/components/ResponsiveImage.tsx create mode 100644 src/lib/image-variants.ts create mode 100644 src/lib/variants-server.ts create mode 100644 tests/lib/image-variants.test.ts diff --git a/package-lock.json b/package-lock.json index 9dcbdb0..7d8475d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -1646,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -5398,7 +5398,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9574,7 +9573,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -9618,7 +9616,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 5bb9e15..e0a10f1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts index 91fd2cd..c9f7cd8 100644 --- a/src/app/api/uploads/finalize/route.ts +++ b/src/app/api/uploads/finalize/route.ts @@ -6,6 +6,7 @@ import { MediaType, UserRole } from "@/generated/prisma/enums"; import { prisma } from "@/lib/prisma"; import { classifyMime } from "@/lib/uploads"; import { recordAudit } from "@/lib/admin/audit"; +import { generateImageVariants } from "@/lib/variants-server"; export const runtime = "nodejs"; @@ -62,5 +63,27 @@ export async function POST(req: Request) { actorEmail: session.user.email ?? null, details: { carbetId: carbet.id, kind }, }); + + // Génération des variantes responsives (best-effort, n'échoue pas la requête). + // L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure. + try { + const variants = await generateImageVariants({ + originalS3Key: parsed.data.s3Key, + mime: parsed.data.mime, + }); + if (!variants.skipped) { + const okCount = variants.results.filter((r) => r.ok).length; + await recordAudit({ + scope: "uploads", + event: "media.variants", + target: media.id, + actorEmail: session.user.email ?? null, + details: { generated: okCount, total: variants.results.length }, + }); + } + } catch (e) { + console.error("[uploads] variants generation error:", e); + } + return NextResponse.json({ media }); } diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index 9a6a53b..c11003a 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import type { CarbetSearchResult } from "@/lib/carbet-search"; import { formatPirogueDuration, truncate } from "@/lib/format"; import { formatAverageRating } from "@/lib/reviews"; +import { buildSrcSet } from "@/lib/image-variants"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; import { StayConstraints } from "@/components/StayConstraints"; @@ -14,13 +15,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
{carbet.coverUrl ? ( - // Use a plain here — uploaded media URLs come from MinIO/S3 and - // don't go through next/image's optimizer in this environment. // eslint-disable-next-line @next/next/no-img-element {`Photo ) : ( diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx index a5c7ca1..4122a35 100644 --- a/src/app/carbets/_components/carbet-gallery.tsx +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import type { PublicCarbetMedia } from "@/lib/carbet-public"; import { MediaType } from "@/generated/prisma/enums"; +import { buildSrcSet } from "@/lib/image-variants"; type Props = { title: string; @@ -73,7 +74,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Photo )} @@ -101,8 +106,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Média )} @@ -179,7 +187,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Photo )} diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx index 26e3809..a8476f4 100644 --- a/src/app/decouvrir/_components/ReelSlide.tsx +++ b/src/app/decouvrir/_components/ReelSlide.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import type { ReelCarbet } from "@/lib/reels"; +import { buildSrcSet } from "@/lib/image-variants"; type Props = { carbet: ReelCarbet; @@ -115,9 +116,13 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl // eslint-disable-next-line @next/next/no-img-element {`${carbet.title} )} diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx index 6ec4097..5887400 100644 --- a/src/app/mes-favoris/page.tsx +++ b/src/app/mes-favoris/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { auth } from "@/auth"; import { listFavoriteCarbets } from "@/lib/reels"; +import { buildSrcSet } from "@/lib/image-variants"; export const dynamic = "force-dynamic"; @@ -41,7 +42,11 @@ export default async function MyFavoritesPage() { // eslint-disable-next-line @next/next/no-img-element {c.title} ) : ( diff --git a/src/components/ResponsiveImage.tsx b/src/components/ResponsiveImage.tsx new file mode 100644 index 0000000..61bfaa6 --- /dev/null +++ b/src/components/ResponsiveImage.tsx @@ -0,0 +1,56 @@ +/** + * avec srcset/sizes pré-rempli sur les variantes Karbé. + * Drop-in remplacement pour les balises `` côté front. + */ + +import { buildSrcSet } from "@/lib/image-variants"; + +type Props = { + src: string; + alt: string; + /** Indication CSS pour le browser. Ex: "(min-width: 768px) 800px, 100vw" */ + sizes?: string; + className?: string; + loading?: "lazy" | "eager"; + fetchPriority?: "high" | "low" | "auto"; + width?: number; + height?: number; + decoding?: "async" | "sync" | "auto"; + draggable?: boolean; + style?: React.CSSProperties; + onClick?: () => void; +}; + +export function ResponsiveImage({ + src, + alt, + sizes = "(min-width: 768px) 800px, 100vw", + className, + loading = "lazy", + fetchPriority = "auto", + width, + height, + decoding = "async", + draggable, + style, + onClick, +}: Props) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} diff --git a/src/lib/image-variants.ts b/src/lib/image-variants.ts new file mode 100644 index 0000000..5d0e22a --- /dev/null +++ b/src/lib/image-variants.ts @@ -0,0 +1,41 @@ +/** + * Variantes responsive : génération + URL helpers. + * + * Convention de nommage : .jpg -> -320.jpg, -800.jpg, -1600.jpg. + * Le format est forcé à JPEG pour les variantes (compression efficace, + * supporté partout). L'original reste tel quel (PNG/WebP/AVIF préservés). + * + * Helper côté client : variantUrl(originalUrl, width) → URL de la variante. + * Le browser fait le fallback automatiquement via srcset si la variante 404. + */ + +export const VARIANT_WIDTHS = [320, 800, 1600] as const; +export type VariantWidth = (typeof VARIANT_WIDTHS)[number]; + +/** Calcule l'URL d'une variante depuis l'URL originale. */ +export function variantUrl(originalUrl: string, width: VariantWidth): string { + const lastDot = originalUrl.lastIndexOf("."); + if (lastDot === -1) return originalUrl; + const base = originalUrl.slice(0, lastDot); + return `${base}-${width}.jpg`; +} + +/** Calcule la s3Key d'une variante depuis la s3Key originale. */ +export function variantS3Key(originalKey: string, width: VariantWidth): string { + const lastDot = originalKey.lastIndexOf("."); + if (lastDot === -1) return originalKey; + const base = originalKey.slice(0, lastDot); + return `${base}-${width}.jpg`; +} + +/** + * srcSet attribut pour un ``. Le browser pick la meilleure variante + * selon viewport+DPR. Si une variante 404, srcset fallback en cascade ; + * on ajoute toujours l'original comme dernière entrée pour garantir + * qu'au moins UNE source fonctionne. + */ +export function buildSrcSet(originalUrl: string): string { + return VARIANT_WIDTHS.map((w) => `${variantUrl(originalUrl, w)} ${w}w`) + .concat([`${originalUrl} 2000w`]) + .join(", "); +} diff --git a/src/lib/variants-server.ts b/src/lib/variants-server.ts new file mode 100644 index 0000000..06f3177 --- /dev/null +++ b/src/lib/variants-server.ts @@ -0,0 +1,126 @@ +/** + * Génération de variantes responsive côté serveur (Node). + * + * - Télécharge l'original depuis MinIO via l'endpoint interne. + * - sharp → 3 variantes (320 / 800 / 1600 px de large max, JPEG quality 80). + * - Upload chaque variante avec naming convention -.jpg. + * - Skippe vidéos (sharp ne les traite pas). + * + * Best-effort : si une variante échoue, on log et on continue. L'original + * fonctionne toujours côté front grâce au srcset fallback. + */ + +import "server-only"; + +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import type { Readable } from "node:stream"; + +import { VARIANT_WIDTHS, variantS3Key, type VariantWidth } from "./image-variants"; + +const ENDPOINT = process.env.S3_ENDPOINT ?? ""; +const BUCKET = process.env.S3_BUCKET ?? ""; +const REGION = process.env.S3_REGION ?? "us-east-1"; +const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? ""; +const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? ""; + +const s3 = new S3Client({ + endpoint: ENDPOINT, + region: REGION, + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }, +}); + +async function streamToBuffer(stream: Readable | ReadableStream): Promise { + if ("getReader" in stream) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks); + } + const chunks: Buffer[] = []; + for await (const c of stream as Readable) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +export type VariantResult = { + width: VariantWidth; + s3Key: string; + ok: boolean; + reason?: string; +}; + +/** + * Génère les 3 variantes responsives pour une image originale. + * Skip silencieusement si mime === video/*. + */ +export async function generateImageVariants(opts: { + originalS3Key: string; + mime: string; +}): Promise<{ skipped: boolean; results: VariantResult[] }> { + if (opts.mime.startsWith("video/")) { + return { skipped: true, results: [] }; + } + + let sharp: (input: Buffer) => import("sharp").Sharp; + try { + const mod = await import("sharp"); + sharp = (mod as unknown as { default: (input: Buffer) => import("sharp").Sharp }).default; + } catch { + return { skipped: true, results: [] }; + } + + // 1. Download original + let originalBuffer: Buffer; + try { + const get = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: opts.originalS3Key })); + if (!get.Body) throw new Error("Empty body"); + originalBuffer = await streamToBuffer(get.Body as Readable); + } catch (e) { + return { + skipped: false, + results: VARIANT_WIDTHS.map((w) => ({ + width: w, + s3Key: variantS3Key(opts.originalS3Key, w), + ok: false, + reason: e instanceof Error ? e.message : "download failed", + })), + }; + } + + // 2. Variantes en parallèle + const results = await Promise.all( + VARIANT_WIDTHS.map(async (w): Promise => { + const targetKey = variantS3Key(opts.originalS3Key, w); + try { + const buf = await sharp(originalBuffer) + .rotate() // respecte l'EXIF orientation + .resize({ width: w, withoutEnlargement: true }) + .jpeg({ quality: 80, progressive: true, mozjpeg: true }) + .toBuffer(); + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: targetKey, + Body: buf, + ContentType: "image/jpeg", + CacheControl: "public, max-age=31536000, immutable", + }), + ); + return { width: w, s3Key: targetKey, ok: true }; + } catch (e) { + return { + width: w, + s3Key: targetKey, + ok: false, + reason: e instanceof Error ? e.message : "resize/upload failed", + }; + } + }), + ); + + return { skipped: false, results }; +} diff --git a/tests/lib/image-variants.test.ts b/tests/lib/image-variants.test.ts new file mode 100644 index 0000000..18fec19 --- /dev/null +++ b/tests/lib/image-variants.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; + +import { VARIANT_WIDTHS, buildSrcSet, variantS3Key, variantUrl } from "@/lib/image-variants"; + +describe("VARIANT_WIDTHS", () => { + it("contient 320, 800, 1600", () => { + expect(VARIANT_WIDTHS).toEqual([320, 800, 1600]); + }); +}); + +describe("variantUrl", () => { + it("transforme .jpg en -320.jpg", () => { + expect(variantUrl("https://x/y/abc.jpg", 320)).toBe("https://x/y/abc-320.jpg"); + }); + it("force JPEG sortie même pour PNG/WebP en input", () => { + expect(variantUrl("https://x/y/abc.png", 800)).toBe("https://x/y/abc-800.jpg"); + expect(variantUrl("https://x/y/abc.webp", 1600)).toBe("https://x/y/abc-1600.jpg"); + }); + it("renvoie l'original si pas d'extension", () => { + expect(variantUrl("https://x/y/abc", 320)).toBe("https://x/y/abc"); + }); +}); + +describe("variantS3Key", () => { + it("transforme correctement la s3Key", () => { + expect(variantS3Key("carbets/foo/123-abc.jpg", 800)).toBe("carbets/foo/123-abc-800.jpg"); + }); +}); + +describe("buildSrcSet", () => { + it("contient les 3 variantes + fallback original", () => { + const set = buildSrcSet("https://x/abc.jpg"); + expect(set).toContain("abc-320.jpg 320w"); + expect(set).toContain("abc-800.jpg 800w"); + expect(set).toContain("abc-1600.jpg 1600w"); + expect(set).toContain("abc.jpg 2000w"); + }); +}); From 4fb7c948ad608253f0f78706d37398d6b948e6ec Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 01:27:20 +0000 Subject: [PATCH 18/21] feat(cron): regenerate-variants task pour batch tous les Media existants --- src/lib/scheduled.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts index f9faee8..d3272f8 100644 --- a/src/lib/scheduled.ts +++ b/src/lib/scheduled.ts @@ -5,10 +5,11 @@ import "server-only"; -import { BookingStatus } from "@/generated/prisma/enums"; +import { BookingStatus, MediaType } from "@/generated/prisma/enums"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; import { purgeExpiredResetTokens } from "@/lib/password-reset"; +import { generateImageVariants } from "@/lib/variants-server"; const PENDING_TTL_DAYS = 7; @@ -67,9 +68,44 @@ export async function listUpcomingArrivalsInThreeDays() { }); } +/** Régénère les variantes responsives pour tous les Media PHOTO en base. */ +export async function regenerateAllVariants(): Promise<{ scanned: number; ok: number; skipped: number; failed: number }> { + const medias = await prisma.media.findMany({ + where: { type: MediaType.PHOTO }, + select: { id: true, s3Key: true }, + }); + let ok = 0; + let skipped = 0; + let failed = 0; + for (const m of medias) { + const ext = m.s3Key.split(".").pop()?.toLowerCase(); + if (!ext || !["jpg", "jpeg", "png", "webp", "avif"].includes(ext)) { + skipped++; + continue; + } + const mime = + ext === "png" ? "image/png" : + ext === "webp" ? "image/webp" : + ext === "avif" ? "image/avif" : + "image/jpeg"; + const result = await generateImageVariants({ originalS3Key: m.s3Key, mime }); + if (result.skipped) skipped++; + else if (result.results.every((r) => r.ok)) ok++; + else failed++; + } + await recordAudit({ + scope: "cron", + event: "variants.regenerate-all", + actorEmail: null, + details: { scanned: medias.length, ok, skipped, failed }, + }); + return { scanned: medias.length, ok, skipped, failed }; +} + export const SCHEDULED_TASKS = { "auto-cancel-stale-pending": autoCancelStalePending, "purge-reset-tokens": purgeResetTokens, + "regenerate-variants": regenerateAllVariants, } as const; export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS; From bc158ca144dec17e68949719b4ad8cb7853dc31a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 01:53:22 +0000 Subject: [PATCH 19/21] =?UTF-8?q?feat(pwa):=20manifest=20+=20ic=C3=B4nes?= =?UTF-8?q?=20192/512/maskable=20+=20Apple=20touch=20+=20viewport=20theme-?= =?UTF-8?q?color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/apple-touch-icon.png | Bin 0 -> 1059 bytes public/icons/favicon-32.png | Bin 0 -> 208 bytes public/icons/icon-192-maskable.png | Bin 0 -> 1069 bytes public/icons/icon-192.png | Bin 0 -> 1202 bytes public/icons/icon-512-maskable.png | Bin 0 -> 3118 bytes public/icons/icon-512.png | Bin 0 -> 3479 bytes public/manifest.webmanifest | 60 +++++++++++++++++++++++++++++ src/app/layout.tsx | 22 +++++++++++ 8 files changed, 82 insertions(+) create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/favicon-32.png create mode 100644 public/icons/icon-192-maskable.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512-maskable.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.webmanifest diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a185b6796551f6bded99d10c48959adfd315c2d7 GIT binary patch literal 1059 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof%%oEi(^Q|oVRzp^KN@cv|jXD zJbAAbchI+2f&3ghWTU4)n*G%xUG9QI?aywdrN2IY{uuUPX8pT=%qyl_bR2P1;ZrIU z=xp|oae1U5#6FQqLjUiTKK#D@__q2Vug||v{x2jn8D_)9T=OMoDk`_vosHdTvC269 z*i`vzM*^qMG+OtcN58LYiP5{NEy{gSHoA}Rsnk*Odpm2_6?Z&Cl{;Wsg!{)QrKk& z)DdZxW4QIJVqdZ8-F6GV?PV=jF1K6gT}qOFw8O8sw&QWo>TNS_0}bPidg(0lJ;Kf@ z?~Sue`KmqrxA|5+J$z{E{(m(wg;$b1mBha%Mz1_Nsdaxvy`kl*&zl-fKWjf3ICpw> n{mnlHmK{Dc$wcTG@`Zhcr?s15`92?D=4bG9^>bP0l+XkKi=OZ8 literal 0 HcmV?d00001 diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c062acf25a431e2370c4a48921b89282250b7bfd GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WrhB?LhE&XXJI$M`!GOmlI`F*| zzsUPrQ@foCl4nXr=_G}*Cd%o*Jice=(urGneE%~BGhAEFf7IsM@5&Pm-+3l*oo-u{ z*RVwW=7MF)n=kSnIH}!`)c0O{oohwsHK6}Bxf=%@Bqzj6t zW0lGR4Kz64E#U0=8D|paWVk>gsk^|f{q?T1C;qR0{GCx;KFiAItmga!KqoSIy85}S Ib4q9e045hvoB#j- literal 0 HcmV?d00001 diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..e80f81192a1e0b24809cb8e17670bfea31e1c067 GIT binary patch literal 1069 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf%%K4i(^Q|oVRx``aN=BXnk0? z`PaH14nNk#y>;zP6imEjocr?3_t?By91qXWT-UL0&pdzDX}>mqXMS*l#l^+JW#EZ_ z@BT7m)y_L-I(={Lp7nn{t`+>hY5RGym5+n$?%?yr%Z&?LY}Y?uzdT~W-Me$K2&Zy_pJX$hwRrc?PJU%7l@X|19{pnzxF*>=e~09 zd{x!l`ALnpUOl+`b*D~%@nf0j={f<-?f+(8nZWAR8v|6^X?T8(wphl(_OCq#_D7j? z1B`8&uLQH0c;A=Li#aA3c3Sqluh@%?kuP~nfzq~1=Ka>WuzpUYsm_I5?pKlpbzQ8# zGH-t9i3A!IE)!iGxj^63>hL-Lm--7*$vuHLzd$xWP*^U;h2;y~ke7{33JVi1yx9|CqI;qAm+C?ktbOz2eKS+6A-vZ<$p7{QbK%u((tr;OTD3{k5A54^Effxp4aR&zbL! z2c+(m&&Z#2aRcwy8vc-#C$sI}{GHI?buqxfA%51L_l611Oux==*lyI#`6BAZg$!kt z1=5Ee=d5UB`J&BxRp5fHRF#JVzsm8O9K4(>y4vcL7equBBQH`%7QBofQlPgk4KsV>6wPoLIK{)W-1GgJ(Ej!&-ZXxEwNmY zac$}~5!vlPdi8JlO=1^bnf-8`Z!L5|OsjvhGEnW?lA9A6%&x{eGu?_hTUfTBjb%%J zo|Mpqyk8j}4%Jl^b8PE@Quh~1Tb;?*cx)M|pv0u4giKifS=l_x-u1z(+;t~&fAt>u zkiTHZvWKQ?bHp!v-@L1eZ~FgtKq<|)IU*Onym)W(W!CcNv%;>M4|(<#a4diTVkfbVt~QZ)z4*}Q$iB}>-D)j literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0fd1351de30e4ac11173a6c54cdeb59338e79e GIT binary patch literal 1202 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfu+*Z#WAE}&fB{ei*6YRv_AAz zKe>AnyVCnvtK)V!uQ+(@(!@1)ul|oMn{%LLzq`SNZ*L!087{as|6)1AMnM4;78XuM zM<<5~0(4@Y{Qbf@A?;i7&S`ZgpMMYUuYPoGqWr}P^E+0rQPu7`WzP;+t^34g2GMwT48y)Vkc!3mtEZ*6+`gnu_|Fp^4Nw#hd$)Pm@r=cZ1Zt9YB*y_W6LwWi00_odC&mYedCXIKxc!mHjgViS#POaGd>J7 z62y>0DSJ_VSFe{V}Gy0sgXLE1u3YjkXmtUl6u`-f?5Oz$*>YlJfZL zUH#8qo>@?*ljqQV{{8ypvTwd$NiKc1=J+D^l-s;_AJ2KEet7i;pv*>QgSUSo`WnO* z%N?EhdZQ2H&5q+mpQ>~s9iBa~nC-i*LCmCN)*Qx3lJl6a&U}^lF8yA^n%l;&SIz_a>OxbS2uq5a zGZWLV<;M%noSj*|$V+LtI%J!Kv^31isGFmj*U?~h@q!@BmiD$dr3K=*GEz_!tuPnST2rynFJnD`tOu>{Jnb>9=pkNoXPUU>0;rttQL+F6FYbX zC+I(vQc@`IlTVie(#sbC?Wk+b=xB(su9(9M)W`JU&V=s&+RTL;@n*%7)nCKc-P>FJ zt4;p;lig`cpPjuj*Ss#@{G{p6b(O0u7TnfdyYKzq`Nh8bvU8jx9Zr|u`B~+QdQ4^#x9i zDAFY$Xk0P_I?|evQeZ^6R3)NfF=%hpj;t zaS{$Oj>T+fUlU+EW53=7giDdVvXUbFWtT=#s$lZr?Fw731jz4|_6MEd_3mYFy|I2S z8)ozbB0xL?UUv(SQB{j!FU6MSS{f{^<~YvS>1;SrXtzG4fpw=z*9li%eLzTV5>A|m zE30o&CF8_sk-hFQ4K_uD@?mTDZH=N$b(#s!KlUT42j|1aL{sL2neM&=V+g2KNkCmz zZOkxzn92s*U0>j+MScOLM6rJ7UV?NgIK78^UIOixR#-e8 zs5gSwrdq* z3jUf(Xx6k5`opG3dZp5S+Ndp|66J%u>x)=U3aR5!BEAUwSxF`KS|@B@1>nuHW)_uL z+EJGH-gav?MYJo$`^h@!OnANe+VI>jia}ja)!+NeP%fP!io6lq6tABZh>>@_b!+|R zQw($#;M0IujI2yshnu3R^ToVq=+vDp(D`WIoX_}{g;0CqnyU1klQ44GXKTzm{!ka` zu@EG_D=hlIzVtmk6@F?j$wnkF#HiiP{`sS!O9vUi_Opv3?oK;jE?H^OANf+=T7o9X zD6V}H4)X5&d`s!R+74MWHx4g64a28gXr}G?n{WeQWKTM$IqqqL$}z5-^9)kvLHED% z_tmvMEdF}tGbr34M_HlnNKI0^Hi> zl!oVk-*hIyWI%q=NOX1};m?3AtsuXQF01h~3RA?x$Zj6YT?jF8xWOMIUHks&)(W(F zP_Z62EW^mn3)7$^7PdC~>egO>*hNqgg&S^CiR4r`gxp;-bZc4U7gS<273!j4!!t1F z!$yDmfpaveALU9oZa4$yN-4w0$um2hlPOoC@l(^GH6mdWkGR7C4ggHP4GQHM;!Sh~%OG7pl1iUYCUkZAC3;`_iXDZlt{y5;p6fMCZ zs0E(S2k8=~$R7jQ?02g-;PUf8_V9>6I)v;ji5KGXLLhs2#B~OQgyV$;KvvRfQLSHn zK+R-|R%1}y0)5_~{@c_aj`IjM10?8V&_r0qBSsi7n0#Wu%X|XJyRj0&htMKVEIvCQ z$f>b)D2u=wq#UMbF$OuU&^QmIKUO-kAZGo~oU%zTZb5P*HOM9&QAvlAIDF+vAl-B8 z5ts_Js}xQYh-nv7flBS+XA;vjs*WJRSKvwXGMQA$)muCan{qe3_D{+zm6-WmHNN*m2#&pFl z9l~xs3uM}F4f}4uYY?f6A8(`JWI9yD;LeU;@rbkNA)_1x7hVG4$qbL)Z$bF5?*`!` zO2c3ki;Tk{oJEF%*Pa*7pga&>qdo6N6w?Ry-lal(DINo>QEqm-)>^i9Zj4h%T; i+uZnl`5#i^ngbW)W=P8Q5vk}u0HVTU!+uyNI`$u7ttJxy literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..abb04bf9ea494914cd2ccb8303170d0f2be0cde0 GIT binary patch literal 3479 zcmeHK>sM1(7T@>Y5E7O_Anj5TkRXZ(NTZ}dk(Qvy%P1@>RVyXI2cR;7C?izuy*vaT zR9K1{YDTc@l#*o5^`Dse&@Var?6V)g{Wv@4 z9*^m2%|`fJ2bj1CXmnEl0XBkMBXy6grx3U4(B|xIG^))S${heH+ES)?vPLZVnIgT2bY_B z$pSxt%2#I-@FC;knNzk1k}C$ie~nF_2HM443Tp(pRSYVou6YF?dJ-QQkctbU6h_%m z4$Ljblh7e;_&GY%7ZqG-^WMv%4esL8cJD*Iz zKbKjqB0RB&-$!`%Bi#~(m5Ug%rgCe9Z3l|mx5joDEk{U{58E{cXbc0$@A#MlOVe#q zR&c;v)Id7&Ve-7u<9jCfcho5-*Jq(vl*X54qn{M<~=s6A~|6$UnPcLe~wAZENJhT)Hn;Qxd zzabN8ZYQXmYoX=~v@J#99*-NyQqI=MWpY9up<{xp^x+>J~xIkSneFZUPn-TNCO&1gerVv}!*J)_(7dYc2-@ zvYsN<6fPeU+Ds`vV$dK}<_?0=eJr%N+lKBiVNuf<3b&zz16|vu1DEsR`gp6-B@KE8 z&(fH^5Y_p;J*iXXGk}*dzIsg^rLPU3j|^#`meuDs8)H$kqGq`q%!jnGR^~2Yej8Q=qxzw`aGthp$FCz4d_P3p8wqHjz z?9M?lk5b=^o$K>{6y34wnl}YAUS#Z9wCOYjj5*9?})*B<6cx;-BAwp zRTb^Wk@?5ZQIj zg}D0+8jEY8pP*f6sa(PbyUwN4Ng`a>e8ITo1tVBzdgt|j$}d3Y%YmLh10zDabxD`| zq*bJ=`91Dh*f5h#T6R~QYMd_>TaqNW(~~gb{il!f7f0FzTTr(OI)2RR;mwh`mRmDi zv8d~rZ=2hr{qB~%@u`4<;6bY7bCbN^INOU$C9JH=?;reRr|31^13U22x}SX~&(?of z(D(*^2&TUlulLh+AE~|j8viAz?ct4Ev8{_>W@qxhe_6L74<<{{uqqq~OBBSj;NaY( zQVD2ci9cRF_6Y~`>1V31Zw2gjh6>>W`G>f6KTlowZTJdY>syyUZz{7k3I?@`n;m;9 zeK0xyo@0$CYK>slm;jzvV{u=Cx~=$>cs;#e?u zOr&>o?8v~i=`{~sZ^cJ=L&AZ{63gQ-o>pyrX2B+&SHC)NaM(8ey*Af8_3M%wWU&-7 z-*s!bQ1htd^(GGG%#zgRdztnAI+&XaEkjFly60{!A3*wXDEaNVhlWZW66{3OK9=N! z{r(#-L;7H14a^^w4))f>>a$9j!o2j+(Zo;4!t&3A4;(n3J0}jDb+Ggc7n07!xz(JO z>-wcUOzt20SXQ-6g&kM`#ZiPF$F(XEba_Lu-*3UJ0JH{FW9IPN+OFm7?I8fTQQ(l4u^Zu_1mJxGjLU#1`Z~!xvM9aHt~;$~6_93qZQ<1uZ)Z z*KQO+KNpg#F!gB=Rvxd<7g2=_YS%6Z@rF(*p~ooT2YT+IJ!Vy z=Rx;s%*_*8dcnK~qC`W!D(txzJn4hGZ0HqHJ8(%;c^xvjj)Cs!#dry^ITrLm909u2 zGF<|)x`zeBeXt7sR$>dBK$Cuo#)V;2q=-7shLB3EGY0gHY}!aS1NyH#28}1E!;H+$ zI8CfG((+yy^`3}gv*gbk)@x_CWXjwAny)1Y+{QE0vtuOc)G&`MgHk?nF8%!(v5 zg`hpgl-Y^ojyM?lVEsZg={fok9IE(#21gMwT&N8rQrClO7Du%NqhbngBi$3|zr(cw zEAxW5K8mAe(8NOu^o_=JIc9#89y!w0=|l@&k6Az9%v zj9Re^V%Z>FOJqwyo@_vs&{7N-yNnGl1gE4@RH(xF;1o`nqd;(yIavgfFP!N>88}pm z0HYE{_!1coz0-;ITkvTYP>rL?yR$*<&vS|fYBZS6?Hn9tRuh@%HM5pL70!sPN0>CH z0c3qtBOA?mrc?^lq9)qA9RoJ6A;!Ew`w>s*ih)>TMOo-V`(&Qb9)mwL*>CqyfhBl&@WesJOE<_1}pL(cF23=t^d{meAAh0Sn;_4_D1}s0yL}P$zNXS4{ zj6_HRICJ~IeVP8w?N9>T2mdc#$q&feq1tc!JG#$*TVgXCEDr-KUKOV-Q#SU|K;3P> z2h|&@LhD=Kuw5z|6T4w1$16x?Ym>b$P6;uT7o?n<*JP(3*ksjrBu?!PF}QYTC!b1b u5@|hfqTuOF3V>-F@%NX&o6n)&$?x8~-{+h+YZLn5g6K8t!|PVb^ZpC!gyZ=D literal 0 HcmV?d00001 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..2f32e8d --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,60 @@ +{ + "name": "Karbé — carbets fluviaux de Guyane", + "short_name": "Karbé", + "description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.", + "start_url": "/decouvrir", + "id": "/decouvrir", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#000000", + "theme_color": "#059669", + "lang": "fr", + "categories": ["travel", "lifestyle"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Au fil de l'eau", + "short_name": "Découvrir", + "url": "/decouvrir", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Mes favoris", + "short_name": "Favoris", + "url": "/mes-favoris", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Mon compte", + "short_name": "Compte", + "url": "/mon-compte", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2a05155..1e1dc82 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -52,6 +52,21 @@ export const metadata: Metadata = { }, description: "Karbé, la marketplace de location de carbets fluviaux de Guyane.", + manifest: "/manifest.webmanifest", + applicationName: "Karbé", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Karbé", + }, + icons: { + icon: [ + { url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: "/icons/apple-touch-icon.png", + }, openGraph: { type: "website", siteName: "Karbé", @@ -62,6 +77,13 @@ export const metadata: Metadata = { }, }; +export const viewport = { + themeColor: "#059669", + width: "device-width", + initialScale: 1, + viewportFit: "cover" as const, +}; + export default async function RootLayout({ children, }: Readonly<{ From d5732917e318a4e9fa6a96bacaa95c1826f24a82 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:03:23 +0000 Subject: [PATCH 20/21] =?UTF-8?q?feat(reels):=20swipe=20horizontal=20anim?= =?UTF-8?q?=C3=A9=20avec=20suivi=20du=20doigt=20+=20snap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/decouvrir/_components/ReelSlide.tsx | 290 +++++++++++++++----- 1 file changed, 216 insertions(+), 74 deletions(-) diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx index a8476f4..7c1b4e7 100644 --- a/src/app/decouvrir/_components/ReelSlide.tsx +++ b/src/app/decouvrir/_components/ReelSlide.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import type { ReelCarbet } from "@/lib/reels"; @@ -14,36 +14,71 @@ type Props = { onToggleFavorite: () => void; }; +const SWIPE_THRESHOLD_RATIO = 0.18; // % de la largeur pour valider le swipe +const VELOCITY_THRESHOLD = 0.4; // px/ms — un flick rapide même court valide + export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) { const [mediaIndex, setMediaIndex] = useState(0); const [muted, setMuted] = useState(true); - const touchStart = useRef<{ x: number; y: number } | null>(null); - const videoRef = useRef(null); + const [dragX, setDragX] = useState(0); + const [transitioning, setTransitioning] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = useRef(null); + const videoRefs = useRef>(new Map()); + const drag = useRef<{ + startX: number; + startY: number; + startTime: number; + locked: "horizontal" | "vertical" | null; + } | null>(null); + const total = carbet.media.length; const current = carbet.media[mediaIndex]; - const nextMedia = useCallback(() => { - setMediaIndex((i) => (i + 1) % carbet.media.length); - }, [carbet.media.length]); - const prevMedia = useCallback(() => { - setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length); - }, [carbet.media.length]); + const goTo = useCallback( + (next: number, animated = true) => { + const clamped = ((next % total) + total) % total; + setTransitioning(animated); + setMediaIndex(clamped); + setDragX(0); + }, + [total], + ); - // Auto-play/pause vidéos quand slide active + const nextMedia = useCallback(() => goTo(mediaIndex + 1), [goTo, mediaIndex]); + const prevMedia = useCallback(() => goTo(mediaIndex - 1), [goTo, mediaIndex]); + + // Suit la largeur du container pour les calculs de seuils / progress useEffect(() => { - if (!videoRef.current) return; - if (isActive && current?.type === "VIDEO") { - videoRef.current.play().catch(() => {}); - } else { - videoRef.current.pause(); - } - }, [isActive, current?.type, mediaIndex]); + const el = containerRef.current; + if (!el) return; + const update = () => setContainerWidth(el.offsetWidth || window.innerWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); - // Reset au changement de slide (différé pour éviter cascading renders) + // Auto-play/pause vidéos selon média actif + useEffect(() => { + videoRefs.current.forEach((video, idx) => { + if (idx === mediaIndex && isActive && carbet.media[idx]?.type === "VIDEO") { + video.play().catch(() => {}); + } else { + video.pause(); + } + }); + }, [isActive, mediaIndex, carbet.media]); + + // Reset au changement de slide carbet (différé pour éviter cascading renders) useEffect(() => { if (isActive) return; - queueMicrotask(() => setMediaIndex(0)); - }, [isActive]); + queueMicrotask(() => goTo(0, false)); + }, [isActive, goTo]); // Navigation clavier ← → useEffect(() => { @@ -65,21 +100,88 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl function onTouchStart(e: React.TouchEvent) { const t = e.touches[0]; - touchStart.current = { x: t.clientX, y: t.clientY }; + drag.current = { + startX: t.clientX, + startY: t.clientY, + startTime: Date.now(), + locked: null, + }; + setTransitioning(false); } - function onTouchEnd(e: React.TouchEvent) { - if (!touchStart.current) return; - const t = e.changedTouches[0]; - const dx = t.clientX - touchStart.current.x; - const dy = t.clientY - touchStart.current.y; - touchStart.current = null; - // Seuil horizontal > vertical pour considérer un swipe horizontal - if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) { - if (dx < 0) nextMedia(); - else prevMedia(); + + function onTouchMove(e: React.TouchEvent) { + if (!drag.current) return; + const t = e.touches[0]; + const dx = t.clientX - drag.current.startX; + const dy = t.clientY - drag.current.startY; + + // Première détection : verrouille l'axe (horizontal ou vertical) + if (drag.current.locked === null) { + if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // trop petit, attend + drag.current.locked = Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical"; + } + + if (drag.current.locked !== "horizontal") return; + // Empêche le scroll vertical pendant un swipe horizontal + e.stopPropagation(); + if (e.cancelable) e.preventDefault(); + + // Résistance aux bords : si on swipe gauche sur le 1er ou droite sur le dernier, + // on glisse moins (effet rubber-band) + let effective = dx; + if (total <= 1) { + effective = dx * 0.2; + } else if (mediaIndex === 0 && dx > 0) { + effective = dx * 0.35; + } else if (mediaIndex === total - 1 && dx < 0) { + effective = dx * 0.35; + } + setDragX(effective); + } + + function onTouchEnd() { + if (!drag.current) return; + const wasHorizontal = drag.current.locked === "horizontal"; + const elapsed = Date.now() - drag.current.startTime; + const width = containerWidth || window.innerWidth; + const velocity = Math.abs(dragX) / Math.max(1, elapsed); // px/ms + drag.current = null; + + if (!wasHorizontal) { + setDragX(0); + return; + } + + const distance = Math.abs(dragX); + const isFlick = velocity > VELOCITY_THRESHOLD && distance > 20; + const isSlow = distance > width * SWIPE_THRESHOLD_RATIO; + const shouldChange = (isFlick || isSlow) && total > 1; + + if (shouldChange) { + if (dragX < 0 && mediaIndex < total - 1) { + goTo(mediaIndex + 1); + } else if (dragX > 0 && mediaIndex > 0) { + goTo(mediaIndex - 1); + } else { + // Bord : retour à 0 + setTransitioning(true); + setDragX(0); + } + } else { + setTransitioning(true); + setDragX(0); } } + // Préchargement intelligent : current, current ± 1 + const preloadIndexes = useMemo(() => { + const s = new Set(); + s.add(mediaIndex); + if (mediaIndex > 0) s.add(mediaIndex - 1); + if (mediaIndex < total - 1) s.add(mediaIndex + 1); + return s; + }, [mediaIndex, total]); + const share = useCallback(async () => { const url = `${window.location.origin}/carbets/${carbet.slug}`; const title = carbet.title; @@ -92,57 +194,97 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl if (!current) return null; + const offsetPct = -mediaIndex * 100; + return ( -
- {/* Média */} -
- {current.type === "VIDEO" ? ( -