Files
Wabenhain/src/components/admin-shell.tsx
T
Nick SzaunigandClaude Opus 5.5 de82ad0233
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
feat(admin): settings page with tabs, system checks and email test
"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]>
2026-09-27 00:07:34 +02:00

348 lines
13 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { Logomark } from "@/components/logomark";
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
import { useI18n } from "@/i18n/i18n-provider";
import type { Dictionary } from "@/i18n/dictionary-type";
import { ThemeToggle } from "@/components/theme-toggle";
import type { ThemeChoice } from "@/lib/theme";
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
export type AdminBadges = Partial<Record<string, number>>;
interface NavItem {
href: string;
label: string;
}
/**
* Gruppiert statt einer langen Liste: 13 Eintraege lesen sich in vier
* kurzen Bloecken schneller, und die taegliche Arbeit (Verkauf) steht oben.
*/
function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavItem[] }[] {
return [
{ label: null, items: [{ href: "/admin", label: t.adminShell.navDashboard }] },
{
label: t.adminShell.groupSales,
items: [
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
{ href: "/admin/retouren", label: t.adminShell.navReturns },
{ href: "/admin/widerrufe", label: t.adminShell.navWithdrawals },
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
{ href: "/admin/versand", label: t.adminShell.navShipping },
],
},
{
label: t.adminShell.groupRange,
items: [
{ href: "/admin/produkte", label: t.adminShell.navProducts },
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
{ href: "/admin/chargen", label: t.adminShell.navBatches },
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
],
},
{
label: t.adminShell.groupCustomers,
items: [
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
],
},
{
label: t.adminShell.groupSite,
items: [
{ href: "/admin/einstellungen", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
],
},
];
}
function getPaletteEntries(t: Dictionary): PaletteEntry[] {
const pages = getAdminNavGroups(t).flatMap((group) =>
group.items.map((item) => ({ ...item, section: "pages" as const }))
);
const actions: PaletteEntry[] = [
{ href: "/admin/produkte/neu", label: t.adminShell.actionNewProduct, section: "actions" },
{ href: "/admin/gutscheine/neu", label: t.adminShell.actionNewVoucher, section: "actions" },
{ href: "/admin/benutzer/neu", label: t.adminShell.actionNewUser, section: "actions" },
{ href: "/admin/versand/neu", label: t.adminShell.actionNewShippingTier, section: "actions" },
{ href: "/shop", label: t.adminShell.actionViewShop, section: "actions" },
];
return [...pages, ...actions];
}
function isActiveHref(pathname: string, href: string): boolean {
if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(`${href}/`);
}
function NavLinks({
pathname,
groups,
badges,
onNavigate,
waitingLabel,
}: {
pathname: string;
groups: ReturnType<typeof getAdminNavGroups>;
badges: AdminBadges;
onNavigate?: () => void;
waitingLabel: (count: number) => string;
}) {
return (
<>
{groups.map((group, groupIndex) => (
<div key={group.label ?? groupIndex} className={groupIndex > 0 ? "mt-5" : ""}>
{group.label ? (
<p className="px-5 pb-1.5 text-[10px] tracking-wide-caps text-cream/40">{group.label}</p>
) : null}
{group.items.map((item) => {
const active = isActiveHref(pathname, item.href);
const count = badges[item.href] ?? 0;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={`group relative flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
active ? "text-cream" : "text-cream/70 hover:text-cream"
}`}
>
{/* Aktive Seite als weich einblendende Flaeche, die beim
Wechsel zur neuen Seite hinueberwandert. */}
{active ? (
<motion.span
layoutId="admin-nav-active"
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-surface/10"
transition={{ type: "spring", stiffness: 420, damping: 36 }}
/>
) : null}
<HexMark
filled={active}
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
}`}
/>
<span className="flex-1">{item.label}</span>
{count > 0 ? (
<span
className="min-w-5 rounded-full bg-honey-deep px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
title={waitingLabel(count)}
>
<span aria-hidden="true">{count}</span>
<span className="sr-only">{waitingLabel(count)}</span>
</span>
) : null}
</Link>
);
})}
</div>
))}
</>
);
}
function Wordmark({ logoSrc }: { logoSrc: string | null }) {
return (
<Link href="/admin" className="flex items-center gap-2">
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" src={logoSrc} />
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
</Link>
);
}
function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
return (
<button
type="button"
onClick={onClick}
className="mx-3 flex w-[calc(100%-1.5rem)] items-center gap-2 border border-cream/15 px-3 py-2 text-left text-xs text-cream/60 transition-colors hover:border-cream/35 hover:text-cream"
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
<span className="flex-1">{label}</span>
<kbd className="border border-cream/20 px-1 text-[10px] text-cream/50">{shortcut}</kbd>
</button>
);
}
interface AdminShellProps {
adminName: string;
/** Gewaehlte Darstellung (Cookie), siehe lib/theme.ts. */
themeChoice?: ThemeChoice;
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
logoSrc?: string | null;
badges?: AdminBadges;
logoutAction: () => void | Promise<void>;
children: ReactNode;
}
/**
* Eigener Arbeitsbereich fuer die Imkerei: gruppierte Seitenleiste mit
* Zaehlern fuer alles, was gerade wartet, und einer Schnellsuche (Strg/Cmd+K).
* Ab lg persistente Seitenleiste links, darunter ein Vollbild-Menue nach
* demselben Muster wie die mobile Hauptnavigation in site-chrome.tsx. Die
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
* (components/hide-on-admin.tsx).
*/
export function AdminShell({
adminName,
logoSrc = null,
themeChoice = "system",
badges = {},
logoutAction,
children,
}: AdminShellProps) {
const { t } = useI18n();
const groups = getAdminNavGroups(t);
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const paletteLabels = {
placeholder: t.adminShell.palettePlaceholder,
noResults: t.adminShell.paletteNoResults,
pages: t.adminShell.paletteSectionPages,
actions: t.adminShell.paletteSectionActions,
dialog: t.adminShell.paletteOpen,
};
const footer = (
<div className="px-5 py-4 border-t border-cream/10">
{/* Der Shop-Kopf ist im Admin ausgeblendet (HideOnAdmin) — ohne diesen
Link fuehrte kein Weg zurueck auf die Website. */}
<div className="mb-4 flex items-center justify-between gap-3">
<Link
href="/"
className="group inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
className="transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:-translate-x-0.5 motion-reduce:transition-none"
>
<path d="M15 6l-6 6 6 6" />
</svg>
<span className="link-draw">{t.adminShell.backToWebsite}</span>
</Link>
<ThemeToggle initial={themeChoice} className="-mr-2 text-cream/70 hover:text-cream" />
</div>
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
<form action={logoutAction} className="mt-2">
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
{t.adminShell.logout}
</button>
</form>
</div>
);
return (
<div className="min-h-screen bg-surface/40 lg:flex">
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
<div className="flex items-center h-16 px-5 border-b border-cream/10">
<Wordmark logoSrc={logoSrc} />
</div>
<div className="pt-4">
<PaletteTrigger
onClick={() => setPaletteOpen(true)}
label={t.adminShell.paletteOpen}
shortcut={t.adminShell.paletteShortcut}
/>
</div>
<nav aria-label={t.adminShell.navAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
<NavLinks pathname={pathname} groups={groups} badges={badges} waitingLabel={t.adminShell.badgeWaiting} />
</nav>
{footer}
</aside>
<div className="flex-1 min-w-0">
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
<Wordmark logoSrc={logoSrc} />
<div className="flex items-center gap-1">
<button
aria-label={t.adminShell.paletteOpen}
className="w-10 h-10 flex items-center justify-center"
onClick={() => setPaletteOpen(true)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</svg>
</button>
<button
aria-label={t.adminShell.openMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
onClick={() => setMobileOpen(true)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
</div>
</header>
{/* Kein zweites <main>: das Root-Layout stellt bereits eines. */}
<div className="admin-content max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">{children}</div>
</div>
<AnimatePresence>
{mobileOpen ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
>
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
<Wordmark logoSrc={logoSrc} />
<button
aria-label={t.adminShell.closeMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
onClick={() => setMobileOpen(false)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
<NavLinks
pathname={pathname}
groups={groups}
badges={badges}
waitingLabel={t.adminShell.badgeWaiting}
onNavigate={() => setMobileOpen(false)}
/>
</nav>
{footer}
</motion.div>
) : null}
</AnimatePresence>
<AdminCommandPalette
entries={getPaletteEntries(t)}
labels={paletteLabels}
open={paletteOpen}
onOpenChange={setPaletteOpen}
/>
</div>
);
}