From f71bcb2b6723764af99717c250db47fe76b6c656 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 00:59:04 +0200 Subject: [PATCH] feat(inhalte): text placeholder {{USt-IdNr}} for the VAT ID Admin texts can now contain the placeholder {{USt-IdNr}}. Wherever content is read (lib/content.ts), it is replaced with the VAT ID maintained under Admin > Einstellungen > Firmendaten, so every text follows a change automatically. The placeholder is matched case- and space-insensitively; an empty VAT ID inserts nothing, and unknown placeholders stay visible so typos show up. Images, videos, select fields and the source field itself are never rewritten. The default imprint text uses the placeholder instead of a hard-coded number, and the settings page lists the available placeholders. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/einstellungen/page.tsx | 26 +++++++++++++ src/i18n/dictionaries/de.ts | 5 ++- src/i18n/dictionaries/en.ts | 5 ++- src/i18n/dictionary-type.ts | 2 + src/lib/content-registry.ts | 2 +- src/lib/content.ts | 44 ++++++++++++++++------ src/lib/text-placeholders.test.ts | 55 ++++++++++++++++++++++++++++ src/lib/text-placeholders.ts | 51 ++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 src/lib/text-placeholders.test.ts create mode 100644 src/lib/text-placeholders.ts 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}

+
    + {TEXT_PLACEHOLDERS.map((placeholder) => ( +
  • + {placeholder.token} + {locale === "de" ? placeholder.labelDe : placeholder.labelEn} +
  • + ))} +
+ + ); +} + 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 + ); +}