feat(admin): /admin/home — éditeur des textes de la page d'accueil (FR+EN, override DB)

This commit is contained in:
Claude Integration 2026-06-01 01:10:49 +00:00
parent d3cc5bdfb9
commit a9fcd18022
10 changed files with 491 additions and 2 deletions

View file

@ -0,0 +1,9 @@
CREATE TABLE "Translation" (
"key" TEXT NOT NULL,
"lang" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang")
);
CREATE INDEX "Translation_lang_idx" ON "Translation"("lang");

View file

@ -348,3 +348,14 @@ model Setting {
updatedAt DateTime @updatedAt
updatedBy String?
}
model Translation {
key String
lang String
value String
updatedAt DateTime @updatedAt
updatedBy String?
@@id([key, lang])
@@index([lang])
}

View file

@ -0,0 +1,169 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { saveHomeTranslationsAction } from "../actions";
type Row = {
key: string;
baseFr: string;
baseEn: string;
overrideFr: string | null;
overrideEn: string | null;
};
type Section = {
id: string;
label: string;
description: string;
rows: Row[];
};
type Props = {
sections: Section[];
};
function autoRows(text: string): number {
const lines = text.split("\n").length;
return Math.min(8, Math.max(1, lines));
}
export function HomeTranslationsForm({ sections }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// État local : on garde uniquement la valeur courante (initialisée avec override ?? base).
// Le baseValue est posé en input caché et sert au backend pour décider override vs reset.
const initial = useMemo(() => {
const m = new Map<string, { fr: string; en: string }>();
for (const s of sections) {
for (const r of s.rows) {
m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn });
}
}
return m;
}, [sections]);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await saveHomeTranslationsAction(formData);
if (res.ok === false) {
setError(res.error);
} else {
const parts: string[] = [];
if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`);
if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`);
setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement.");
}
});
}
// On crée un seul formulaire global qui contient toutes les sections.
let counter = 0;
return (
<form action={onSubmit} className="space-y-8">
<fieldset disabled={pending} className="space-y-8">
{sections.map((section) => (
<section key={section.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<header className="mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{section.label}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">{section.description}</p>
</header>
<div className="space-y-4">
{section.rows.map((r) => {
const idxFr = counter++;
const idxEn = counter++;
const init = initial.get(r.key)!;
const hasOverrideFr = r.overrideFr !== null;
const hasOverrideEn = r.overrideEn !== null;
return (
<div key={r.key} className="rounded-md border border-zinc-100 bg-zinc-50/50 p-3">
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<code className="text-[11px] font-mono text-zinc-600">{r.key}</code>
<span className="flex gap-1 text-[10px] uppercase tracking-wider">
{hasOverrideFr ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
FR modifié
</span>
) : null}
{hasOverrideEn ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
EN modifié
</span>
) : null}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
FR
</span>
<input type="hidden" name={`entries[${idxFr}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxFr}][lang]`} value="fr" />
<input type="hidden" name={`entries[${idxFr}][baseValue]`} value={r.baseFr} />
<textarea
name={`entries[${idxFr}][value]`}
rows={autoRows(init.fr)}
defaultValue={init.fr}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseFr.slice(0, 80)}{r.baseFr.length > 80 ? "…" : ""}</span>
</span>
</label>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
EN
</span>
<input type="hidden" name={`entries[${idxEn}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxEn}][lang]`} value="en" />
<input type="hidden" name={`entries[${idxEn}][baseValue]`} value={r.baseEn} />
<textarea
name={`entries[${idxEn}][value]`}
rows={autoRows(init.en)}
defaultValue={init.en}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseEn.slice(0, 80)}{r.baseEn.length > 80 ? "…" : ""}</span>
</span>
</label>
</div>
</div>
);
})}
</div>
</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="sticky bottom-3 flex items-center justify-end gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-md">
<span className="text-xs text-zinc-500">
Laisser une case vide ou identique au texte de base réinitialise l&apos;override.
</span>
<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…" : "Enregistrer les modifications"}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { deleteTranslationOverride, upsertTranslation } from "@/lib/admin/translations";
import { invalidateTranslationCache } from "@/lib/i18n/overrides";
import { isHomeKey } from "@/lib/admin/home-keys";
const entrySchema = z.object({
key: z.string().min(1).max(200),
lang: z.enum(["fr", "en"]),
value: z.string().max(4000),
baseValue: z.string().max(4000),
});
type SaveResult = { ok: true; saved: number; reset: number } | { ok: false; error: string };
export async function saveHomeTranslationsAction(fd: FormData): Promise<SaveResult> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// FormData arrive avec entries[N][key], entries[N][lang], entries[N][value], entries[N][baseValue].
const grouped = new Map<string, Record<string, string>>();
for (const [name, val] of fd.entries()) {
if (typeof val !== "string") continue;
const m = name.match(/^entries\[(\d+)\]\[(key|lang|value|baseValue)\]$/);
if (!m) continue;
const [, idx, field] = m;
if (!grouped.has(idx)) grouped.set(idx, {});
grouped.get(idx)![field] = val;
}
let saved = 0;
let reset = 0;
for (const raw of grouped.values()) {
const parsed = entrySchema.safeParse(raw);
if (!parsed.success) continue;
if (!isHomeKey(parsed.data.key)) continue;
const trimmed = parsed.data.value.trim();
const base = parsed.data.baseValue;
if (trimmed === "" || trimmed === base) {
// Suppression de l'override : on revient à la valeur du fichier.
await deleteTranslationOverride(parsed.data.key, parsed.data.lang);
reset++;
} else {
await upsertTranslation(parsed.data.key, parsed.data.lang, trimmed, actorEmail);
saved++;
}
}
invalidateTranslationCache();
await recordAudit({
scope: "admin.home",
event: "translations.save",
actorEmail,
details: { saved, reset },
});
revalidatePath("/admin/home");
revalidatePath("/");
return { ok: true, saved, reset };
}

