diff --git a/src/app/admin/einstellungen/page.tsx b/src/app/admin/einstellungen/page.tsx index ec04196..547c94d 100644 --- a/src/app/admin/einstellungen/page.tsx +++ b/src/app/admin/einstellungen/page.tsx @@ -3,10 +3,12 @@ import { BackLink } from "@/components/back-link"; import { AdminBadge } from "@/components/admin-list"; import type { ContentFieldDef } from "@/lib/content-registry"; import { readIntegrationStatus } from "@/lib/integration-status"; +import { TEXT_PLACEHOLDERS } from "@/lib/text-placeholders"; import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import type { Dictionary } from "@/i18n/dictionary-type"; +import type { Locale } from "@/i18n/config"; import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form"; import { SmtpTestPanel } from "./smtp-test-panel"; @@ -76,6 +78,7 @@ export default async function AdminEinstellungenPage(props: PageProps<"/admin/ei ) : ( <>

{s.intro}

+ {current ? active.groups.map((group) => ( +

{s.placeholdersHeading}

+

{s.placeholdersIntro}

+ + + ); +} + function SystemTab({ t }: { t: Dictionary }) { const s = t.adminContentPage; const integrations = readIntegrationStatus(); diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 37aa3d8..e30dbf0 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -235,7 +235,7 @@ export const de: Dictionary = { { heading: "Umsatzsteuer-ID", paragraphs: [ - "Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: DE366479158", + "Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: {{USt-IdNr}}", ], }, { @@ -1496,6 +1496,9 @@ export const de: Dictionary = { sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.", sectionText: "Texte", sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.", + placeholdersHeading: "Textbausteine", + placeholdersIntro: + "Schreibe einen Baustein in einen beliebigen Text, und auf der Website erscheint an dieser Stelle der aktuelle Wert. Ändert sich der Wert, ziehen alle Texte automatisch mit.", groupBrand: "Logo und Favicon", groupCompany: "Firmendaten", groupShipping: "Versand", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 8b8d99f..a431fa1 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -235,7 +235,7 @@ export const en: Dictionary = { { heading: "VAT ID", paragraphs: [ - "VAT identification number pursuant to § 27a of the German VAT Act (UStG): DE366479158", + "VAT identification number pursuant to § 27a of the German VAT Act (UStG): {{USt-IdNr}}", ], }, { @@ -1470,6 +1470,9 @@ export const en: Dictionary = { sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.", sectionText: "Text", sectionTextIntro: "Every text field, grouped by section.", + placeholdersHeading: "Text placeholders", + placeholdersIntro: + "Write a placeholder into any text and the website shows the current value in its place. When the value changes, every text follows automatically.", groupBrand: "Logo and favicon", groupCompany: "Company details", groupShipping: "Shipping", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 012016c..d775fac 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -1032,6 +1032,8 @@ export interface Dictionary { sectionImagesIntro: string; sectionText: string; sectionTextIntro: string; + placeholdersHeading: string; + placeholdersIntro: string; groupBrand: string; groupCompany: string; groupShipping: string; diff --git a/src/lib/content-registry.ts b/src/lib/content-registry.ts index 3139c54..f25bfe3 100644 --- a/src/lib/content-registry.ts +++ b/src/lib/content-registry.ts @@ -70,7 +70,7 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [ { key: "company.location", group: "company", labelDe: "Standort (Anzeigetext)", labelEn: "Location (display text)", type: "text", localized: false, getDefault: () => siteConfig.location }, { key: "company.email", group: "company", labelDe: "E-Mail", labelEn: "Email", type: "text", localized: false, getDefault: () => siteConfig.email }, { key: "company.phone", group: "company", labelDe: "Telefon", labelEn: "Phone", type: "text", localized: false, getDefault: () => siteConfig.phone }, - { key: "company.vatId", group: "company", labelDe: "Umsatzsteuer-Identifikationsnummer (erscheint auf jeder Rechnung)", labelEn: "VAT identification number (shown on every invoice)", type: "text", localized: false, getDefault: () => siteConfig.vatId }, + { key: "company.vatId", group: "company", labelDe: "Umsatzsteuer-Identifikationsnummer (erscheint auf jeder Rechnung; in Texten per Baustein {{USt-IdNr}} einfügbar)", labelEn: "VAT identification number (shown on every invoice; insert into texts with the placeholder {{USt-IdNr}})", type: "text", localized: false, getDefault: () => siteConfig.vatId }, { key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street }, { key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip }, { key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city }, diff --git a/src/lib/content.ts b/src/lib/content.ts index 876b921..e48f2fd 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -1,27 +1,49 @@ import { prisma } from "@/lib/prisma"; import { CONTENT_REGISTRY, getContentFieldDef, type ContentFieldDef } from "@/lib/content-registry"; +import { PLACEHOLDER_SOURCE_KEYS, containsPlaceholder, resolvePlaceholders } from "@/lib/text-placeholders"; import type { Locale } from "@/i18n/config"; /** * Liefert den aktuell wirksamen Text fuer einen Registry-Schluessel — den * Admin-Override aus SiteContent, falls gepflegt, sonst den urspruenglichen - * Dictionary-/site-config-Text. Nie leer/undefined, solange der Schluessel in + * Dictionary-/site-config-Text, mit eingesetzten Textbausteinen + * (lib/text-placeholders.ts). Nie leer/undefined, solange der Schluessel in * der Registry existiert. */ export async function getContentValue(key: string, locale: Locale): Promise { - const def = getContentFieldDef(key); - if (!def) return ""; - - const row = await prisma.siteContent.findUnique({ where: { key } }); - if (row) { - const value = def.localized ? (locale === "de" ? row.valueDe : (row.valueEn ?? row.valueDe)) : row.valueDe; - if (value.trim() !== "") return value; - } - return def.getDefault(locale); + return (await getContentValues([key], locale))[key] ?? ""; } -/** Batch-Variante fuer Seiten, die mehrere Schluessel auf einmal brauchen (ein DB-Roundtrip statt vieler). */ +/** + * Batch-Variante fuer Seiten, die mehrere Schluessel auf einmal brauchen (ein + * DB-Roundtrip statt vieler). Enthaelt einer der Texte einen Textbaustein wie + * {{USt-IdNr}}, folgt genau eine weitere Abfrage fuer die Bausteinquellen — + * Texte ohne Baustein kosten nichts extra. + */ export async function getContentValues(keys: string[], locale: Locale): Promise> { + const raw = await readRawValues(keys, locale); + const needsPlaceholders = keys.some((key) => isPlaceholderTarget(key) && containsPlaceholder(raw[key])); + if (!needsPlaceholders) return raw; + + // Bausteinquellen sind nicht lokalisierte Firmendaten. + const sources = await readRawValues([...PLACEHOLDER_SOURCE_KEYS], "de"); + return Object.fromEntries( + keys.map((key) => [key, isPlaceholderTarget(key) ? resolvePlaceholders(raw[key], sources) : raw[key]]) + ); +} + +/** + * Bausteine gelten nur in Texten. Bild-, Video- und Auswahlfelder tragen + * Pfade bzw. feste Werte; die Bausteinquellen selbst werden nie aufgeloest, + * damit sich kein Baustein im Kreis auf sich selbst beziehen kann. + */ +function isPlaceholderTarget(key: string): boolean { + const def = getContentFieldDef(key); + if (!def || PLACEHOLDER_SOURCE_KEYS.includes(key)) return false; + return def.type !== "image" && def.type !== "video" && def.type !== "select"; +} + +async function readRawValues(keys: string[], locale: Locale): Promise> { const rows = await prisma.siteContent.findMany({ where: { key: { in: keys } } }); const rowsByKey = new Map(rows.map((r) => [r.key, r])); diff --git a/src/lib/text-placeholders.test.ts b/src/lib/text-placeholders.test.ts new file mode 100644 index 0000000..584ac22 --- /dev/null +++ b/src/lib/text-placeholders.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "vitest"; +import { TEXT_PLACEHOLDERS, containsPlaceholder, resolvePlaceholders } from "./text-placeholders"; + +const sources = { "company.vatId": "DE123456789" }; + +describe("resolvePlaceholders", () => { + test("setzt die USt-IdNr. fuer {{USt-IdNr}} ein", () => { + // Arrange + const text = "Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: {{USt-IdNr}}"; + + // Act + const result = resolvePlaceholders(text, sources); + + // Assert + expect(result).toBe("Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: DE123456789"); + }); + + test("erkennt den Baustein unabhaengig von Gross-/Kleinschreibung und Leerzeichen", () => { + expect(resolvePlaceholders("{{ ust-idnr }} / {{USTIDNR}}", sources)).toBe("DE123456789 / DE123456789"); + }); + + test("ersetzt jedes Vorkommen", () => { + expect(resolvePlaceholders("{{USt-IdNr}} und {{USt-IdNr}}", sources)).toBe("DE123456789 und DE123456789"); + }); + + test("laesst Texte ohne Baustein unveraendert", () => { + expect(resolvePlaceholders("Honig aus den Wäldern", sources)).toBe("Honig aus den Wäldern"); + }); + + test("setzt bei leerer Quelle einen leeren Text ein statt den Baustein stehen zu lassen", () => { + expect(resolvePlaceholders("USt-IdNr.: {{USt-IdNr}}", { "company.vatId": "" })).toBe("USt-IdNr.: "); + }); + + test("laesst unbekannte Bausteine unveraendert", () => { + expect(resolvePlaceholders("{{Unbekannt}}", sources)).toBe("{{Unbekannt}}"); + }); +}); + +describe("containsPlaceholder", () => { + test("meldet einen bekannten Baustein", () => { + expect(containsPlaceholder("Nr.: {{USt-IdNr}}")).toBe(true); + }); + + test("meldet keinen Baustein bei normalem Text oder unbekanntem Baustein", () => { + expect(containsPlaceholder("Nr.: DE123")).toBe(false); + expect(containsPlaceholder("{{Unbekannt}}")).toBe(false); + }); +}); + +describe("TEXT_PLACEHOLDERS", () => { + test("verweist nur auf Quellen, die selbst keinen Baustein aufloesen muessen", () => { + // Eine Quelle, die wiederum Bausteine enthaelt, koennte sich im Kreis aufloesen. + expect(TEXT_PLACEHOLDERS.map((placeholder) => placeholder.sourceKey)).toEqual(["company.vatId"]); + }); +}); diff --git a/src/lib/text-placeholders.ts b/src/lib/text-placeholders.ts new file mode 100644 index 0000000..d3c87de --- /dev/null +++ b/src/lib/text-placeholders.ts @@ -0,0 +1,51 @@ +/** + * Textbausteine fuer Admin-Texte (Admin > Einstellungen): wer in einem Text + * {{USt-IdNr}} schreibt, bekommt beim Ausgeben die im Admin gepflegte + * Umsatzsteuer-Identifikationsnummer eingesetzt. So steht die Nummer an genau + * einer Stelle, und Impressum, AGB oder jeder andere Text ziehen bei einer + * Aenderung automatisch mit. + * + * Aufgeloest wird zentral in lib/content.ts, also ueberall, wo Admin-Texte + * gelesen werden. Die Quellen sind bewusst Felder, die selbst NICHT + * aufgeloest werden — ein Baustein, der auf einen Text mit Baustein zeigt, + * koennte sich sonst im Kreis drehen. + */ +export interface TextPlaceholder { + /** So wird der Baustein im Admin angezeigt und in Texte geschrieben. */ + token: string; + /** Registry-Schluessel, dessen Wert eingesetzt wird. */ + sourceKey: string; + /** Erkennt den Baustein tolerant: Gross-/Kleinschreibung, Leerzeichen, Bindestrich. */ + pattern: RegExp; + labelDe: string; + labelEn: string; +} + +export const TEXT_PLACEHOLDERS: readonly TextPlaceholder[] = [ + { + token: "{{USt-IdNr}}", + sourceKey: "company.vatId", + pattern: /\{\{\s*ust-?idnr\s*\}\}/gi, + labelDe: "Umsatzsteuer-Identifikationsnummer (Firmendaten)", + labelEn: "VAT identification number (company details)", + }, +]; + +export const PLACEHOLDER_SOURCE_KEYS: readonly string[] = TEXT_PLACEHOLDERS.map((placeholder) => placeholder.sourceKey); + +export function containsPlaceholder(text: string): boolean { + return TEXT_PLACEHOLDERS.some((placeholder) => new RegExp(placeholder.pattern.source, "i").test(text)); +} + +/** + * Setzt alle bekannten Bausteine ein. Ist die Quelle leer, wird ein leerer + * Text eingesetzt — ein stehengebliebenes "{{USt-IdNr}}" auf der + * oeffentlichen Seite waere schlimmer. Unbekannte Bausteine bleiben + * unveraendert stehen, damit ein Tippfehler im Admin sichtbar bleibt. + */ +export function resolvePlaceholders(text: string, sources: Readonly>): string { + return TEXT_PLACEHOLDERS.reduce( + (result, placeholder) => result.replace(placeholder.pattern, () => (sources[placeholder.sourceKey] ?? "").trim()), + text + ); +}