feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
+14 -14
View File
@@ -7,7 +7,7 @@ import { analyticsEventLabel } from "@/lib/analytics/events";
import { AnalyticsChart } from "./AnalyticsChart";
import styles from "./AdminAnalyticsView.module.css";
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
const DEVICE_LABELS: Record<string, string> = { mobile: "Handy", desktop: "Computer", tablet: "Tablet" };
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
function fmtInt(n: number): string {
@@ -73,7 +73,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
if (!canViewAnalytics({ req })) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<h1 className={styles.title}>Auswertung</h1>
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
</div>
);
@@ -102,8 +102,8 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<h1 className={styles.title}>Auswertung</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung ohne personenbezogene Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<div className={styles.filterBar}>
{RANGE_OPTIONS.map((option) => (
@@ -132,12 +132,12 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Auswertungs-Einstellungen“).
</p>
)}
<div className={styles.tiles}>
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
<Tile label="Besuche" value={current.sessions} delta={delta("sessions")} />
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
@@ -146,7 +146,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
</div>
<div className={styles.chartCard}>
<h2 className={styles.sectionTitle}>Seitenaufrufe &amp; Sessions</h2>
<h2 className={styles.sectionTitle}>Seitenaufrufe und Besuche</h2>
<AnalyticsChart series={current.series} />
</div>
@@ -155,7 +155,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
<EntryTable
title="Events"
title="Ereignisse"
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
/>
<EntryTable
@@ -169,25 +169,25 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
</div>
<div className={styles.privacyPanel}>
<h3>Analytics &amp; Datenschutz</h3>
<h3>Auswertung und Datenschutz</h3>
<p>Erhobene Daten:</p>
<ul>
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
<li>Seitenaufrufe und Ereignisse (siehe Tabelle „Ereignisse“)</li>
<li>Gerätetyp (Handy/Computer/Tablet)</li>
<li>Browser-Familie</li>
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
</ul>
<p>
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
dauerhaften individuellen Nutzerprofile — Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
dauerhaften individuellen Nutzerprofile - Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
gruppiert.
</p>
<p>
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
aggregierte Tagesstatistiken (konfigurierbar unter „Auswertungs-Einstellungen“).
</p>
<p style={{ fontStyle: "italic", color: "#777" }}>
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels — bitte im
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels - bitte im
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
</p>
</div>
+2 -2
View File
@@ -71,7 +71,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
// Local API (overrideAccess: true by default) so this can see
// calendarFeedTokenHash despite its field-level access being locked to
// "nobody" for every real API path — see collections/Users.ts.
// "nobody" for every real API path - see collections/Users.ts.
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
const itemsByDay = new Map<string, AdminCalendarItem[]>();
@@ -181,7 +181,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
{fmtTime(entry.startTime)}-{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
+2 -2
View File
@@ -21,7 +21,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
setError(data.error || "Der Videoanruf konnte nicht gestartet werden.");
return;
}
setUrl(data.testUrl);
@@ -46,7 +46,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
disabled={loading}
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
</button>
{url && !copied && (
<input
+6 -17
View File
@@ -19,7 +19,7 @@ export function AdminTestCallButton() {
});
const data = (await response.json().catch(() => ({}))) as { testUrl?: string; error?: string };
if (!response.ok || !data.testUrl) {
setError(data.error || "Der Test-Call konnte nicht gestartet werden.");
setError(data.error || "Der Test-Videoanruf konnte nicht gestartet werden.");
return;
}
@@ -38,25 +38,14 @@ export function AdminTestCallButton() {
}
return (
<div style={{ padding: "0 8px 8px" }}>
<div className="anouma-custom-nav-action">
<button
type="button"
onClick={() => void createTestCall()}
disabled={loading}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: "#8b616d",
border: "1px solid #8b616d",
cursor: loading ? "wait" : "pointer",
opacity: loading ? 0.7 : 1,
}}
className="anouma-custom-nav-button"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "✓ Test-Link kopiert" : "▶ Test-Videoanruf"}
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
</button>
{url && (
<input
@@ -65,10 +54,10 @@ export function AdminTestCallButton() {
value={url}
aria-label="Test-Videoanruf-Link"
onFocus={(event) => event.currentTarget.select()}
style={{ width: "100%", marginTop: 6, padding: "5px 7px", border: "1px solid #e2e2e2", borderRadius: 5, fontSize: 11 }}
className="anouma-custom-nav-input"
/>
)}
{error && <p style={{ margin: "6px 0 0", color: "#a33", fontSize: 11 }}>{error}</p>}
{error && <p className="anouma-custom-nav-error">{error}</p>}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
const links = [
{ href: "/admin/anfragen", kicker: "Kommunikation", title: "Anfragen", text: "Kontakt aufnehmen, antworten und E-Mail-Verläufe bearbeiten." },
{ href: "/admin/kalender", kicker: "Planung", title: "Kalender", text: "Freie Zeiten, Buchungen und Veranstaltungen im Blick behalten." },
{ href: "/admin/collections/offers", kicker: "Website", title: "Angebote", text: "Angebote, Gruppen und Buchungsarten pflegen." },
{ href: "/admin/collections/events", kicker: "Termine", title: "Veranstaltungen", text: "Termine anlegen, bearbeiten und Anmeldungen verwalten." },
];
export function AdminWelcome() {
return (
<section className="anouma-admin-welcome" aria-labelledby="anouma-admin-welcome-title">
<div className="anouma-admin-welcome__intro">
<p className="anouma-admin-eyebrow">Anouma Verwaltung</p>
<h1 id="anouma-admin-welcome-title">Was möchtest du heute erledigen?</h1>
<p>Hier findest du die wichtigsten Bereiche für Website, Anfragen und Termine an einem Ort.</p>
</div>
<div className="anouma-admin-quicklinks">
{links.map((link, index) => (
<Link key={link.href} href={link.href} className="anouma-admin-quicklink">
<span className="anouma-admin-quicklink__number">0{index + 1}</span>
<span className="anouma-admin-quicklink__kicker">{link.kicker}</span>
<strong>{link.title}</strong>
<span>{link.text}</span>
</Link>
))}
</div>
</section>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
labelFormatter={(label) => `Tag: ${label}`}
/>
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Besuche" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
+5 -16
View File
@@ -3,27 +3,16 @@ import type { ServerProps } from "payload";
import { canViewAnalytics } from "@/access";
export function AnalyticsNavLink({ user }: ServerProps) {
// Payload's custom views/nav links have no declarative `access` option —
// Payload's custom views/nav links have no declarative `access` option -
// this component must gate itself (see also AdminAnalyticsView.tsx, which
// re-checks the same thing for direct navigation to /admin/analytics).
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
return (
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/analytics"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📊 Analytics
<div className="anouma-custom-nav-item">
<Link href="/admin/analytics" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">03</span>
<span>Auswertung</span>
</Link>
</div>
);
+3 -3
View File
@@ -36,7 +36,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
// Clipboard API can be unavailable (e.g. insecure context) — the
// Clipboard API can be unavailable (e.g. insecure context) - the
// input is still selectable/copyable by hand in that case.
}
}
@@ -46,7 +46,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
<h2 className={styles.heading}>Kalender synchronisieren</h2>
<p className={styles.hint}>
Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken — teile ihn nicht.
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken - teile ihn nicht.
</p>
{!url && (
@@ -65,7 +65,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
{url && (
<div className={styles.result}>
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt — bitte gleich kopieren.</p>
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt - bitte gleich kopieren.</p>
<div className={styles.urlRow}>
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
<button type="button" className={styles.secondaryButton} onClick={copy}>
+4 -15
View File
@@ -4,21 +4,10 @@ import { AdminTestCallButton } from "./AdminTestCallButton";
export function CalendarNavLink() {
return (
<>
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/kalender"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📅 Kalender
<div className="anouma-custom-nav-item">
<Link href="/admin/kalender" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">02</span>
<span>Kalender</span>
</Link>
</div>
<AdminTestCallButton />
+8 -1
View File
@@ -1,5 +1,12 @@
import Link from "next/link";
export function ConversationsNavLink() {
return <div style={{ padding: "0 8px 8px" }}><Link href="/admin/anfragen" style={{ display: "block", padding: "8px 12px", borderRadius: 6, fontSize: 13, fontWeight: 600, color: "#8b616d", textDecoration: "none", border: "1px solid #e2e2e2" }}>💬 Anfragen</Link></div>;
return (
<div className="anouma-custom-nav-item">
<Link href="/admin/anfragen" className="anouma-custom-nav-link">
<span className="anouma-custom-nav-link__icon">01</span>
<span>Anfragen</span>
</Link>
</div>
);
}