Files
Wabenhain/src/app/admin/einstellungen/page.tsx
T
Nick SzaunigandClaude Opus 5.5 f71bcb2b67
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
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) <[email protected]>
2026-10-01 00:59:04 +02:00

169 lines
6.8 KiB
TypeScript

import Link from "next/link";
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";
/**
* Alle Einstellungen des Shops an einem Ort, nach Bereichen in Reiter
* gegliedert (lib/settings-tabs.ts). Die Reiter sind echte Links
* (?reiter=…): direkt aufrufbar, als Lesezeichen speicherbar und ohne
* JavaScript bedienbar. Frueher getrennt: /admin/inhalte und
* /admin/e-mail-test — beide leiten hierher um.
*/
export default async function AdminEinstellungenPage(props: PageProps<"/admin/einstellungen">) {
const locale = await getLocale();
const t = getDictionary(locale);
const s = t.adminContentPage;
const { reiter } = await props.searchParams;
const activeId = parseSettingsTab(reiter);
const active = SETTINGS_TABS.find((tab) => tab.id === activeId) ?? SETTINGS_TABS[0];
const tabLabels: Record<SettingsTabId, string> = {
firma: s.tabCompany,
marke: s.tabBrand,
startseite: s.tabHome,
seiten: s.tabPages,
social: s.tabSocial,
recht: s.tabLegal,
system: s.tabSystem,
};
const groupHeadings: Record<ContentFieldDef["group"], string> = {
company: s.groupCompany,
shipping: s.groupShipping,
brand: s.groupBrand,
home: s.groupHome,
pages: s.groupPages,
social: s.groupSocial,
legal: s.groupLegal,
};
const current = active.groups.length > 0 ? await loadCurrentValues() : null;
return (
<div>
<BackLink href="/admin" label={s.backToDashboard} />
<h1 className="mb-6 text-2xl text-heading">{s.title}</h1>
<nav aria-label={s.tabsLabel} className="-mx-1 mb-8 overflow-x-auto border-b border-ink/10">
<ul className="flex min-w-max gap-1 px-1">
{SETTINGS_TABS.map((tab) => {
const isActive = tab.id === active.id;
return (
<li key={tab.id}>
<Link
href={`${SETTINGS_PATH}?reiter=${tab.id}`}
aria-current={isActive ? "page" : undefined}
className={`-mb-px block border-b-2 px-4 py-3 text-sm transition-colors ${
isActive ? "border-honey text-heading" : "border-transparent text-ink/65 hover:border-ink/20 hover:text-heading"
}`}
>
{tabLabels[tab.id]}
</Link>
</li>
);
})}
</ul>
</nav>
{active.id === "system" ? (
<SystemTab t={t} />
) : (
<>
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
<PlaceholderHint t={t} locale={locale} />
{current
? active.groups.map((group) => (
<GroupForm
key={group}
groupHeading={groupHeadings[group]}
groupKeyValue={group}
fields={fieldsForGroup(group)}
current={current}
locale={locale}
t={t}
/>
))
: null}
</>
)}
</div>
);
}
/**
* 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 }) {
const s = t.adminContentPage;
const integrations = readIntegrationStatus();
const warnings = { "stripe-test-mode": s.warningStripeTest, "stripe-mixed-mode": s.warningStripeMixed } as const;
return (
<div className="space-y-12">
<p className="max-w-2xl text-sm text-ink/65">{s.systemIntro}</p>
<section aria-labelledby="settings-integrations">
<h2 id="settings-integrations" className="text-xl text-heading">
{s.integrationsHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{s.integrationsIntro}</p>
<ul className="max-w-3xl divide-y divide-ink/10 border-y border-ink/10">
{integrations.map((integration) => (
<li key={integration.key} className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 py-4">
<div className="min-w-0">
<p className="text-heading">{s.integrationNames[integration.key]}</p>
<p className="mt-0.5 text-xs text-ink/60">{integration.required ? s.statusRequired : s.statusOptional}</p>
{integration.warning ? (
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{warnings[integration.warning]}</p>
) : null}
{!integration.configured ? (
<p className="mt-2 break-words font-mono text-xs text-ink/70">{s.statusMissing(integration.missing.join(", "))}</p>
) : null}
</div>
<AdminBadge tone={integration.configured ? (integration.warning ? "danger" : "positive") : integration.required ? "danger" : "neutral"}>
{integration.configured ? s.statusConfigured : s.statusNotConfigured}
</AdminBadge>
</li>
))}
</ul>
</section>
<section aria-labelledby="settings-email-test">
<h2 id="settings-email-test" className="text-xl text-heading">
{s.emailTestHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{t.adminEmailTestPage.intro}</p>
<SmtpTestPanel />
</section>
</div>
);
}