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
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { Inter } from "next/font/google";
import "../(frontend)/globals.css";
// Its own root layout — the video-call UI is intentionally full-bleed and
// Its own root layout - the video-call UI is intentionally full-bleed and
// dark, without the marketing navbar/footer around it.
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
+1 -1
View File
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: post.seo,
fallbackTitle: `${post.title} – ${siteConfig.name}`,
fallbackTitle: `${post.title} - ${siteConfig.name}`,
fallbackDescription: () => post.teaser,
fallbackImage: post.coverImage,
defaultOgImage: seoSettings.defaultOgImage,
+2 -2
View File
@@ -45,7 +45,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: offer.seo,
fallbackTitle: `${offer.title} – ${siteConfig.name}`,
fallbackTitle: `${offer.title} - ${siteConfig.name}`,
fallbackDescription: () => offer.shortDescription,
fallbackImage: offer.image,
defaultOgImage: seoSettings.defaultOgImage,
@@ -133,7 +133,7 @@ export default async function OfferDetailPage({ params }: Args) {
{offer.groupSchedule && (
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
<p className="font-medium">Fester Termin</p>
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `–${offer.groupSchedule.endTime}` : ""}</p>
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `-${offer.groupSchedule.endTime}` : ""}</p>
</div>
)}
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
+3 -3
View File
@@ -19,13 +19,13 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – die Angebote von Anouma im Überblick.";
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> {
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: intro.seo,
fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`,
fallbackTitle: `Begleitung für dich und deine Familie - ${siteConfig.name}`,
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch – gemeinsam schauen wir, was dein Anliegen gerade braucht."
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch - gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
+3 -3
View File
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({
title: `Singkreise – ${siteConfig.name}`,
title: `Singkreise - ${siteConfig.name}`,
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
path: "/angebote/singkreise",
});
@@ -30,7 +30,7 @@ export default async function SingkreisePage() {
<>
<PageHeader
eyebrow="Singkreise"
title="Singkreise – miteinander singen, klingen und sein"
title="Singkreise - miteinander singen, klingen und sein"
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
/>
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby – melde dich, wenn du einen Kreis besuchen möchtest."
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby - melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
+1 -1
View File
@@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
type Args = { params: Promise<{ tokenFile: string }> };
/**
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
* GET /calendar/<token>.ics - a private iCalendar feed, subscribable from
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
* itself is the only credential (there's no login here), so it's treated
* like a secret: looked up by hash only, never logged, and the response is
+3 -3
View File
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter – Vor- und Nachname]
[Platzhalter - Vor- und Nachname]
<br />
[Platzhalter – Anschrift]
[Platzhalter - Anschrift]
<br />
E-Mail: {contact.email}
</p>
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.]
Zugriffs). [Platzhalter - Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
+5 -1
View File
@@ -2,7 +2,7 @@
@font-face {
font-family: "Antropos";
src: url("/fonts/antropos.ttf") format("truetype");
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
@@ -80,6 +80,10 @@ body {
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
}
.font-antropos {
font-family: "Antropos", ui-serif, Georgia, serif;
}
::selection {
background: var(--color-anouma-rose-soft);
color: var(--color-anouma-plum);
+2 -2
View File
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma – Natur, Gemeinschaft und gemeinsame Räume.";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma - Natur, Gemeinschaft und gemeinsame Räume.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
@@ -36,7 +36,7 @@ export default async function ImpressionenPage() {
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt – echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
lead="Diese Galerie ist als Platzhalter-System angelegt - echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
+6 -6
View File
@@ -12,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
// noindex, but still crawlable — legal boilerplate deliberately kept
// noindex, but still crawlable - legal boilerplate deliberately kept
// out of search results without cutting off the internal links on it.
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/impressum` },
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter – Vor- und Nachname]
[Platzhalter - Vor- und Nachname]
<br />
[Platzhalter – Straße und Hausnummer]
[Platzhalter - Straße und Hausnummer]
<br />
[Platzhalter – PLZ und Ort]
[Platzhalter - PLZ und Ort]
</p>
</div>
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
[Platzhalter - sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
[Platzhalter - Vor- und Nachname, Anschrift wie oben]
</p>
</div>
+1 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: contact.seo,
fallbackTitle: `Kontakt – ${siteConfig.name}`,
fallbackTitle: `Kontakt - ${siteConfig.name}`,
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
// Applies to every /konto/* page at once — private account area, never indexable.
// Applies to every /konto/* page at once - private account area, never indexable.
export const metadata: Metadata = { robots: { index: false, follow: false } };
const navItems = [
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
E-Mail: {customer.email} - für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
+2 -2
View File
@@ -31,7 +31,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s – ${siteConfig.name}`,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
@@ -47,7 +47,7 @@ export const metadata: Metadata = {
title: siteConfig.title,
description: siteConfig.description,
},
// Configurable via env only — never a hardcoded verification ID (see .env.example).
// Configurable via env only - never a hardcoded verification ID (see .env.example).
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
};
+7 -7
View File
@@ -27,11 +27,11 @@ export async function generateMetadata(): Promise<Metadata> {
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: home.seo,
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`,
fallbackDescription: () =>
home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -42,8 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
// Homepage bypasses the "%s — Anouma" title template — the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
// Homepage bypasses the "%s - Anouma" title template - the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma - Anouma".
return { ...meta, title: { absolute: resolved.title } };
}
@@ -93,10 +93,10 @@ export default async function Home() {
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen – jedes Angebot ist ein eigener, geschützter Raum."
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen - jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 space-y-6">
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{/* Prozessbegleitung ist das Kernangebot - bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
@@ -263,7 +263,7 @@ export default async function Home() {
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
"Ich freue mich, von dir zu hören - schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
+2 -2
View File
@@ -22,7 +22,7 @@ export async function generateMetadata(): Promise<Metadata> {
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: booking.seo,
fallbackTitle: `Termin buchen – ${siteConfig.name}`,
fallbackTitle: `Termin buchen - ${siteConfig.name}`,
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -115,7 +115,7 @@ export default async function TerminBuchenPage() {
title="Allgemeine Anfrage"
lead={
bookableOffers.length > 0
? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben."
? "Für einen konkreten Termin wähle oben ein Angebot - hier kannst du auch einfach eine allgemeine Nachricht schreiben."
: undefined
}
align="left"
@@ -30,7 +30,7 @@ export default async function BeitretenPage({ params }: Args) {
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
<div className="relative mx-auto w-full max-w-md px-6 text-center">
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
ANOUMA
</Link>
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
+2 -2
View File
@@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
const resolved = resolveSeo({
seo: event.seo,
fallbackTitle: `${event.title} – ${siteConfig.name}`,
fallbackTitle: `${event.title} - ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
fallbackImage: event.image,
defaultOgImage: seoSettings.defaultOgImage,
@@ -74,7 +74,7 @@ export default async function EventDetailPage({ params }: Args) {
if (event.isOnline) {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() });
// Must check the collection, not just presence — a logged-in customer
// Must check the collection, not just presence - a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users" && user.role === "admin";
const settings = await payload.findGlobal({ slug: "meeting-settings" });
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function TerminePage() {
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören.
Schau gerne bald wieder vorbei, oder melde dich direkt - ich freue mich von dir zu hören.
</p>
<div className="mt-5 flex justify-center">
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
+1 -1
View File
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: about.seo,
fallbackTitle: `Über mich – ${siteConfig.name}`,
fallbackTitle: `Über mich - ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
fallbackImage: about.portrait,
defaultOgImage: seoSettings.defaultOgImage,
+2
View File
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
@@ -57,6 +58,7 @@ export const importMap = {
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* - generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
+2 -2
View File
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent — but the client's own claim is
* visitor has granted statistics consent - but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// verified - this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
// no body — handled below as a missing email
// no body - handled below as a missing email
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || email.length > 254) {
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// (with an advisory lock) before allowing this to become "confirmed" -
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Cancelling is deliberately allowed regardless of verification status —
// Cancelling is deliberately allowed regardless of verification status -
// only creating/confirming a booking is a gated "protected" action.
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export async function POST(request: Request) {
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// stale slot list) - the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayBounds = berlinDayBounds(start);
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders — reuses
* (a second, separate cron entry from /api/cron/event-reminders - reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
/**
* Used by the Docker healthcheck (see docker-compose.yml) and by
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
* being considered "up" — not just that the Node process is listening.
* being considered "up" - not just that the Node process is listening.
*/
export async function GET() {
try {
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
}
const { user } = await payload.auth({ headers: request.headers });
// Must check the collection, not just presence — a logged-in customer
// Must check the collection, not just presence - a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users" && user.role === "admin";
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real
// Only meaningful for participants - a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
clientId: isHost ? undefined : clientId,
+383 -3
View File
@@ -1,3 +1,383 @@
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
the admin panel uses Payload's own default styling, only the public site
uses the ANOUMA design system (see app/(frontend)/globals.css). */
@font-face {
font-family: "Anouma Admin";
src: url("/fonts/baar-antropos.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 100 900;
}
:root {
--anouma-ink: #342c31;
--anouma-plum: #66505f;
--anouma-mauve: #8b616d;
--anouma-rose: #d19ca1;
--anouma-cream: #f7f2ed;
--anouma-paper: #fffdfb;
--anouma-line: #e4d9d2;
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
}
html,
body {
background: var(--anouma-cream);
color: var(--anouma-ink);
font-family: var(--font-body);
}
body,
button,
input,
textarea,
select {
font-family: var(--font-body);
}
/* The left navigation is intentionally dark and quiet. Content stays light. */
.nav {
background: var(--anouma-ink);
border-right: 1px solid #4b3f46;
color: #f8f2ed;
}
.nav__header {
border-bottom: 1px solid #4b3f46;
padding: 24px 20px 20px;
}
.nav__header a,
.nav__header button {
color: #fffaf6;
}
.nav__links {
padding: 18px 12px;
}
.nav-group {
margin: 0 0 18px;
}
.nav-group__label,
.nav-group__toggle {
color: #bdaeb4;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.nav-group__label {
padding: 0 10px 8px;
}
.nav__link {
border-radius: 4px;
color: #f8f2ed;
font-size: 14px;
margin: 2px 0;
min-height: 38px;
padding: 9px 10px;
}
.nav__link:hover,
.nav__link:focus-visible,
.nav__link.active {
background: #4c3d47;
color: #fff;
}
.nav__link.active {
box-shadow: inset 3px 0 0 var(--anouma-rose);
}
.nav__controls {
border-top: 1px solid #4b3f46;
padding: 14px 12px;
}
.app-header,
.step-nav {
background: var(--anouma-paper);
border-bottom: 1px solid var(--anouma-line);
}
.template-default__wrap,
.dashboard__wrap,
.collection-list__wrap,
.edit-view__wrap {
background: var(--anouma-cream);
}
.template-default__wrap {
min-height: 100vh;
}
.dashboard__header,
.collection-list__header,
.edit-view__header {
border-bottom: 1px solid var(--anouma-line);
margin-bottom: 26px;
padding-bottom: 18px;
}
.dashboard__header h1,
.collection-list__header h1,
.edit-view__header h1 {
color: var(--anouma-ink);
font-size: clamp(26px, 3vw, 38px);
font-weight: 600;
letter-spacing: -0.025em;
}
.card,
.dashboard__card,
.table-wrap,
.collection-list__table-wrap,
.field-type,
.document-fields__main {
border-color: var(--anouma-line);
}
.card,
.dashboard__card,
.table-wrap {
border-radius: 6px;
box-shadow: 0 8px 24px rgb(52 44 49 / 4%);
}
.btn {
border-radius: 4px;
font-weight: 600;
}
.btn--style-primary {
--bg-color: var(--anouma-mauve);
--hover-bg: var(--anouma-plum);
}
input,
textarea,
select,
.rs__control {
border-color: #d7c9c1;
border-radius: 4px;
}
input:focus,
textarea:focus,
select:focus,
.rs__control--is-focused {
border-color: var(--anouma-mauve);
box-shadow: 0 0 0 2px rgb(139 97 109 / 12%);
}
.field-label,
.field-label label {
color: var(--anouma-plum);
font-weight: 650;
}
.description,
.field-description {
color: #7b6b72;
}
.table thead th,
.table th {
background: #f1e9e4;
color: var(--anouma-plum);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.table tbody tr:hover {
background: #fbf7f4;
}
.anouma-admin-welcome {
background: var(--anouma-paper);
border: 1px solid var(--anouma-line);
border-left: 4px solid var(--anouma-mauve);
border-radius: 6px;
box-shadow: 0 12px 32px rgb(52 44 49 / 5%);
margin: 0 0 28px;
padding: 28px;
}
.anouma-admin-welcome__intro {
max-width: 680px;
}
.anouma-admin-eyebrow {
color: var(--anouma-mauve);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
margin: 0 0 8px;
text-transform: uppercase;
}
.anouma-admin-welcome h1 {
color: var(--anouma-ink);
font-size: clamp(25px, 3vw, 36px);
font-weight: 600;
letter-spacing: -0.03em;
margin: 0;
}
.anouma-admin-welcome__intro > p:last-child {
color: #776870;
font-size: 15px;
line-height: 1.6;
margin: 10px 0 0;
}
.anouma-admin-quicklinks {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 26px;
}
.anouma-admin-quicklink {
background: #faf5f1;
border: 1px solid var(--anouma-line);
border-radius: 4px;
color: var(--anouma-ink);
display: flex;
flex-direction: column;
min-height: 154px;
padding: 16px;
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.anouma-admin-quicklink:hover,
.anouma-admin-quicklink:focus-visible {
background: #fffaf7;
border-color: var(--anouma-mauve);
color: var(--anouma-ink);
transform: translateY(-2px);
}
.anouma-admin-quicklink__number {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 24px;
}
.anouma-admin-quicklink__kicker {
color: var(--anouma-mauve);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.anouma-admin-quicklink strong {
font-size: 17px;
font-weight: 650;
margin-top: 5px;
}
.anouma-admin-quicklink > span:last-child {
color: #7b6b72;
font-size: 12px;
line-height: 1.45;
margin-top: 6px;
}
.anouma-custom-nav-item,
.anouma-custom-nav-action {
margin: 0 0 6px;
padding: 0;
}
.anouma-custom-nav-link,
.anouma-custom-nav-button {
align-items: center;
border: 1px solid #5a4a55;
border-radius: 4px;
display: flex;
font-size: 13px;
font-weight: 650;
gap: 10px;
min-height: 38px;
padding: 8px 10px;
text-decoration: none;
width: 100%;
}
.anouma-custom-nav-link {
color: #fffaf6;
}
.anouma-custom-nav-link:hover,
.anouma-custom-nav-link:focus-visible {
background: #4c3d47;
border-color: #7d6371;
color: #fff;
}
.anouma-custom-nav-link__icon {
color: var(--anouma-rose);
font-family: var(--font-mono);
font-size: 10px;
}
.anouma-custom-nav-button {
background: var(--anouma-mauve);
border-color: var(--anouma-mauve);
color: #fff;
cursor: pointer;
justify-content: flex-start;
}
.anouma-custom-nav-button:hover,
.anouma-custom-nav-button:focus-visible {
background: var(--anouma-plum);
border-color: var(--anouma-plum);
}
.anouma-custom-nav-button:disabled {
cursor: wait;
opacity: 0.7;
}
.anouma-custom-nav-input {
background: #fffaf6;
border: 1px solid #d7c9c1;
border-radius: 3px;
color: var(--anouma-ink);
font-family: var(--font-mono);
font-size: 10px;
margin-top: 6px;
padding: 6px 7px;
width: 100%;
}
.anouma-custom-nav-error {
color: #f1b5b5;
font-size: 11px;
line-height: 1.4;
margin: 6px 0 0;
}
@media (max-width: 1100px) {
.anouma-admin-quicklinks {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.anouma-admin-welcome {
padding: 20px;
}
.anouma-admin-quicklinks {
grid-template-columns: 1fr;
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { siteConfig } from "@/lib/site";
import "./(frontend)/globals.css";
// Required because the app has two root layouts — (frontend) and (payload) —
// Required because the app has two root layouts - (frontend) and (payload) -
// so there is no single layout Next.js could compose a 404 page from.
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
@@ -36,7 +36,7 @@ export default function GlobalNotFound() {
>
<body className="flex min-h-full flex-col bg-background text-foreground">
{/* This page is always statically prerendered, so it can't read live
CMS data — the mega menu is intentionally omitted here. */}
CMS data - the mega menu is intentionally omitted here. */}
<Navbar offers={[]} />
<main className="flex-1">
<section className="relative overflow-hidden py-24 sm:py-32">
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
</div>
</section>
</main>
{/* Minimal static footer — the full Footer reads live CMS data,
{/* Minimal static footer - the full Footer reads live CMS data,
which isn't available on this always-static 404 page. */}
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
&copy; {new Date().getFullYear()} {siteConfig.name}
+4 -4
View File
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
// every request — must not be statically prerendered at build time.
// every request - must not be statically prerendered at build time.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
@@ -10,7 +10,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
if (!indexable) {
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
// sichtbar") — block everything rather than list exceptions.
// sichtbar") - block everything rather than list exceptions.
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
@@ -24,7 +24,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
"/admin",
"/admin/*",
"/api/*",
// Customer accounts — never public.
// Customer accounts - never public.
"/konto",
"/konto/*",
"/login",
@@ -32,7 +32,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
"/auth/*",
// Private calendar-subscription feed (secret-token URLs).
"/calendar/*",
// Meeting join/call flow — session-gated, never a page worth indexing.
// Meeting join/call flow - session-gated, never a page worth indexing.
"/termine/*/beitreten",
"/termine/*/call",
],
+3 -3
View File
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads live CMS content on every request — must not be statically
// Reads live CMS content on every request - must not be statically
// prerendered at build time (no DB is available during `next build`; see
// AGENTS.md / the same convention used by every other CMS-backed route).
export const dynamic = "force-dynamic";
@@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
// Static, always-public routes that aren't backed by a dynamic [slug]
// collection. Deliberately excludes: /login, /registrieren, /konto*,
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
// /api/* — none of those are public content (see app/robots.ts, which
// /api/* - none of those are public content (see app/robots.ts, which
// mirrors this same exclusion list).
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
{ path: "", changeFrequency: "weekly", priority: 1 },
@@ -40,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}));
// getOffers/getAllEvents/getPosts already only return published,
// publicly-readable documents (overrideAccess: false — see
// publicly-readable documents (overrideAccess: false - see
// lib/payload/content.ts's header comment).
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);