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
+ );
+}