feat: redesign CMS and unify German admin UI
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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." });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user