Le client component CarbetForm importait des options depuis lib/admin/carbets qui contient "server-only" → erreur build turbopack. Sortie des options dans src/lib/admin/carbet-options.ts sans server-only.
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
|
|
import {
|
|
ACCESS_TYPE_OPTIONS,
|
|
STATUS_OPTIONS,
|
|
TRANSPORT_MODE_OPTIONS,
|
|
} from "@/lib/admin/carbet-options";
|
|
|
|
export type CarbetFormInitial = {
|
|
ownerId?: string;
|
|
title?: string;
|
|
slug?: string;
|
|
description?: string;
|
|
river?: string;
|
|
embarkPoint?: string;
|
|
latitude?: number | string;
|
|
longitude?: number | string;
|
|
capacity?: number;
|
|
accessType?: string;
|
|
roadAccessNote?: string | null;
|
|
pirogueDurationMin?: number | null;
|
|
minStayNights?: number | null;
|
|
maxStayNights?: number | null;
|
|
minCapacity?: number | null;
|
|
transportMode?: string | null;
|
|
pirogueProviderId?: string | null;
|
|
status?: string;
|
|
};
|
|
|
|
type Props = {
|
|
initial?: CarbetFormInitial;
|
|
owners: { id: string; firstName: string; lastName: string; email: string }[];
|
|
providers: { id: string; name: string; rivers: string[] }[];
|
|
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
|
submitLabel?: string;
|
|
};
|
|
|
|
export function CarbetForm({ initial = {}, owners, providers, action, submitLabel = "Enregistrer" }: Props) {
|
|
const [pending, startTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
function onSubmit(formData: FormData) {
|
|
setError(null);
|
|
setSuccess(null);
|
|
startTransition(async () => {
|
|
const res = await action(formData);
|
|
if (res && res.ok === false) {
|
|
setError(res.error);
|
|
} else if (res && res.ok === true) {
|
|
setSuccess("Carbet enregistré.");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<form action={onSubmit} className="space-y-6">
|
|
<fieldset disabled={pending} className="space-y-6">
|
|
{/* Identité */}
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FormField label="Titre" required>
|
|
<input name="title" defaultValue={initial.title ?? ""} className={inputCls} required maxLength={200} />
|
|
</FormField>
|
|
<FormField label="Slug" required hint="URL publique : /carbets/<slug>">
|
|
<input
|
|
name="slug"
|
|
defaultValue={initial.slug ?? ""}
|
|
className={inputCls}
|
|
required
|
|
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
|
|
placeholder="ex. karbe-awara-maroni"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Propriétaire" required className="sm:col-span-2">
|
|
<select name="ownerId" defaultValue={initial.ownerId ?? ""} className={selectCls} required>
|
|
<option value="" disabled>— sélectionner un propriétaire —</option>
|
|
{owners.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.firstName} {o.lastName} ({o.email})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Description" required className="sm:col-span-2" hint="Markdown léger autorisé.">
|
|
<textarea
|
|
name="description"
|
|
rows={6}
|
|
defaultValue={initial.description ?? ""}
|
|
className={textareaCls}
|
|
required
|
|
maxLength={20000}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Localisation */}
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Localisation</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FormField label="Fleuve" required>
|
|
<input name="river" defaultValue={initial.river ?? ""} className={inputCls} required maxLength={100} placeholder="Maroni" />
|
|
</FormField>
|
|
<FormField label="Point d'embarquement" required>
|
|
<input
|
|
name="embarkPoint"
|
|
defaultValue={initial.embarkPoint ?? ""}
|
|
className={inputCls}
|
|
required
|
|
maxLength={200}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Latitude" required hint="Décimal (-90 à 90)">
|
|
<input
|
|
name="latitude"
|
|
type="number"
|
|
step="0.000001"
|
|
defaultValue={initial.latitude?.toString() ?? ""}
|
|
className={inputCls}
|
|
required
|
|
/>
|
|
</FormField>
|
|
<FormField label="Longitude" required hint="Décimal (-180 à 180)">
|
|
<input
|
|
name="longitude"
|
|
type="number"
|
|
step="0.000001"
|
|
defaultValue={initial.longitude?.toString() ?? ""}
|
|
className={inputCls}
|
|
required
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Accès */}
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Accès & transport</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<FormField label="Type d'accès" required>
|
|
<select name="accessType" defaultValue={initial.accessType ?? "ROAD_AND_RIVER"} className={selectCls} required>
|
|
{ACCESS_TYPE_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Durée pirogue (min)" hint="Optionnel — vide si accès route uniquement">
|
|
<input
|
|
name="pirogueDurationMin"
|
|
type="number"
|
|
min={0}
|
|
max={1440}
|
|
defaultValue={initial.pirogueDurationMin?.toString() ?? ""}
|
|
className={inputCls}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Note d'accès route" className="sm:col-span-2" hint="GPS, type de piste, distance dernière ville…">
|
|
<textarea
|
|
name="roadAccessNote"
|
|
rows={2}
|
|
defaultValue={initial.roadAccessNote ?? ""}
|
|
className={textareaCls}
|
|
maxLength={1000}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Mode de transport pirogue">
|
|
<select name="transportMode" defaultValue={initial.transportMode ?? ""} className={selectCls}>
|
|
<option value="">— non spécifié —</option>
|
|
{TRANSPORT_MODE_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Prestataire pirogue partenaire">
|
|
<select name="pirogueProviderId" defaultValue={initial.pirogueProviderId ?? ""} className={selectCls}>
|
|
<option value="">— aucun —</option>
|
|
{providers.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name} ({p.rivers.join(", ")})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Séjour */}
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
|
<FormField label="Capacité" required hint="Voyageurs max">
|
|
<input
|
|
name="capacity"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
defaultValue={initial.capacity?.toString() ?? ""}
|
|
className={inputCls}
|
|
required
|
|
/>
|
|
</FormField>
|
|
<FormField label="Capacité min recommandée" hint="Facultatif">
|
|
<input
|
|
name="minCapacity"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
defaultValue={initial.minCapacity?.toString() ?? ""}
|
|
className={inputCls}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Nuits min" hint="Facultatif">
|
|
<input
|
|
name="minStayNights"
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
defaultValue={initial.minStayNights?.toString() ?? ""}
|
|
className={inputCls}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Nuits max" hint="Facultatif">
|
|
<input
|
|
name="maxStayNights"
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
defaultValue={initial.maxStayNights?.toString() ?? ""}
|
|
className={inputCls}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Publication */}
|
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Publication</h2>
|
|
<FormField label="Statut" hint="Brouillon n'apparaît pas sur le site public. Archivé reste en base mais non listé.">
|
|
<select name="status" defaultValue={initial.status ?? "DRAFT"} className={selectCls}>
|
|
{STATUS_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
</section>
|
|
|
|
{error ? (
|
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
|
) : null}
|
|
{success ? (
|
|
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
|
) : null}
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
type="submit"
|
|
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
>
|
|
{pending ? "Enregistrement…" : submitLabel}
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
);
|
|
}
|