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,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;
}