View file

@ -0,0 +1,39 @@
import { HOME_SECTIONS } from "@/lib/admin/home-keys";
import { listTranslationsForKeys } from "@/lib/admin/translations";
import { HomeTranslationsForm } from "./_components/HomeTranslationsForm";
export const dynamic = "force-dynamic";
export default async function HomeAdminPage() {
const allKeys = await listTranslationsForKeys(HOME_SECTIONS.flatMap((s) => s.prefixes));
const keysBySection = HOME_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
description: s.description,
rows: allKeys.filter((r) => s.prefixes.some((p) => r.key.startsWith(p))),
}));
const totalOverrides = allKeys.reduce(
(acc, r) => acc + (r.overrideFr !== null ? 1 : 0) + (r.overrideEn !== null ? 1 : 0),
0,
);
return (
<div className="mx-auto max-w-6xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Page d&apos;accueil</h1>
<p className="mt-1 text-sm text-zinc-600">
Édition des textes affichés sur la page d&apos;accueil publique, en français et en anglais.
Les modifications sont appliquées immédiatement (cache rafraîchi sous 10 secondes).
</p>
<p className="mt-1 text-xs text-zinc-500">
{totalOverrides === 0
? "Aucun texte personnalisé pour l'instant — les valeurs par défaut viennent des fichiers de traduction."
: `${totalOverrides} valeur${totalOverrides > 1 ? "s" : ""} personnalisée${totalOverrides > 1 ? "s" : ""} actuellement active${totalOverrides > 1 ? "s" : ""}.`}
</p>
</header>
<HomeTranslationsForm sections={keysBySection} />
</div>
);
}

View file

