feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each area stands on its own: Firma & Versand, Marke, Startseite, Seiten, Social Media, Rechtstexte and System & Tests. Tabs are plain links (?reiter=...), bookmarkable and usable without JavaScript; saving returns to the same tab. System & Tests brings everything that checks the running shop into one place: - integration overview (Stripe, Stripe webhook, Microsoft 365 mail, Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only whether each is set up and the names of missing variables, never a value; warns when Stripe runs on test keys or mixes test/live - the email test, now with the IPv4 network checks for smtp.office365.com:587 and login.microsoftonline.com:443 /admin/inhalte and /admin/e-mail-test redirect to the new page; the separate "E-Mail-Test" menu entry is gone. The tab parameter and the saved group are validated against a fixed list before they are used in a redirect. Docs point to the new location and cover the ENETUNREACH (IPv6) case. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
b5311fae83
commit
de82ad0233
17 files changed
+654
-288
No files matched your search
@@ -0,0 +1,142 @@
|
||||
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 { 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 { 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>
|
||||
{current
|
||||
? active.groups.map((group) => (
|
||||
<GroupForm
|
||||
key={group}
|
||||
groupHeading={groupHeadings[group]}
|
||||
groupKeyValue={group}
|
||||
fields={fieldsForGroup(group)}
|
||||
current={current}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user