feat(inhalte): text placeholder {{USt-IdNr}} for the VAT ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
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) <[email protected]>
This commit is contained in:
1 parent
77bdd9958c
commit
f71bcb2b67
8 files changed
+176
-14
No files matched your search
@@ -3,10 +3,12 @@ import { BackLink } from "@/components/back-link";
|
|||||||
import { AdminBadge } from "@/components/admin-list";
|
import { AdminBadge } from "@/components/admin-list";
|
||||||
import type { ContentFieldDef } from "@/lib/content-registry";
|
import type { ContentFieldDef } from "@/lib/content-registry";
|
||||||
import { readIntegrationStatus } from "@/lib/integration-status";
|
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 { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
import type { Locale } from "@/i18n/config";
|
||||||
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
|
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
|
||||||
import { SmtpTestPanel } from "./smtp-test-panel";
|
import { SmtpTestPanel } from "./smtp-test-panel";
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export default async function AdminEinstellungenPage(props: PageProps<"/admin/ei
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
|
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
|
||||||
|
<PlaceholderHint t={t} locale={locale} />
|
||||||
{current
|
{current
|
||||||
? active.groups.map((group) => (
|
? active.groups.map((group) => (
|
||||||
<GroupForm
|
<GroupForm
|
||||||
@@ -95,6 +98,29 @@ export default async function AdminEinstellungenPage(props: PageProps<"/admin/ei
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersicht der Textbausteine (lib/text-placeholders.ts) ueber den
|
||||||
|
* Formularen: welche es gibt und was sie einsetzen. Der Baustein ist per
|
||||||
|
* select-all markierbar, damit er sich mit einem Klick kopieren laesst.
|
||||||
|
*/
|
||||||
|
function PlaceholderHint({ t, locale }: { t: Dictionary; locale: Locale }) {
|
||||||
|
const s = t.adminContentPage;
|
||||||
|
return (
|
||||||
|
<aside className="mb-10 max-w-2xl border-l-[3px] border-honey bg-surface px-5 py-4 text-sm">
|
||||||
|
<h2 className="text-heading">{s.placeholdersHeading}</h2>
|
||||||
|
<p className="mt-1 text-ink/65">{s.placeholdersIntro}</p>
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{TEXT_PLACEHOLDERS.map((placeholder) => (
|
||||||
|
<li key={placeholder.token} className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<code className="select-all rounded bg-background px-1.5 py-0.5 font-mono text-heading">{placeholder.token}</code>
|
||||||
|
<span className="text-ink/70">{locale === "de" ? placeholder.labelDe : placeholder.labelEn}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SystemTab({ t }: { t: Dictionary }) {
|
function SystemTab({ t }: { t: Dictionary }) {
|
||||||
const s = t.adminContentPage;
|
const s = t.adminContentPage;
|
||||||
const integrations = readIntegrationStatus();
|
const integrations = readIntegrationStatus();
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const de: Dictionary = {
|
|||||||
{
|
{
|
||||||
heading: "Umsatzsteuer-ID",
|
heading: "Umsatzsteuer-ID",
|
||||||
paragraphs: [
|
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.",
|
sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.",
|
||||||
sectionText: "Texte",
|
sectionText: "Texte",
|
||||||
sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.",
|
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",
|
groupBrand: "Logo und Favicon",
|
||||||
groupCompany: "Firmendaten",
|
groupCompany: "Firmendaten",
|
||||||
groupShipping: "Versand",
|
groupShipping: "Versand",
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const en: Dictionary = {
|
|||||||
{
|
{
|
||||||
heading: "VAT ID",
|
heading: "VAT ID",
|
||||||
paragraphs: [
|
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.",
|
sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.",
|
||||||
sectionText: "Text",
|
sectionText: "Text",
|
||||||
sectionTextIntro: "Every text field, grouped by section.",
|
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",
|
groupBrand: "Logo and favicon",
|
||||||
groupCompany: "Company details",
|
groupCompany: "Company details",
|
||||||
groupShipping: "Shipping",
|
groupShipping: "Shipping",
|
||||||
|
|||||||
@@ -1032,6 +1032,8 @@ export interface Dictionary {
|
|||||||
sectionImagesIntro: string;
|
sectionImagesIntro: string;
|
||||||
sectionText: string;
|
sectionText: string;
|
||||||
sectionTextIntro: string;
|
sectionTextIntro: string;
|
||||||
|
placeholdersHeading: string;
|
||||||
|
placeholdersIntro: string;
|
||||||
groupBrand: string;
|
groupBrand: string;
|
||||||
groupCompany: string;
|
groupCompany: string;
|
||||||
groupShipping: string;
|
groupShipping: string;
|
||||||
|
|||||||
@@ -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.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.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.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.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.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 },
|
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
|
||||||
|
|||||||
+33
-11
@@ -1,27 +1,49 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { CONTENT_REGISTRY, getContentFieldDef, type ContentFieldDef } from "@/lib/content-registry";
|
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";
|
import type { Locale } from "@/i18n/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den aktuell wirksamen Text fuer einen Registry-Schluessel — den
|
* Liefert den aktuell wirksamen Text fuer einen Registry-Schluessel — den
|
||||||
* Admin-Override aus SiteContent, falls gepflegt, sonst den urspruenglichen
|
* 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.
|
* der Registry existiert.
|
||||||
*/
|
*/
|
||||||
export async function getContentValue(key: string, locale: Locale): Promise<string> {
|
export async function getContentValue(key: string, locale: Locale): Promise<string> {
|
||||||
const def = getContentFieldDef(key);
|
return (await getContentValues([key], locale))[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<Record<string, string>> {
|
export async function getContentValues(keys: string[], locale: Locale): Promise<Record<string, string>> {
|
||||||
|
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<Record<string, string>> {
|
||||||
const rows = await prisma.siteContent.findMany({ where: { key: { in: keys } } });
|
const rows = await prisma.siteContent.findMany({ where: { key: { in: keys } } });
|
||||||
const rowsByKey = new Map(rows.map((r) => [r.key, r]));
|
const rowsByKey = new Map(rows.map((r) => [r.key, r]));
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>>): string {
|
||||||
|
return TEXT_PLACEHOLDERS.reduce(
|
||||||
|
(result, placeholder) => result.replace(placeholder.pattern, () => (sources[placeholder.sourceKey] ?? "").trim()),
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user