feat(admin): /admin/home — éditeur des textes de la page d'accueil (FR+EN, override DB)
This commit is contained in:
parent
d3cc5bdfb9
commit
a9fcd18022
10 changed files with 491 additions and 2 deletions
51
src/lib/admin/home-keys.ts
Normal file
51
src/lib/admin/home-keys.ts
Normal 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));
|
||||
}
|
||||
72
src/lib/admin/translations.ts
Normal file
72
src/lib/admin/translations.ts
Normal 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
59
src/lib/i18n/overrides.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue