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
+1 -1
View File
@@ -22,7 +22,7 @@ const buttonBase =
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
* every client-side route change. Both only ever act through track()/
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
* setAnalyticsConfig() from lib/analytics/client.ts - no network call is
* possible from this component before a visitor has actually consented.
*/
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
+1 -1
View File
@@ -1,4 +1,4 @@
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual — animates on the parent's `group-hover`. */
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual - animates on the parent's `group-hover`. */
export function ArrowIcon({ className = "" }: { className?: string }) {
return (
<svg
+1 -1
View File
@@ -27,7 +27,7 @@ type ArrowLinkProps = {
/**
* The site's one secondary-action pattern: a text link with a small arrow
* that nudges forward on hover. Used instead of a pill button wherever the
* action isn't the single primary one on screen — keeps most CTAs feeling
* action isn't the single primary one on screen - keeps most CTAs feeling
* like an invitation to read on rather than another button to click.
*/
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
// tone the button appears on — see components/Section.tsx for the tones.
// tone the button appears on - see components/Section.tsx for the tones.
const variants: Record<Variant, string> = {
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
secondary:
+2 -2
View File
@@ -12,11 +12,11 @@ export async function Footer() {
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.08em]">
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.
Räume für Verbindung - mit dir selbst, miteinander und mit der Natur.
</p>
</div>
+2 -2
View File
@@ -3,7 +3,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea
/**
* The one field style used by every form on the site (previously each form
* declared its own near-identical `fieldClass` constant with small drifts in
* padding/size/background — see ContactForm, auth/*, meeting/* history).
* padding/size/background - see ContactForm, auth/*, meeting/* history).
* Functionality is untouched: both components just spread the native
* input/textarea props through, so controlled and uncontrolled forms,
* validation attributes, autoComplete, etc. all keep working exactly as
@@ -23,7 +23,7 @@ function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
id: string;
/** Keeps the label for screen readers only — for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
/** Keeps the label for screen readers only - for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
hideLabel?: boolean;
wrapperClassName?: string;
};
+1 -1
View File
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma – warme, naturverbundene Begleitung"
label="Anouma - warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ImageMood } from "@/lib/angebote";
/**
* Renders a photo when `src` is provided, otherwise falls back to a warm,
* organic gradient placeholder in the given mood. Pages should always pass
* `src` once real photography exists — no other prop changes are needed.
* `src` once real photography exists - no other prop changes are needed.
*/
const moodGradients: Record<ImageMood, string> = {
+1 -1
View File
@@ -69,7 +69,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
className="font-antropos text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
>
{siteConfig.name.toUpperCase()}
</Link>
+1 -1
View File
@@ -16,7 +16,7 @@ type PageHeaderProps = {
compact?: boolean;
};
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` - every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({
eyebrow,
title,
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter – noch zu ergänzen:</span> {children}
<span className="font-medium">Platzhalter - noch zu ergänzen:</span> {children}
</div>
);
}
+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>
);
}
+3 -3
View File
@@ -41,7 +41,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<div>
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
<p className="mt-1 text-sm text-anouma-plum/80">
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} - {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<BookingStatusBadge status={displayStatus} />
@@ -57,7 +57,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
<p className="mt-1 text-base text-anouma-plum">
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} -{" "}
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
</p>
<div className="mt-3 flex flex-wrap gap-3">
@@ -95,7 +95,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
<p className="text-sm text-anouma-plum/80">Vor Ort - Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
+1 -1
View File
@@ -119,7 +119,7 @@ export function BookingWidget({
return;
}
setResult({ ok: true });
// Event only — never the offer/date/message content.
// Event only - never the offer/date/message content.
track("booking_started");
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
+2 -2
View File
@@ -9,7 +9,7 @@ const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
// A stable per-browser identity so a host's "kick" survives the removed
// participant simply reconnecting (which always gets a fresh participantId
// — see lib/meeting/token.ts). Deliberately in localStorage, not
// - see lib/meeting/token.ts). Deliberately in localStorage, not
// sessionStorage: it must outlive the "kicked" screen clearing the session
// and the "Erneut beitreten" link going back through this form again.
function getOrCreateClientId(): string {
@@ -20,7 +20,7 @@ function getOrCreateClientId(): string {
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
return created;
} catch {
// Storage unavailable (private browsing, etc.) — fall back to a
// Storage unavailable (private browsing, etc.) - fall back to a
// one-off id; kicks just won't stick across reconnects in that case.
return crypto.randomUUID();
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function RegisterForm({ slug }: { slug: string }) {
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung – wir freuen uns auf dich!
Danke für deine Anmeldung - wir freuen uns auf dich!
</p>
);
}