@ -134,7 +134,10 @@ const GROUPS: NavGroup[] = [
},
{
label: "Contenu",
items: [{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages }],
items: [
{ href: "/admin/home", label: "Page d'accueil", icon: ICONS.dashboard },
{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages },
],
},
{
label: "Système",

View file

@ -0,0 +1,51 @@
/**
* Sections éditables depuis /admin/home.
*
* Liste curatée des préfixes de clés qui apparaissent réellement sur la
* page d'accueil. Le reste (season, language, access, site) est éditable
* via /admin/translations (toutes les clés) une fois construit.
*/
export const HOME_SECTIONS: { id: string; label: string; description: string; prefixes: string[] }[] = [
{
id: "hero",
label: "Bandeau d'accueil (hero)",
description: "Le visuel plein écran tout en haut — accroche + sous-titre + boutons.",
prefixes: ["hero."],
},
{
id: "experiences",
label: "Deux expériences",
description: "Section présentant les 2 modes (route + fleuve / expédition fleuve).",
prefixes: ["experiences."],
},
{
id: "howItWorks",
label: "Comment ça marche",
description: "Les étapes pour réserver un séjour.",
prefixes: ["howItWorks."],
},
{
id: "ce",
label: "Comités d'entreprise",
description: "Section dédiée aux CE et leurs membres.",
prefixes: ["ce."],
},
{
id: "testimonials",
label: "Témoignages",
description: "Bloc témoignages voyageurs.",
prefixes: ["testimonials."],
},
{
id: "footer",
label: "Pied de page",
description: "Liens et mentions en pied de page.",
prefixes: ["footer."],
},
];
export const HOME_PREFIXES: string[] = HOME_SECTIONS.flatMap((s) => s.prefixes);
export function isHomeKey(key: string): boolean {
return HOME_PREFIXES.some((p) => key.startsWith(p));
}

View file

@ -0,0 +1,72 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import frMessages from "@/messages/fr.json";
import enMessages from "@/messages/en.json";
const BASE: Record<"fr" | "en", Record<string, string>> = {
fr: frMessages as Record<string, string>,
en: enMessages as Record<string, string>,
};
export type TranslationRow = {
key: string;
baseFr: string;
baseEn: string;
overrideFr: string | null;
overrideEn: string | null;
updatedAt: Date | null;
updatedBy: string | null;
};
export async function listTranslationsForKeys(prefixes: string[]): Promise<TranslationRow[]> {
// Toutes les clés du fichier FR (canonique) qui matchent un préfixe.
const allKeys = Object.keys(BASE.fr).filter((k) => prefixes.some((p) => k.startsWith(p)));
allKeys.sort();
const overrides = await prisma.translation.findMany({
where: { key: { in: allKeys } },
select: { key: true, lang: true, value: true, updatedAt: true, updatedBy: true },
});
type Override = (typeof overrides)[number];
const overrideMap = new Map<string, Map<string, Override>>();
for (const o of overrides) {
if (!overrideMap.has(o.key)) overrideMap.set(o.key, new Map());
overrideMap.get(o.key)!.set(o.lang, o);
}
return allKeys.map((key) => {
const rowFr = overrideMap.get(key)?.get("fr");
const rowEn = overrideMap.get(key)?.get("en");
const lastTs = Math.max(rowFr?.updatedAt.getTime() ?? 0, rowEn?.updatedAt.getTime() ?? 0);
const lastEditor = (rowFr?.updatedAt ?? new Date(0)) > (rowEn?.updatedAt ?? new Date(0))
? rowFr?.updatedBy ?? null
: rowEn?.updatedBy ?? null;
return {
key,
baseFr: BASE.fr[key] ?? "",
baseEn: BASE.en[key] ?? "",
overrideFr: rowFr?.value ?? null,
overrideEn: rowEn?.value ?? null,
updatedAt: lastTs > 0 ? new Date(lastTs) : null,
updatedBy: lastEditor,
};
});
}
export async function upsertTranslation(
key: string,
lang: "fr" | "en",
value: string,
actor: string | null,
): Promise<void> {
await prisma.translation.upsert({
where: { key_lang: { key, lang } },
create: { key, lang, value, updatedBy: actor },
update: { value, updatedBy: actor },
});
}
export async function deleteTranslationOverride(key: string, lang: "fr" | "en"): Promise<void> {
await prisma.translation.delete({ where: { key_lang: { key, lang } } }).catch(() => {});
}

59
src/lib/i18n/overrides.ts Normal file
View file

@ -0,0 +1,59 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import type { Locale } from "./types";
type Cache = {
fr: Map<string, string>;
en: Map<string, string>;
loadedAt: number;
};
const TTL_MS = 10_000;
let cache: Cache | null = null;
let inflight: Promise<Cache> | null = null;
async function refresh(): Promise<Cache> {
const rows = await prisma.translation.findMany({
select: { key: true, lang: true, value: true },
});
const fr = new Map<string, string>();
const en = new Map<string, string>();
for (const r of rows) {
if (r.lang === "fr") fr.set(r.key, r.value);
else if (r.lang === "en") en.set(r.key, r.value);
}
cache = { fr, en, loadedAt: Date.now() };
return cache;
}
async function loadCache(): Promise<Cache> {
if (cache && Date.now() - cache.loadedAt < TTL_MS) return cache;
if (inflight) return inflight;
inflight = refresh().finally(() => {
inflight = null;
});
return inflight;
}
export async function getTranslationOverride(key: string, lang: Locale): Promise<string | undefined> {
try {
const c = await loadCache();
return c[lang].get(key);
} catch {
return undefined;
}
}
export async function getTranslationOverridesMap(lang: Locale): Promise<Map<string, string>> {
try {
const c = await loadCache();
return c[lang];
} catch {
return new Map();
}
}
export function invalidateTranslationCache(): void {
cache = null;
}

View file

@ -16,6 +16,7 @@ import { isPluginEnabled } from "@/lib/plugins/server";
import frMessages from "@/messages/fr.json";
import enMessages from "@/messages/en.json";
import { getTranslationOverride, getTranslationOverridesMap } from "./overrides";
const DICTS: Record<Locale, Record<string, string>> = {
fr: frMessages as Record<string, string>,
@ -55,6 +56,8 @@ export async function getLocale(): Promise<Locale> {
*/
export async function t(key: string, locale?: Locale): Promise<string> {
const lang = locale ?? (await getLocale());
const override = await getTranslationOverride(key, lang);
if (override !== undefined) return override;
const dict = DICTS[lang];
const fallback = DICTS.fr;
return dict[key] ?? fallback[key] ?? key;
@ -63,8 +66,14 @@ export async function t(key: string, locale?: Locale): Promise<string> {
/**
* dict(locale) : retourne le dico complet pour passer en prop client-side.
* Garde le bundle léger (le client ne reçoit qu'une langue à la fois).
* Les overrides DB sont fusionnés par-dessus le fichier de base.
*/
export async function dict(locale?: Locale): Promise<Record<string, string>> {
const lang = locale ?? (await getLocale());
return DICTS[lang];
const base = DICTS[lang];
const overrides = await getTranslationOverridesMap(lang);
if (overrides.size === 0) return base;
const merged: Record<string, string> = { ...base };
for (const [k, v] of overrides) merged[k] = v;
return merged;
}