Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system
- Next.js 16 App Router site with the ANOUMA design system - Payload CMS (PostgreSQL) for offers, events, posts and page content - WebRTC video-call system with custom signaling server - SMTP email reminders and booking-request notifications - Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# PostgreSQL connection string used by Payload (see docker-compose.yml for a
|
||||
# local database, or use a hosted Postgres such as Neon/Supabase).
|
||||
DATABASE_URI=postgresql://postgres:postgres@127.0.0.1:5432/anouma
|
||||
|
||||
# Long random string used to sign Payload's auth tokens/cookies.
|
||||
# Generate one with: openssl rand -base64 48
|
||||
PAYLOAD_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# Public URL of this site — used by Payload for absolute admin/media links,
|
||||
# and to build the meeting join link inside reminder emails.
|
||||
# Set this to the real domain in production (e.g. https://anouma.org).
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
|
||||
# Long random string used to sign meeting join tokens (separate from
|
||||
# PAYLOAD_SECRET on purpose). Generate one with: openssl rand -base64 48
|
||||
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# Secret the external cron job must send as "Authorization: Bearer <value>"
|
||||
# to trigger /api/cron/event-reminders. Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# SMTP settings for the existing ANOUMA mail system (reminder emails).
|
||||
# No external newsletter service — plain SMTP via nodemailer.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="ANOUMA <support@anouma.org>"
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# payload
|
||||
/media
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Runs a custom Node server (server.ts) for the WebRTC signaling WebSocket,
|
||||
# so this can't use Next's "standalone" output — we ship the full app +
|
||||
# node_modules instead. DATABASE_URI/PAYLOAD_SECRET etc. are only needed at
|
||||
# runtime, not at build time (see docker-compose.yml and .env.example).
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/payload.config.ts ./payload.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/collections ./collections
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/globals ./globals
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/access ./access
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/fields ./fields
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/components ./components
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
RUN mkdir -p media && chown nextjs:nodejs media
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Anouma
|
||||
|
||||
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit P2P-Video-Call (WebRTC), Anmeldungen und E-Mail-Erinnerungen.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
||||
- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
|
||||
- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
|
||||
- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist)
|
||||
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration
|
||||
- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
|
||||
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, WebSocket-Signaling, Reminder-Logik
|
||||
- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
|
||||
- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
|
||||
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
|
||||
- `server.ts` — eigener Node-Server (statt `next start`), weil daran der WebRTC-Signaling-WebSocket hängt
|
||||
|
||||
Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`).
|
||||
|
||||
## Online-Termine & Video-Call
|
||||
|
||||
- Ein Termin wird per Häkchen „Online-Termin“ zu einem Video-Call-Termin. Payload generiert dabei automatisch ein zufälliges Meeting-Passwort (nie aus der Meeting-ID abgeleitet); die Admin kann jederzeit ein neues erzeugen lassen.
|
||||
- Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`.
|
||||
- Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen).
|
||||
- Die Zeitfenster (wie früh Host/Teilnehmer beitreten dürfen, wie lange das Meeting nach Ende offen bleibt) sind zentral im CMS unter **Video-Termin-Einstellungen** konfigurierbar.
|
||||
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Das WebSocket-Signaling (`/ws/signaling`, siehe `server.ts`) prüft dieses Token erneut, bevor jemand einem Raum beitreten darf.
|
||||
- Reines P2P-WebRTC: Der Server relayt nur kleine Signaling-Nachrichten (Angebot/Antwort/ICE), niemals Audio/Video/Bildschirmfreigabe. Es wird nichts aufgezeichnet oder gespeichert.
|
||||
- Teilnehmer-Anmeldungen laufen über die Collection **Anmeldungen** (`event-registrations`), öffentlich erreichbar über `/api/meetings/[slug]/register`.
|
||||
|
||||
### E-Mail-Erinnerungen
|
||||
|
||||
Erinnerungs-Mails (60/30 Minuten vorher, pro Termin im CMS einzeln an-/abschaltbar) werden **nicht** automatisch im Hintergrund verschickt, sondern müssen von einem externen Cron-Job ausgelöst werden, z. B. alle 5 Minuten:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
|
||||
```
|
||||
|
||||
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# .env ausfüllen: DATABASE_URI, PAYLOAD_SECRET (z. B. mit `openssl rand -base64 48`)
|
||||
```
|
||||
|
||||
### Datenbank
|
||||
|
||||
Lokal per Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen).
|
||||
|
||||
### Entwicklung
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Website: http://localhost:3000
|
||||
- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
|
||||
|
||||
### Vorhandene Inhalte ins CMS übertragen
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
|
||||
|
||||
### Produktion
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run migrate # wendet Datenbank-Migrationen an
|
||||
npm run start
|
||||
```
|
||||
|
||||
Mit Docker: `docker build -t anouma .` (baut die App und startet sie über den eigenen `server.ts`-Server statt `next start`, wegen des WebSocket-Signalings), `.env` per `--env-file` oder `docker-compose.yml` (Service `app`, aktuell auskommentiert) bereitstellen.
|
||||
|
||||
## Weitere Skripte
|
||||
|
||||
| Skript | Zweck |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| `npm run generate:types` | `payload-types.ts` aus der Config neu erzeugen |
|
||||
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
|
||||
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
||||
| `npm run lint` | ESLint |
|
||||
|
||||
## Design
|
||||
|
||||
Siehe `app/(frontend)/globals.css` für das Farbsystem und `AGENTS.md` für die Next.js-Version-16-Hinweise, die für jede Code-Änderung in `app/` gelten.
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Access, FieldAccess } from "payload";
|
||||
|
||||
/**
|
||||
* Only the "users" auth collection (Anna/admins) may perform the action —
|
||||
* and only with the "admin" role, so adding further roles to Users later
|
||||
* only means extending this one check. Site visitors authenticate against
|
||||
* the separate "customers" collection (see collections/Customers.ts) and
|
||||
* must never be treated as admins here.
|
||||
*/
|
||||
export const isAdmin: Access = ({ req: { user } }) =>
|
||||
user?.collection === "users" && user.role === "admin";
|
||||
|
||||
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
||||
user?.collection === "users" && user.role === "admin";
|
||||
|
||||
/**
|
||||
* Admins can read every document (including drafts); everyone else may only
|
||||
* read documents whose Payload draft/publish status is "published".
|
||||
*/
|
||||
export const publishedOrAdmin: Access = ({ req: { user } }) => {
|
||||
if (user?.collection === "users" && user.role === "admin") return true;
|
||||
|
||||
return {
|
||||
_status: { equals: "published" },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
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
|
||||
// dark, without the marketing navbar/footer around it.
|
||||
|
||||
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Video-Call",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CallLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="de" className={`${inter.variable} h-full antialiased`}>
|
||||
<body className="min-h-full bg-neutral-900">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CallRoom } from "@/components/meeting/CallRoom";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default async function CallPage({ params }: Args) {
|
||||
const { slug } = await params;
|
||||
return <CallRoom slug={slug} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { getPostBySlug } from "@/lib/payload/content";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return { title: post.title, description: post.teaser };
|
||||
}
|
||||
|
||||
export default async function PostDetailPage({ params }: Args) {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={publishDate}
|
||||
title={post.title}
|
||||
lead={post.teaser}
|
||||
crumbs={[{ title: "Aktuelles", href: "/aktuelles" }, { title: post.title }]}
|
||||
/>
|
||||
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<ImagePlaceholder
|
||||
mood="sand"
|
||||
label={post.title}
|
||||
src={mediaUrl(post.coverImage, "hero")}
|
||||
alt={mediaAlt(post.coverImage) ?? post.title}
|
||||
shape="soft"
|
||||
priority
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<RichText data={post.content} />
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getPosts } from "@/lib/payload/content";
|
||||
import { getAktuellesIntroGlobal } from "@/lib/payload/globals";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const intro = await getAktuellesIntroGlobal();
|
||||
return {
|
||||
title: intro.title || "Aktuelles",
|
||||
description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AktuellesPage() {
|
||||
const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={intro.eyebrow || "Aktuelles"}
|
||||
title={intro.title || "Neuigkeiten, Termine und Inspiration"}
|
||||
lead={intro.lead}
|
||||
crumbs={[{ title: "Aktuelles" }]}
|
||||
/>
|
||||
|
||||
<Section tone="plain">
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-lg text-anouma-plum">
|
||||
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post, i) => (
|
||||
<Reveal key={post.slug} delay={Math.min(i * 0.06, 0.3)}>
|
||||
<Link href={`/aktuelles/${post.slug}`} className="group block">
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
|
||||
<ImagePlaceholder
|
||||
mood="sand"
|
||||
label={post.title}
|
||||
src={mediaUrl(post.coverImage, "card")}
|
||||
alt={mediaAlt(post.coverImage) ?? post.title}
|
||||
shape="soft"
|
||||
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<h2 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { getOfferBySlug } from "@/lib/payload/content";
|
||||
import { moodForOffer } from "@/lib/angebote";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
import { OFFER_CATEGORIES } from "@/collections/Offers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const offer = await getOfferBySlug(slug);
|
||||
if (!offer) return {};
|
||||
return {
|
||||
title: offer.title,
|
||||
description: offer.shortDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OfferDetailPage({ params }: Args) {
|
||||
const { slug } = await params;
|
||||
const offer = await getOfferBySlug(slug);
|
||||
if (!offer) notFound();
|
||||
|
||||
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={categoryLabel}
|
||||
title={offer.title}
|
||||
lead={offer.shortDescription}
|
||||
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: offer.title }]}
|
||||
/>
|
||||
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<ImagePlaceholder
|
||||
mood={moodForOffer(offer)}
|
||||
label={offer.title}
|
||||
src={mediaUrl(offer.image, "hero")}
|
||||
alt={mediaAlt(offer.image) ?? offer.title}
|
||||
shape="soft"
|
||||
priority
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<RichText data={offer.description} />
|
||||
</div>
|
||||
</Section>
|
||||
<CTA
|
||||
title="Interesse geweckt?"
|
||||
lead={`Melde dich gerne für ein unverbindliches Gespräch zu „${offer.title}“.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { AngebotCard } from "@/components/AngebotCard";
|
||||
import { Button } from "@/components/Button";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
import { getAngeboteIntroGlobal } from "@/lib/payload/globals";
|
||||
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const intro = await getAngeboteIntroGlobal();
|
||||
return {
|
||||
title: intro.title || "Angebote",
|
||||
description:
|
||||
intro.lead ||
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AngebotePage() {
|
||||
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
|
||||
const groups = groupOffersByCategory(offers);
|
||||
|
||||
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
|
||||
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
|
||||
const kindergruppen = groups.find((g) => g.category === "kindergruppen");
|
||||
const singkreise = groups.find((g) => g.category === "singkreise");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={intro.title || "Angebote"}
|
||||
lead={intro.lead}
|
||||
crumbs={[{ title: "Angebote" }]}
|
||||
/>
|
||||
|
||||
{prozessbegleitung && (
|
||||
<Section tone="plain">
|
||||
<div className="grid items-center gap-14 lg:grid-cols-2">
|
||||
<Reveal>
|
||||
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none">
|
||||
<ImagePlaceholder
|
||||
mood={moodForOffer(prozessbegleitung)}
|
||||
label={prozessbegleitung.title}
|
||||
src={mediaUrl(prozessbegleitung.image, "hero")}
|
||||
alt={mediaAlt(prozessbegleitung.image)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||||
Für Erwachsene
|
||||
</p>
|
||||
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
|
||||
{prozessbegleitung.title}
|
||||
</h2>
|
||||
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
|
||||
{prozessbegleitung.shortDescription}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button href={`/angebote/${prozessbegleitung.slug}`}>
|
||||
{prozessbegleitung.title} entdecken
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{doula && (
|
||||
<Section tone="cream">
|
||||
<div className="grid items-center gap-14 lg:grid-cols-2">
|
||||
<Reveal className="order-2 lg:order-1">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||||
Kinderwunsch · Schwangerschaft · Geburt · Wochenbett
|
||||
</p>
|
||||
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
|
||||
{doula.title}
|
||||
</h2>
|
||||
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
|
||||
{doula.shortDescription}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button href={`/angebote/${doula.slug}`}>{doula.title} entdecken</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1} className="order-1 lg:order-2">
|
||||
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none lg:ml-auto">
|
||||
<ImagePlaceholder
|
||||
mood={moodForOffer(doula)}
|
||||
label={doula.title}
|
||||
src={mediaUrl(doula.image, "hero")}
|
||||
alt={mediaAlt(doula.image)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{kindergruppen && kindergruppen.offers.length > 0 && (
|
||||
<Section id="kindergruppen" tone="plain">
|
||||
<SectionHeading eyebrow="Für Kinder" title="Kindergruppen" lead={kindergruppen.label} />
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2">
|
||||
{kindergruppen.offers.map((offer, i) => (
|
||||
<Reveal key={offer.slug} delay={i * 0.08}>
|
||||
<AngebotCard
|
||||
href={`/angebote/${offer.slug}`}
|
||||
title={offer.title}
|
||||
tagline={offer.shortDescription}
|
||||
mood={moodForOffer(offer)}
|
||||
imageSrc={mediaUrl(offer.image, "card")}
|
||||
size="large"
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{singkreise && singkreise.offers.length > 0 && (
|
||||
<Section tone="warm">
|
||||
<SectionHeading eyebrow="Für Herz und Seele" title="Singkreise" />
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-3">
|
||||
{singkreise.offers.map((offer, i) => (
|
||||
<Reveal key={offer.slug} delay={i * 0.08}>
|
||||
<AngebotCard
|
||||
href={`/angebote/${offer.slug}`}
|
||||
title={offer.title}
|
||||
tagline={offer.shortDescription}
|
||||
mood={moodForOffer(offer)}
|
||||
imageSrc={mediaUrl(offer.image, "card")}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<CTA
|
||||
title="Unsicher, welcher Raum passt?"
|
||||
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { AngebotCard } from "@/components/AngebotCard";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
import { moodForOffer } from "@/lib/angebote";
|
||||
import { mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Singkreise",
|
||||
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
|
||||
};
|
||||
|
||||
export default async function SingkreisePage() {
|
||||
const offers = await getOffers();
|
||||
const singkreise = offers
|
||||
.filter((offer) => offer.category === "singkreise")
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Singkreise"
|
||||
title="Singkreise – miteinander singen, klingen und sein"
|
||||
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
|
||||
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
|
||||
/>
|
||||
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{singkreise.map((offer, i) => (
|
||||
<Reveal key={offer.slug} delay={i * 0.08}>
|
||||
<AngebotCard
|
||||
href={`/angebote/${offer.slug}`}
|
||||
title={offer.title}
|
||||
tagline={offer.shortDescription}
|
||||
mood={moodForOffer(offer)}
|
||||
imageSrc={mediaUrl(offer.image, "card")}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { PlaceholderNote } from "@/components/PlaceholderNote";
|
||||
import { getContactGlobal } from "@/lib/payload/globals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Datenschutz",
|
||||
description: "Datenschutzerklärung von Anouma.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const contact = await getContactGlobal();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
<PlaceholderNote>
|
||||
Diese Seite ist eine strukturelle Vorlage. Bitte die Angaben zu verantwortlicher Stelle,
|
||||
Hosting, eingesetzten Diensten und Cookies mit den tatsächlich genutzten Tools ergänzen
|
||||
und rechtlich prüfen lassen, bevor die Seite live geht.
|
||||
</PlaceholderNote>
|
||||
|
||||
<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]
|
||||
<br />
|
||||
[Platzhalter — Anschrift]
|
||||
<br />
|
||||
E-Mail: {contact.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
|
||||
Erhebung und Speicherung personenbezogener Daten
|
||||
</h2>
|
||||
<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.]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontaktformular</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
Wenn du uns per Formular oder E-Mail Anfragen zukommen lässt, werden deine Angaben aus
|
||||
dem Formular inklusive der von dir dort angegebenen Kontaktdaten zwecks Bearbeitung der
|
||||
Anfrage bei uns gespeichert. Diese Daten geben wir nicht ohne deine Einwilligung weiter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Deine Rechte</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
Du hast jederzeit das Recht auf Auskunft, Berichtigung, Löschung und Einschränkung der
|
||||
Verarbeitung deiner gespeicherten personenbezogenen Daten sowie ein Recht auf
|
||||
Datenübertragbarkeit und Widerspruch. Wende dich hierzu gerne an {contact.email}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
/* Anouma color palette */
|
||||
--color-anouma-mauve-dark: #8b616d;
|
||||
--color-anouma-rose: #d19ca1;
|
||||
--color-anouma-rose-soft: #e0b4b1;
|
||||
--color-anouma-rose-pale: #e5c4bc;
|
||||
--color-anouma-peach-rose: #ecc8be;
|
||||
--color-anouma-powder: #efd5c8;
|
||||
--color-anouma-cream-warm: #efdbcd;
|
||||
--color-anouma-cream-beige: #f0e0d3;
|
||||
--color-anouma-cream-light: #f2e2d6;
|
||||
|
||||
--color-anouma-sage: #89937c;
|
||||
--color-anouma-olive: #667052;
|
||||
--color-anouma-moss: #4f5b45;
|
||||
--color-anouma-walnut: #765a48;
|
||||
--color-anouma-caramel: #a47c60;
|
||||
--color-anouma-sand: #d4c1a5;
|
||||
--color-anouma-cream-beige-2: #e8dcc8;
|
||||
--color-anouma-taupe: #a89580;
|
||||
--color-anouma-dustyrose: #a97070;
|
||||
--color-anouma-mauve: #8b6f7d;
|
||||
--color-anouma-plum: #66505f;
|
||||
|
||||
--color-background: #fbf6f1;
|
||||
--color-foreground: #453238;
|
||||
|
||||
--font-serif: var(--font-cormorant), "Cormorant Garamond", ui-serif, Georgia, serif;
|
||||
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--animate-fade-up: fade-up 0.8s ease-out both;
|
||||
--animate-fade-in: fade-in 1s ease-out both;
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fbf6f1;
|
||||
--foreground: #453238;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--color-anouma-rose) var(--color-anouma-cream-light);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-anouma-rose-soft);
|
||||
color: var(--color-anouma-plum);
|
||||
}
|
||||
|
||||
/* Visible, elegant focus state used across all interactive elements */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid var(--color-anouma-mauve-dark);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Impressionen",
|
||||
description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.",
|
||||
};
|
||||
|
||||
const gallery: { mood: ImageMood; label: string; span?: string }[] = [
|
||||
{ mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" },
|
||||
{ mood: "rose", label: "Gemeinschaft und Begegnung" },
|
||||
{ mood: "peach", label: "Schwangerschaft und Wachsen" },
|
||||
{ mood: "sand", label: "Singen und Klang" },
|
||||
{ mood: "caramel", label: "Kreativität und Hände", span: "sm:row-span-2" },
|
||||
{ mood: "dustyrose", label: "Mutter und Kind" },
|
||||
{ mood: "plum", label: "Stille und Innehalten" },
|
||||
{ mood: "mauve", label: "Räume der Verbindung" },
|
||||
];
|
||||
|
||||
export default function ImpressionenPage() {
|
||||
return (
|
||||
<>
|
||||
<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."
|
||||
crumbs={[{ title: "Impressionen" }]}
|
||||
/>
|
||||
<Section tone="plain">
|
||||
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{gallery.map((item, i) => (
|
||||
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
|
||||
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { PlaceholderNote } from "@/components/PlaceholderNote";
|
||||
import { getContactGlobal } from "@/lib/payload/globals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Impressum",
|
||||
description: "Impressum von Anouma gemäß § 5 TMG.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
const contact = await getContactGlobal();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
<PlaceholderNote>
|
||||
Dieses Impressum enthält noch Platzhalterangaben. Bitte vor Veröffentlichung durch die
|
||||
vollständigen, rechtsverbindlichen Angaben gemäß § 5 TMG ersetzen (und im Zweifel
|
||||
rechtlich prüfen lassen).
|
||||
</PlaceholderNote>
|
||||
|
||||
<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]
|
||||
<br />
|
||||
[Platzhalter — Straße und Hausnummer]
|
||||
<br />
|
||||
[Platzhalter — PLZ und Ort]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontakt</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
{contact.phone && (
|
||||
<>
|
||||
Telefon: {contact.phone}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
E-Mail: {contact.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
|
||||
Umsatzsteuer-Identifikationsnummer
|
||||
</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
|
||||
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]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Haftungshinweis</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte
|
||||
externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren
|
||||
Betreiber verantwortlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getContactGlobal } from "@/lib/payload/globals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const contact = await getContactGlobal();
|
||||
return {
|
||||
title: contact.title || "Kontakt",
|
||||
description: contact.lead || "Ich freue mich, von dir zu hören.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KontaktPage() {
|
||||
const contact = await getContactGlobal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={contact.eyebrow || "Kontakt"}
|
||||
title={contact.title || "Ich freue mich, von dir zu hören"}
|
||||
lead={contact.lead}
|
||||
crumbs={[{ title: "Kontakt" }]}
|
||||
/>
|
||||
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||
<Reveal>
|
||||
<ContactForm toEmail={contact.email} />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="space-y-8">
|
||||
<div className="relative aspect-[4/3] w-full">
|
||||
<ImagePlaceholder mood="mauve" label="Kontakt" className="h-full w-full" />
|
||||
</div>
|
||||
<div className="space-y-3 text-anouma-plum">
|
||||
<p>
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
E-Mail
|
||||
</span>
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
</p>
|
||||
{contact.phone && (
|
||||
<p>
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
Telefon
|
||||
</span>
|
||||
<span className="text-lg text-anouma-plum">{contact.phone}</span>
|
||||
</p>
|
||||
)}
|
||||
{contact.region && (
|
||||
<p>
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
Region
|
||||
</span>
|
||||
<span className="text-lg text-anouma-plum">{contact.region}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { PersonalCalendar } from "@/components/booking/PersonalCalendar";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Kalender" };
|
||||
|
||||
export default async function KontoKalenderPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return null;
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Mein Kalender</h2>
|
||||
<div className="mt-6">
|
||||
<PersonalCalendar bookings={bookings} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container } from "@/components/Section";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const navItems = [
|
||||
{ title: "Übersicht", href: "/konto" },
|
||||
{ title: "Kalender", href: "/konto/kalender" },
|
||||
{ title: "Meine Termine", href: "/konto/termine" },
|
||||
{ title: "Profil", href: "/konto/profil" },
|
||||
];
|
||||
|
||||
export default async function KontoLayout({ children }: { children: ReactNode }) {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/login?next=/konto");
|
||||
|
||||
return (
|
||||
<section className="bg-anouma-cream-light py-16">
|
||||
<Container>
|
||||
<div className="mb-10">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">Mein Konto</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-medium text-anouma-plum">Hallo, {customer.name}</h1>
|
||||
</div>
|
||||
<div className="grid gap-10 lg:grid-cols-[220px_1fr]">
|
||||
<nav aria-label="Konto-Navigation" className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
|
||||
</nav>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { BookingCard } from "@/components/booking/BookingCard";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
|
||||
export default async function KontoOverviewPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return null; // Layout already redirects; keeps TS happy.
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
const pending = bookings.filter((b) => b.status === "pending");
|
||||
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
|
||||
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="rounded-3xl bg-white p-6">
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Deine Daten</h2>
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">Name</dt>
|
||||
<dd className="text-base text-anouma-plum">{customer.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">E-Mail</dt>
|
||||
<dd className="text-base text-anouma-plum">{customer.email}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Offene Buchungsanfragen</h2>
|
||||
</div>
|
||||
{pending.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-anouma-plum/70">Keine offenen Anfragen.</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{pending.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Bestätigte Termine</h2>
|
||||
{confirmed.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-anouma-plum/70">Noch keine bestätigten Termine.</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{confirmed.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{past.length > 0 && (
|
||||
<div>
|
||||
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||
Alle Termine (inkl. vergangene) ansehen →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { ProfileForm } from "@/components/auth/ProfileForm";
|
||||
|
||||
export const metadata: Metadata = { title: "Profil" };
|
||||
|
||||
export default async function KontoProfilPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Profil</h2>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||
import { BookingCard } from "@/components/booking/BookingCard";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Termine" };
|
||||
|
||||
export default async function KontoTerminePage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return null;
|
||||
|
||||
const bookings = await getCustomerBookings(customer.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Meine Termine</h2>
|
||||
{bookings.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-anouma-plum/70">
|
||||
Du hast noch keine Terminanfragen gestellt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{bookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
import "./globals.css";
|
||||
|
||||
// Every page under this layout can read live content from Payload, so the
|
||||
// whole subtree is rendered dynamically rather than statically at build time.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.domain),
|
||||
title: {
|
||||
default: siteConfig.title,
|
||||
template: `%s — ${siteConfig.name}`,
|
||||
},
|
||||
description: siteConfig.description,
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: siteConfig.locale,
|
||||
url: siteConfig.domain,
|
||||
siteName: siteConfig.name,
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const offers = await getOffers();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
data-scroll-behavior="smooth"
|
||||
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="flex min-h-full flex-col bg-background text-foreground">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-anouma-mauve-dark focus:px-5 focus:py-3 focus:text-anouma-cream-light"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<Navbar offers={offers} />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Anmelden",
|
||||
};
|
||||
|
||||
export default async function LoginPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (customer) redirect("/konto");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-md">
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Noch kein Konto?{" "}
|
||||
<Link href="/registrieren" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||
Jetzt registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Hero } from "@/components/Hero";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { AngebotCard } from "@/components/AngebotCard";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { EventTeaserCard } from "@/components/EventTeaserCard";
|
||||
import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content";
|
||||
import { getHomeGlobal } from "@/lib/payload/globals";
|
||||
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const home = await getHomeGlobal();
|
||||
return {
|
||||
title: { absolute: home.heroTitle || "Willkommen bei Anouma" },
|
||||
description:
|
||||
home.heroSupporting ||
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const [home, offers, posts, events] = await Promise.all([
|
||||
getHomeGlobal(),
|
||||
getOffers(),
|
||||
getPosts(3),
|
||||
getUpcomingEvents(3),
|
||||
]);
|
||||
const groups = groupOffersByCategory(offers);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero
|
||||
eyebrow={home.heroEyebrow ?? undefined}
|
||||
title={home.heroTitle || "Willkommen bei Anouma"}
|
||||
mood="rose"
|
||||
imageSrc={mediaUrl(home.heroImage, "hero")}
|
||||
imageAlt={mediaAlt(home.heroImage)}
|
||||
actions={
|
||||
<>
|
||||
<Button href="/termin-buchen">Termin buchen</Button>
|
||||
<Button href="/angebote" variant="secondary">
|
||||
Angebote entdecken
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{home.heroHighlight && <p>{home.heroHighlight}</p>}
|
||||
{home.heroSupporting && (
|
||||
<p className="mt-3 text-base text-anouma-plum/90">{home.heroSupporting}</p>
|
||||
)}
|
||||
</Hero>
|
||||
|
||||
<Section tone="cream">
|
||||
<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."
|
||||
/>
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{groups.map((group, i) => {
|
||||
const isSingle = group.offers.length === 1;
|
||||
const first = group.offers[0];
|
||||
return (
|
||||
<Reveal key={group.category} delay={i * 0.08}>
|
||||
<AngebotCard
|
||||
href={isSingle ? `/angebote/${first.slug}` : group.href}
|
||||
title={group.label}
|
||||
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
|
||||
mood={moodForOffer(first)}
|
||||
imageSrc={mediaUrl(first.image, "card")}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{(home.aboutTitle || home.aboutText) && (
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-2 lg:items-center">
|
||||
<Reveal>
|
||||
<div className="relative mx-auto aspect-[4/5] w-full max-w-md lg:max-w-none">
|
||||
<ImagePlaceholder
|
||||
mood="mauve"
|
||||
label="Anouma"
|
||||
src={mediaUrl(home.aboutImage, "hero")}
|
||||
alt={mediaAlt(home.aboutImage)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
{home.aboutEyebrow && (
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||||
{home.aboutEyebrow}
|
||||
</p>
|
||||
)}
|
||||
{home.aboutTitle && (
|
||||
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
|
||||
{home.aboutTitle}
|
||||
</h2>
|
||||
)}
|
||||
{home.aboutText && (
|
||||
<div className="mt-6">
|
||||
<RichText data={home.aboutText} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<Button href="/ueber-mich" variant="ghost">
|
||||
Mehr über mich lesen →
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{home.values && home.values.length > 0 && (
|
||||
<Section tone="mauve">
|
||||
<SectionHeading
|
||||
eyebrow={home.valuesEyebrow ?? undefined}
|
||||
title={home.valuesTitle || "Philosophie"}
|
||||
align="center"
|
||||
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
|
||||
/>
|
||||
<div className="mt-16 grid gap-10 sm:grid-cols-3">
|
||||
{home.values.map((wert, i) => (
|
||||
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
|
||||
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
|
||||
{wert.title}
|
||||
</p>
|
||||
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
|
||||
„{wert.quote}“
|
||||
</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<Section tone="warm">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
|
||||
<Button href="/termine" variant="ghost">
|
||||
Alle Termine →
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||||
{events.map((event, i) => (
|
||||
<Reveal key={event.slug} delay={i * 0.08}>
|
||||
<EventTeaserCard event={event} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{posts.length > 0 && (
|
||||
<Section tone="cream">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
|
||||
<Button href="/aktuelles" variant="ghost">
|
||||
Alle Neuigkeiten →
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||||
{posts.map((post, i) => (
|
||||
<Reveal key={post.slug} delay={i * 0.08}>
|
||||
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
|
||||
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section tone="plain">
|
||||
<SectionHeading eyebrow="Impressionen" title="Einblicke in gemeinsame Räume" align="center" className="mx-auto" />
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{(["moss", "rose", "peach", "sand"] as const).map((mood, i) => (
|
||||
<Reveal key={mood} delay={i * 0.06}>
|
||||
<ImagePlaceholder mood={mood} label="Impression" shape="soft" className="aspect-square" />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 text-center">
|
||||
<Link
|
||||
href="/impressionen"
|
||||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
||||
>
|
||||
Alle Impressionen ansehen
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<CTA
|
||||
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."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Konto erstellen",
|
||||
};
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (customer) redirect("/konto");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Konto"
|
||||
title="Konto erstellen"
|
||||
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
|
||||
crumbs={[{ title: "Konto erstellen" }]}
|
||||
/>
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-md">
|
||||
<RegisterForm />
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Schon ein Konto?{" "}
|
||||
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||
Jetzt anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const booking = await getBookingGlobal();
|
||||
return {
|
||||
title: booking.title || "Termin buchen",
|
||||
description: booking.lead || "Kennenlernen & Termine vereinbaren.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TerminBuchenPage() {
|
||||
const [booking, contact] = await Promise.all([getBookingGlobal(), getContactGlobal()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={booking.eyebrow || "Termin buchen"}
|
||||
title={booking.title || "Kennenlernen & Termine vereinbaren"}
|
||||
lead={booking.lead}
|
||||
crumbs={[{ title: "Termin buchen" }]}
|
||||
/>
|
||||
|
||||
{booking.steps && booking.steps.length > 0 && (
|
||||
<Section tone="cream">
|
||||
<SectionHeading eyebrow="Ablauf" title="So findest du zu deinem Termin" />
|
||||
<div className="mt-14 grid gap-8 sm:grid-cols-3">
|
||||
{booking.steps.map((s, i) => (
|
||||
<Reveal key={s.id ?? s.title} delay={i * 0.08}>
|
||||
<p className="font-serif text-5xl font-medium text-anouma-rose">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<h3 className="mt-4 font-serif text-xl font-medium text-anouma-plum">{s.title}</h3>
|
||||
<p className="mt-3 text-base leading-relaxed text-anouma-plum">{s.text}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-xl">
|
||||
<SectionHeading title="Nachricht senden" align="left" />
|
||||
<div className="mt-10">
|
||||
<ContactForm
|
||||
toEmail={contact.email}
|
||||
subjectPrefix="Terminanfrage über anouma.org"
|
||||
submitLabel="Terminanfrage senden"
|
||||
/>
|
||||
</div>
|
||||
{booking.formNote && (
|
||||
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { JoinForm } from "@/components/meeting/JoinForm";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { getEventForJoin } from "@/lib/payload/content";
|
||||
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meeting beitreten",
|
||||
};
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default async function BeitretenPage({ params }: Args) {
|
||||
const { slug } = await params;
|
||||
const event = await getEventForJoin(slug);
|
||||
if (!event || !event.isOnline) notFound();
|
||||
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" });
|
||||
const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-[80vh] items-center overflow-hidden py-20">
|
||||
<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">
|
||||
ANOUMA
|
||||
</Link>
|
||||
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
Dein Termin beginnt bald
|
||||
</p>
|
||||
<h1 className="mt-3 font-serif text-3xl font-medium text-anouma-plum">{event.title}</h1>
|
||||
<p className="mt-2 text-base text-anouma-plum/80">
|
||||
{dateLabel} · {timeLabel} Uhr
|
||||
</p>
|
||||
|
||||
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
|
||||
<JoinForm slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { RegisterForm } from "@/components/meeting/RegisterForm";
|
||||
import { getEventBySlug } from "@/lib/payload/content";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
import { formatFullDate, formatTimeRange } from "@/lib/format";
|
||||
import { EVENT_CATEGORIES } from "@/collections/Events";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, statusLabel } from "@/lib/meeting/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const event = await getEventBySlug(slug);
|
||||
if (!event) return {};
|
||||
return { title: event.title };
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: Args) {
|
||||
const { slug } = await params;
|
||||
const event = await getEventBySlug(slug);
|
||||
if (!event) notFound();
|
||||
|
||||
const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label;
|
||||
const time = formatTimeRange(event.startTime, event.endTime);
|
||||
|
||||
let meetingCard = null;
|
||||
if (event.isOnline) {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: await headers() });
|
||||
const isHost = Boolean(user);
|
||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime
|
||||
? combineDateAndTime(event.date, event.endTime)
|
||||
: new Date(start.getTime() + 60 * 60_000);
|
||||
const status = getMeetingStatus({
|
||||
start,
|
||||
end,
|
||||
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
|
||||
closeAfterMinutes: settings.meetingCloseAfterMinutes,
|
||||
});
|
||||
const joinable = canJoinMeeting(status);
|
||||
|
||||
meetingCard = (
|
||||
<div className="rounded-3xl bg-anouma-plum p-6 text-center text-anouma-cream-light">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/80">
|
||||
Online-Termin
|
||||
</p>
|
||||
{joinable ? (
|
||||
<Link
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="mt-4 inline-flex w-full items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light"
|
||||
>
|
||||
{statusLabel[status]}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-4 text-base">{statusLabel[status]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={categoryLabel}
|
||||
title={event.title}
|
||||
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
|
||||
/>
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||
<div className="order-2 lg:order-1 space-y-10">
|
||||
{event.description && <RichText data={event.description} />}
|
||||
{event.registrationRequired && (
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
|
||||
<div className="mt-4 max-w-sm">
|
||||
<RegisterForm slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 space-y-6 lg:order-2">
|
||||
{meetingCard}
|
||||
<div className="relative aspect-[4/3] w-full">
|
||||
<ImagePlaceholder
|
||||
mood="rose"
|
||||
label={event.title}
|
||||
src={mediaUrl(event.image, "card")}
|
||||
alt={mediaAlt(event.image) ?? event.title}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<dl className="space-y-3 rounded-3xl bg-anouma-cream-light p-6 text-sm">
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Datum</dt>
|
||||
<dd className="text-base text-anouma-plum">{formatFullDate(event.date)}</dd>
|
||||
</div>
|
||||
{time && (
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Uhrzeit</dt>
|
||||
<dd className="text-base text-anouma-plum">{time}</dd>
|
||||
</div>
|
||||
)}
|
||||
{event.location && (
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Ort</dt>
|
||||
<dd className="text-base text-anouma-plum">{event.location}</dd>
|
||||
</div>
|
||||
)}
|
||||
{event.maxParticipants && (
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
|
||||
Teilnehmerzahl
|
||||
</dt>
|
||||
<dd className="text-base text-anouma-plum">max. {event.maxParticipants}</dd>
|
||||
</div>
|
||||
)}
|
||||
{event.registrationRequired && event.registrationInfo && (
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
|
||||
Anmeldung
|
||||
</dt>
|
||||
<dd className="text-base text-anouma-plum">{event.registrationInfo}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<CTA
|
||||
title="Dabei sein?"
|
||||
lead={`Melde dich gerne für „${event.title}“ an oder frag nach freien Plätzen.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { EventTeaserCard } from "@/components/EventTeaserCard";
|
||||
import { getAllEvents } from "@/lib/payload/content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Termine",
|
||||
description: "Aktuelle Termine und Veranstaltungen von Anouma.",
|
||||
};
|
||||
|
||||
export default async function TerminePage() {
|
||||
const events = await getAllEvents();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
|
||||
const past = events.filter((e) => new Date(e.date) < today);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Termine"
|
||||
title="Aktuelle Termine"
|
||||
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
|
||||
crumbs={[{ title: "Termine" }]}
|
||||
/>
|
||||
|
||||
<Section tone="plain">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-lg text-anouma-plum">
|
||||
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
|
||||
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{upcoming.map((event, i) => (
|
||||
<Reveal key={event.slug} delay={Math.min(i * 0.06, 0.3)}>
|
||||
<EventTeaserCard event={event} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{past.length > 0 && (
|
||||
<Section tone="warm">
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Vergangene Termine</h2>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{past.map((event) => (
|
||||
<div key={event.slug} className="opacity-70">
|
||||
<EventTeaserCard event={event} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Section } from "@/components/Section";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { getAboutGlobal } from "@/lib/payload/globals";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const about = await getAboutGlobal();
|
||||
return {
|
||||
title: about.title || "Über mich",
|
||||
description: "Mein Weg, meine Werte und was mich bewegt.",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UeberMichPage() {
|
||||
const about = await getAboutGlobal();
|
||||
const hasClosing = about.closingHighlight || about.closingParagraph;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={about.eyebrow || "Über mich"}
|
||||
title={about.title || "Mein Weg, meine Werte und was mich bewegt"}
|
||||
crumbs={[{ title: "Über mich" }]}
|
||||
/>
|
||||
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-[0.85fr_1.15fr] lg:items-start">
|
||||
<Reveal className="lg:sticky lg:top-28">
|
||||
<div className="relative mx-auto aspect-[4/5] w-full max-w-sm lg:max-w-none">
|
||||
<ImagePlaceholder
|
||||
mood="dustyrose"
|
||||
label="Portrait"
|
||||
src={mediaUrl(about.portrait, "hero")}
|
||||
alt={mediaAlt(about.portrait)}
|
||||
priority
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="max-w-2xl">
|
||||
<Reveal>
|
||||
<RichText data={about.body} />
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{hasClosing && (
|
||||
<section className="relative overflow-hidden bg-anouma-plum py-24 text-anouma-cream-light">
|
||||
<OrganicBlob tone="mauve" className="-left-24 top-0 h-96 w-96 opacity-25" />
|
||||
<div className="relative mx-auto max-w-3xl px-6 text-center sm:px-8">
|
||||
<Reveal>
|
||||
{about.closingLead && (
|
||||
<p className="text-lg text-anouma-cream-light/90">{about.closingLead}</p>
|
||||
)}
|
||||
{about.closingHighlight && (
|
||||
<p className="mt-6 text-balance font-serif text-3xl italic leading-snug sm:text-4xl">
|
||||
„{about.closingHighlight}“
|
||||
</p>
|
||||
)}
|
||||
{about.closingParagraph && (
|
||||
<p className="mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-anouma-cream-light/90">
|
||||
{about.closingParagraph}
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CTA
|
||||
title="Lust, dich kennenzulernen"
|
||||
lead="Wenn dich mein Weg anspricht, freue ich mich, dich in einem persönlichen Gespräch kennenzulernen."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap.js";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap.js";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
|
||||
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
const alternative = booking.proposedAlternative;
|
||||
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
|
||||
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
|
||||
}
|
||||
|
||||
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||
// (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({
|
||||
collection: "booking-requests",
|
||||
id,
|
||||
data: {
|
||||
status: "confirmed",
|
||||
date: alternative.date,
|
||||
startTime: alternative.startTime,
|
||||
endTime: alternative.endTime,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
console.error("accept-alternative failed", error);
|
||||
return NextResponse.json({ error: "Der Termin konnte nicht angenommen werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
|
||||
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (booking.status === "cancelled") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("booking cancel failed", error);
|
||||
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
offerSlug?: unknown;
|
||||
start?: unknown;
|
||||
appointmentType?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
|
||||
const startISO = typeof body.start === "string" ? body.start : "";
|
||||
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
|
||||
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
|
||||
|
||||
if (!offerSlug || !startISO) {
|
||||
return NextResponse.json({ error: "Angebot und Termin sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "offers",
|
||||
where: { slug: { equals: offerSlug } },
|
||||
limit: 1,
|
||||
overrideAccess: false,
|
||||
});
|
||||
const offer = docs[0];
|
||||
if (!offer || !offer.bookable || !offer.durationMinutes) {
|
||||
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
|
||||
}
|
||||
|
||||
const start = new Date(startISO);
|
||||
if (Number.isNaN(start.getTime()) || start < new Date()) {
|
||||
return NextResponse.json({ error: "Bitte wähle einen gültigen, zukünftigen Termin." }, { status: 400 });
|
||||
}
|
||||
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
|
||||
// Anna confirms (see collections/BookingRequests.ts).
|
||||
const dayStart = new Date(start);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(start);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
const slotsByDay = await getAvailableSlots(payload, {
|
||||
durationMinutes: offer.durationMinutes,
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
});
|
||||
const key = dayStart.toISOString().slice(0, 10);
|
||||
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
|
||||
if (!isStillFree) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "booking-requests",
|
||||
data: {
|
||||
user: user.id,
|
||||
offer: offer.id,
|
||||
date: start.toISOString(),
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
appointmentType,
|
||||
status: "pending",
|
||||
userMessage: message || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("booking request failed", error);
|
||||
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/booking/slots?offer=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD */
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const offerSlug = searchParams.get("offer");
|
||||
const fromParam = searchParams.get("from");
|
||||
const toParam = searchParams.get("to");
|
||||
|
||||
if (!offerSlug) {
|
||||
return NextResponse.json({ error: "offer ist erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { docs } = await payload.find({
|
||||
collection: "offers",
|
||||
where: { slug: { equals: offerSlug } },
|
||||
limit: 1,
|
||||
overrideAccess: false,
|
||||
});
|
||||
const offer = docs[0];
|
||||
if (!offer || !offer.bookable || !offer.durationMinutes) {
|
||||
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
|
||||
}
|
||||
|
||||
const from = fromParam ? new Date(fromParam) : new Date();
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
|
||||
// Cap the range so this can't be abused to run an expensive scan.
|
||||
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
|
||||
if (to.getTime() - from.getTime() > maxRangeMs) {
|
||||
return NextResponse.json({ error: "Zeitraum zu groß." }, { status: 400 });
|
||||
}
|
||||
|
||||
const slotsByDay = await getAvailableSlots(payload, { durationMinutes: offer.durationMinutes, from, to });
|
||||
|
||||
const result: Record<string, { start: string; end: string }[]> = {};
|
||||
for (const [day, slots] of slotsByDay) {
|
||||
result[day] = slots.map((s) => ({ start: s.start.toISOString(), end: s.end.toISOString() }));
|
||||
}
|
||||
|
||||
return NextResponse.json({ durationMinutes: offer.durationMinutes, slots: result });
|
||||
} catch (error) {
|
||||
console.error("booking slots failed", error);
|
||||
return NextResponse.json({ error: "Verfügbarkeiten konnten nicht geladen werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runEventReminders } from "@/lib/meeting/reminders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Intended to be called by an external cron job every few minutes, e.g.:
|
||||
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runEventReminders();
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
console.error("event-reminders cron failed", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,97 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { createMeetingToken } from "@/lib/meeting/token";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
|
||||
let body: { name?: unknown; password?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const password = typeof body.password === "string" ? body.password.trim() : "";
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "events",
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
});
|
||||
const event = docs[0];
|
||||
if (!event || !event.isOnline) {
|
||||
return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
const isHost = Boolean(user);
|
||||
|
||||
if (!isHost) {
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: "Bitte gib das Meeting-Passwort ein." }, { status: 400 });
|
||||
}
|
||||
if (!event.meetingPassword || !safeEqual(password, event.meetingPassword)) {
|
||||
return NextResponse.json({ error: "Das Meeting-Passwort ist nicht korrekt." }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime
|
||||
? combineDateAndTime(event.date, event.endTime)
|
||||
: new Date(start.getTime() + 60 * 60_000);
|
||||
|
||||
const status = getMeetingStatus({
|
||||
start,
|
||||
end,
|
||||
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
|
||||
closeAfterMinutes: settings.meetingCloseAfterMinutes,
|
||||
});
|
||||
|
||||
if (!canJoinMeeting(status)) {
|
||||
const message =
|
||||
status === "scheduled"
|
||||
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
|
||||
: "Dieser Termin ist bereits beendet.";
|
||||
return NextResponse.json({ error: message }, { status: 403 });
|
||||
}
|
||||
|
||||
const { token, participantId } = createMeetingToken({
|
||||
eventSlug: slug,
|
||||
name,
|
||||
role: isHost ? "host" : "participant",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token,
|
||||
participantId,
|
||||
role: isHost ? "host" : "participant",
|
||||
eventTitle: event.title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("meeting join failed", error);
|
||||
return NextResponse.json({ error: "Der Beitritt ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
|
||||
let body: { name?: unknown; email?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
const email = typeof body.email === "string" ? body.email.trim() : "";
|
||||
if (!name || !email || !email.includes("@")) {
|
||||
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "events",
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
});
|
||||
const event = docs[0];
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const { docs: existingForEmail } = await payload.find({
|
||||
collection: "event-registrations",
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: event.id } },
|
||||
{ email: { equals: email } },
|
||||
{ status: { equals: "registered" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
if (existingForEmail[0]) {
|
||||
return NextResponse.json({ ok: true, alreadyRegistered: true });
|
||||
}
|
||||
|
||||
if (event.maxParticipants) {
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: "event-registrations",
|
||||
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
|
||||
});
|
||||
if (totalDocs >= event.maxParticipants) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "event-registrations",
|
||||
data: { event: event.id, name, email, status: "registered" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("event registration failed", error);
|
||||
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/* 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). */
|
||||
@@ -0,0 +1,31 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { importMap } from "./admin/importMap.js";
|
||||
import "./custom.css";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Button } from "@/components/Button";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
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) —
|
||||
// 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").
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Seite nicht gefunden",
|
||||
};
|
||||
|
||||
export default function GlobalNotFound() {
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
|
||||
>
|
||||
<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. */}
|
||||
<Navbar offers={[]} />
|
||||
<main className="flex-1">
|
||||
<section className="relative overflow-hidden py-24 sm:py-32">
|
||||
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
|
||||
<div className="mx-auto grid max-w-5xl items-center gap-12 px-6 sm:px-8 lg:grid-cols-2 lg:px-12">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">404</p>
|
||||
<h1 className="mt-4 text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
|
||||
Diesen Weg gibt es hier nicht
|
||||
</h1>
|
||||
<p className="mt-5 max-w-md text-lg leading-relaxed text-anouma-plum">
|
||||
Die gesuchte Seite konnte nicht gefunden werden. Vielleicht findest du deinen Weg
|
||||
über die Startseite oder die Angebote weiter.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<Button href="/">Zur Startseite</Button>
|
||||
<Button href="/angebote" variant="secondary">
|
||||
Angebote ansehen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mx-auto aspect-square w-full max-w-sm">
|
||||
<ImagePlaceholder mood="sand" label="Seite nicht gefunden" className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/* 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">
|
||||
© {new Date().getFullYear()} {siteConfig.name}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/impressum", "/datenschutz"],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteConfig.domain}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
const routes = [
|
||||
"",
|
||||
"/ueber-mich",
|
||||
"/angebote",
|
||||
"/angebote/prozessbegleitung",
|
||||
"/angebote/doula-begleitung",
|
||||
"/angebote/erdenkinder",
|
||||
"/angebote/maedchenkreis",
|
||||
"/angebote/singkreise",
|
||||
"/angebote/singkreise/singen-im-kreis",
|
||||
"/angebote/singkreise/singen-fuer-schwangere",
|
||||
"/angebote/singkreise/mama-baby-singkreis",
|
||||
"/aktuelles",
|
||||
"/termin-buchen",
|
||||
"/kontakt",
|
||||
"/impressionen",
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return routes.map((route) => ({
|
||||
url: `${siteConfig.domain}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route === "" ? "weekly" : "monthly",
|
||||
priority: route === "" ? 1 : 0.7,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const WEEKDAYS = [
|
||||
{ label: "Montag", value: "monday" },
|
||||
{ label: "Dienstag", value: "tuesday" },
|
||||
{ label: "Mittwoch", value: "wednesday" },
|
||||
{ label: "Donnerstag", value: "thursday" },
|
||||
{ label: "Freitag", value: "friday" },
|
||||
{ label: "Samstag", value: "saturday" },
|
||||
{ label: "Sonntag", value: "sunday" },
|
||||
] as const;
|
||||
|
||||
export const Availability: CollectionConfig = {
|
||||
slug: "availability",
|
||||
labels: {
|
||||
singular: "Verfügbarkeit",
|
||||
plural: "Verfügbarkeiten",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "weekday",
|
||||
defaultColumns: ["weekday", "startTime", "endTime", "active"],
|
||||
group: "Buchungen",
|
||||
description: "Wiederkehrende wöchentliche Arbeitszeiten für Buchungsanfragen. Kein Eintrag = an diesem Tag nicht verfügbar.",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "weekday",
|
||||
type: "select",
|
||||
label: "Wochentag",
|
||||
required: true,
|
||||
options: [...WEEKDAYS],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Start",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Ende",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "active",
|
||||
type: "checkbox",
|
||||
label: "Aktiv",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: "Deaktivieren statt löschen, um eine Zeitspanne vorübergehend auszusetzen.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const AvailabilityOverrides: CollectionConfig = {
|
||||
slug: "availability-overrides",
|
||||
labels: {
|
||||
singular: "Ausnahme",
|
||||
plural: "Ausnahmen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "date",
|
||||
defaultColumns: ["date", "type", "reason"],
|
||||
group: "Buchungen",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } },
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
label: "Art",
|
||||
required: true,
|
||||
defaultValue: "unavailable",
|
||||
options: [
|
||||
{ label: "Nicht verfügbar (ganzer Tag)", value: "unavailable" },
|
||||
{ label: "Abweichende Uhrzeiten", value: "custom-hours" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => data?.type === "custom-hours" },
|
||||
fields: [
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Start",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Ende",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reason",
|
||||
type: "select",
|
||||
label: "Grund",
|
||||
options: [
|
||||
{ label: "Urlaub", value: "urlaub" },
|
||||
{ label: "Feiertag", value: "feiertag" },
|
||||
{ label: "Krankheit", value: "krankheit" },
|
||||
{ label: "Sonderöffnungszeit", value: "sonderoeffnungszeit" },
|
||||
{ label: "Sonstiges", value: "sonstiges" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,403 @@
|
||||
import { APIError } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||
import {
|
||||
alternativeProposedEmail,
|
||||
bookingCancelledAdminEmail,
|
||||
bookingConfirmedEmail,
|
||||
bookingRejectedEmail,
|
||||
bookingRequestReceivedEmail,
|
||||
newBookingRequestAdminEmail,
|
||||
} from "@/lib/email/bookingTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import type { Customer, Event, Offer } from "@/payload-types";
|
||||
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ label: "Vor Ort", value: "onsite" },
|
||||
{ label: "Online", value: "online" },
|
||||
] as const;
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
{ label: "Ausstehend", value: "pending" },
|
||||
{ label: "Bestätigt", value: "confirmed" },
|
||||
{ label: "Abgelehnt", value: "rejected" },
|
||||
{ label: "Storniert", value: "cancelled" },
|
||||
] as const;
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
const appointmentTypeLabel = (value: string) =>
|
||||
APPOINTMENT_TYPES.find((t) => t.value === value)?.label ?? value;
|
||||
|
||||
async function notifyAdmins(
|
||||
req: Parameters<CollectionAfterChangeHook>[0]["req"],
|
||||
template: { subject: string; html: string },
|
||||
) {
|
||||
const { docs: admins } = await req.payload.find({ collection: "users", limit: 50, req });
|
||||
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
|
||||
}
|
||||
|
||||
// Runs whenever a booking is about to become "confirmed": locks the day
|
||||
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
|
||||
// inside that same lock/transaction, and — for online appointments — creates
|
||||
// the linked video-call Event (reusing the existing meeting/password system)
|
||||
// before the booking itself is written.
|
||||
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => {
|
||||
if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const date = data.date ?? originalDoc.date;
|
||||
const startTime = data.startTime ?? originalDoc.startTime;
|
||||
const endTime = data.endTime ?? originalDoc.endTime;
|
||||
const appointmentType = data.appointmentType ?? originalDoc.appointmentType;
|
||||
|
||||
await acquireBookingDayLock(req, dateKey(date));
|
||||
|
||||
const { docs: sameDayConfirmed } = await req.payload.find({
|
||||
collection: "booking-requests",
|
||||
where: {
|
||||
and: [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ id: { not_equals: originalDoc.id } },
|
||||
{ date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } },
|
||||
{ date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } },
|
||||
],
|
||||
},
|
||||
req,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const newStart = new Date(startTime).getTime();
|
||||
const newEnd = new Date(endTime).getTime();
|
||||
const conflict = sameDayConfirmed.some((doc) => {
|
||||
const s = new Date(doc.startTime).getTime();
|
||||
const e = new Date(doc.endTime).getTime();
|
||||
return newStart < e && s < newEnd;
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
throw new APIError(
|
||||
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
|
||||
409,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
|
||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
|
||||
const event = (await req.payload.create({
|
||||
collection: "events",
|
||||
req,
|
||||
data: {
|
||||
title: offer.title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
category: "sonstiges",
|
||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
||||
isPrivateBooking: true,
|
||||
bookingRequest: originalDoc.id,
|
||||
},
|
||||
// Created as a draft so it never appears in public /termine listings
|
||||
// (see lib/payload/content.ts) — it's still reachable via its direct
|
||||
// join link, which is exactly what a private 1:1 booking needs.
|
||||
draft: true,
|
||||
})) as Event;
|
||||
data.linkedEvent = event.id;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, req }) => {
|
||||
try {
|
||||
const user = (doc.user && typeof doc.user === "object" ? doc.user : await req.payload.findByID({
|
||||
collection: "customers",
|
||||
id: doc.user,
|
||||
req,
|
||||
})) as Customer;
|
||||
const offer = (doc.offer && typeof doc.offer === "object" ? doc.offer : await req.payload.findByID({
|
||||
collection: "offers",
|
||||
id: doc.offer,
|
||||
req,
|
||||
})) as Offer;
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
||||
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
||||
const accountUrl = `${serverUrl}/konto/termine`;
|
||||
|
||||
if (operation === "create") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingRequestReceivedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
}),
|
||||
);
|
||||
await notifyAdmins(
|
||||
req,
|
||||
newBookingRequestAdminEmail({
|
||||
customerName: user.name,
|
||||
customerEmail: user.email,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
adminUrl,
|
||||
}),
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const statusChanged = previousDoc?.status !== doc.status;
|
||||
|
||||
if (statusChanged && doc.status === "confirmed") {
|
||||
let online: { joinUrl: string; password: string } | undefined;
|
||||
let onsite: { address: string; mapsUrl: string } | undefined;
|
||||
|
||||
if (doc.appointmentType === "online" && doc.linkedEvent) {
|
||||
const event = (typeof doc.linkedEvent === "object" ? doc.linkedEvent : await req.payload.findByID({
|
||||
collection: "events",
|
||||
id: doc.linkedEvent,
|
||||
req,
|
||||
})) as Event;
|
||||
online = {
|
||||
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
||||
password: event.meetingPassword || "—",
|
||||
};
|
||||
} else if (doc.appointmentType === "onsite") {
|
||||
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
||||
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
|
||||
const address = addressParts.join(", ");
|
||||
onsite = { address, mapsUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` };
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingConfirmedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
online,
|
||||
onsite,
|
||||
}),
|
||||
);
|
||||
} else if (statusChanged && doc.status === "rejected") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
bookingRejectedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
}),
|
||||
);
|
||||
} else if (statusChanged && doc.status === "cancelled") {
|
||||
await notifyAdmins(
|
||||
req,
|
||||
bookingCancelledAdminEmail({
|
||||
customerName: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: fmtTime(doc.startTime),
|
||||
adminUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
|
||||
if (proposedNew && doc.status === "pending") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
alternativeProposedEmail({
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
originalDateLabel: fmtDate(doc.date),
|
||||
originalTimeLabel: fmtTime(doc.startTime),
|
||||
altDateLabel: fmtDate(doc.proposedAlternative.date),
|
||||
altTimeLabel: fmtTime(doc.proposedAlternative.startTime),
|
||||
accountUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Email delivery must never break the booking write itself.
|
||||
req.payload.logger.error({ err, msg: "booking-requests notifyByEmail failed" });
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const BookingRequests: CollectionConfig = {
|
||||
slug: "booking-requests",
|
||||
labels: {
|
||||
singular: "Buchungsanfrage",
|
||||
plural: "Buchungsanfragen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
||||
group: "Buchungen",
|
||||
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
|
||||
},
|
||||
access: {
|
||||
// Customer-initiated writes always go through the vetted /api/booking/*
|
||||
// routes (Local API, elevated access, after the route verifies
|
||||
// ownership) — never directly through REST/GraphQL. This keeps a
|
||||
// customer from ever reading/writing another customer's booking, or
|
||||
// setting their own status to "confirmed" directly.
|
||||
create: isAdmin,
|
||||
read: ({ req }) => {
|
||||
if (req.user?.collection === "users") return true;
|
||||
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
||||
return false;
|
||||
},
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [handleConfirmation],
|
||||
afterChange: [notifyByEmail],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "customers",
|
||||
label: "Nutzer:in",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "offer",
|
||||
type: "relationship",
|
||||
relationTo: "offers",
|
||||
label: "Angebot",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Startzeit",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Endzeit",
|
||||
required: true,
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "appointmentType",
|
||||
type: "select",
|
||||
label: "Terminart",
|
||||
required: true,
|
||||
defaultValue: "onsite",
|
||||
options: [...APPOINTMENT_TYPES],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [...BOOKING_STATUSES],
|
||||
admin: {
|
||||
description: "Auf „Bestätigt“ setzen und speichern, um den Termin verbindlich zu machen (Konfliktprüfung läuft automatisch).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "userMessage",
|
||||
type: "textarea",
|
||||
label: "Nachricht der Nutzerin/des Nutzers",
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "internalNote",
|
||||
type: "textarea",
|
||||
label: "Interne Notiz",
|
||||
access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
|
||||
admin: { description: "Nur für Anna sichtbar." },
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Alternativer Termin",
|
||||
fields: [
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "proposedAlternative",
|
||||
type: "group",
|
||||
label: "",
|
||||
fields: [
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Alternatives Datum",
|
||||
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Alternative Startzeit",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Alternative Endzeit",
|
||||
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "linkedEvent",
|
||||
type: "relationship",
|
||||
relationTo: "events",
|
||||
label: "Verknüpfter Video-Termin",
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
|
||||
const user = req.user;
|
||||
if (!user) return false;
|
||||
if (user.collection === "users") return true;
|
||||
if (user.collection === "customers") return { id: { equals: user.id } };
|
||||
return false;
|
||||
};
|
||||
|
||||
export const Customers: CollectionConfig = {
|
||||
slug: "customers",
|
||||
labels: {
|
||||
singular: "Kundin/Kunde",
|
||||
plural: "Kund:innen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "phone"],
|
||||
group: "Buchungen",
|
||||
description: "Konten der Website-Besucher:innen (nicht die Admin-Zugänge unter „Benutzer:innen“).",
|
||||
},
|
||||
auth: {
|
||||
maxLoginAttempts: 8,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
access: {
|
||||
// Anyone can create their own account (registration); reading/updating
|
||||
// is restricted to the account owner or an admin.
|
||||
create: () => true,
|
||||
read: isSelfOrAdmin,
|
||||
update: isSelfOrAdmin,
|
||||
delete: ({ req }) => req.user?.collection === "users",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "phone",
|
||||
type: "text",
|
||||
label: "Telefonnummer",
|
||||
required: false,
|
||||
admin: {
|
||||
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
},
|
||||
},
|
||||
// "email" and "password" are added automatically by the auth config.
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const EventRegistrations: CollectionConfig = {
|
||||
slug: "event-registrations",
|
||||
labels: {
|
||||
singular: "Anmeldung",
|
||||
plural: "Anmeldungen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
||||
group: "Inhalte",
|
||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
},
|
||||
access: {
|
||||
// Visitors register themselves through the public event page.
|
||||
create: () => true,
|
||||
read: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "event",
|
||||
type: "relationship",
|
||||
relationTo: "events",
|
||||
label: "Termin",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
|
||||
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "registered",
|
||||
options: [
|
||||
{ label: "Angemeldet", value: "registered" },
|
||||
{ label: "Storniert", value: "cancelled" },
|
||||
{ label: "Teilgenommen", value: "attended" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "registeredAt",
|
||||
type: "date",
|
||||
label: "Registriert am",
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime" } },
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { position: "sidebar" },
|
||||
fields: [
|
||||
{
|
||||
name: "reminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
import { generateMeetingPassword } from "@/lib/meeting/password";
|
||||
|
||||
export const EVENT_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula", value: "doula" },
|
||||
{ label: "Erdenkinder", value: "erdenkinder" },
|
||||
{ label: "Mädchenkreis", value: "maedchenkreis" },
|
||||
{ label: "Singen im Kreis", value: "singen-im-kreis" },
|
||||
{ label: "Singen für Schwangere", value: "singen-fuer-schwangere" },
|
||||
{ label: "Singen für Mamas mit Baby", value: "singen-fuer-mamas-mit-baby" },
|
||||
{ label: "Sonstiges", value: "sonstiges" },
|
||||
] as const;
|
||||
|
||||
// Auto-generates the meeting password for new online events, and
|
||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||
// Never derived from the event id/slug — always a fresh random value.
|
||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
|
||||
if (!data?.isOnline) return data;
|
||||
if (!data.meetingPassword || data.regeneratePassword) {
|
||||
data.meetingPassword = generateMeetingPassword();
|
||||
}
|
||||
data.regeneratePassword = false;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const Events: CollectionConfig = {
|
||||
slug: "events",
|
||||
labels: {
|
||||
singular: "Termin",
|
||||
plural: "Termine",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "date", "category", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: { interval: 1500 },
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [setMeetingPassword],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
label: "Beschreibung",
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
label: "Datum",
|
||||
required: true,
|
||||
admin: {
|
||||
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
|
||||
width: "34%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "startTime",
|
||||
type: "date",
|
||||
label: "Startzeit",
|
||||
admin: {
|
||||
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
|
||||
width: "33%",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "endTime",
|
||||
type: "date",
|
||||
label: "Endzeit",
|
||||
admin: {
|
||||
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
|
||||
width: "33%",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "location",
|
||||
type: "text",
|
||||
label: "Ort",
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
label: "Kategorie",
|
||||
required: true,
|
||||
defaultValue: "sonstiges",
|
||||
options: [...EVENT_CATEGORIES],
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Bild",
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "maxParticipants",
|
||||
type: "number",
|
||||
label: "Maximale Teilnehmerzahl",
|
||||
min: 1,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "registrationRequired",
|
||||
type: "checkbox",
|
||||
label: "Anmeldung erforderlich",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "registrationInfo",
|
||||
type: "text",
|
||||
label: "Anmeldelink bzw. Kontaktmöglichkeit",
|
||||
admin: {
|
||||
description: "z. B. eine E-Mail-Adresse, ein Link oder ein Hinweistext.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Online-Termin (Video-Call)",
|
||||
fields: [
|
||||
{
|
||||
name: "isOnline",
|
||||
type: "checkbox",
|
||||
label: "Online-Termin",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "Aktiviert Meeting-Passwort, Beitrittsseite und Erinnerungs-E-Mails.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "meetingPassword",
|
||||
type: "text",
|
||||
label: "Meeting-Passwort",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
condition: (data) => Boolean(data?.isOnline),
|
||||
description: "Wird automatisch erzeugt. Ist nicht Teil des Meeting-Links.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "regeneratePassword",
|
||||
type: "checkbox",
|
||||
label: "Neues Passwort generieren (beim Speichern)",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
condition: (data) => Boolean(data?.isOnline),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => Boolean(data?.isOnline) },
|
||||
fields: [
|
||||
{
|
||||
name: "reminder60Enabled",
|
||||
type: "checkbox",
|
||||
label: "Erinnerung 60 Minuten vorher",
|
||||
defaultValue: true,
|
||||
admin: { width: "34%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Enabled",
|
||||
type: "checkbox",
|
||||
label: "Erinnerung 30 Minuten vorher",
|
||||
defaultValue: true,
|
||||
admin: { width: "33%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminderEnabled",
|
||||
type: "checkbox",
|
||||
label: "Auch Host per E-Mail erinnern",
|
||||
defaultValue: true,
|
||||
admin: { width: "33%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => Boolean(data?.isOnline) },
|
||||
fields: [
|
||||
{
|
||||
name: "hostReminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "isPrivateBooking",
|
||||
type: "checkbox",
|
||||
label: "Aus privater Buchungsanfrage erzeugt",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
readOnly: true,
|
||||
description: "Wird automatisch gesetzt, wenn dieser Termin aus einer bestätigten Einzelbuchung entstanden ist. Solche Termine bleiben unveröffentlicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bookingRequest",
|
||||
type: "relationship",
|
||||
relationTo: "booking-requests",
|
||||
label: "Zugehörige Buchungsanfrage",
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
labels: {
|
||||
singular: "Bild",
|
||||
plural: "Bilder",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "alt",
|
||||
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
|
||||
},
|
||||
access: {
|
||||
// Images are public so the website can display them without a login.
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "..", "media"),
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
|
||||
imageSizes: [
|
||||
{ name: "thumbnail", width: 400, position: "centre" },
|
||||
{ name: "card", width: 800, position: "centre" },
|
||||
{ name: "hero", width: 1600, position: "centre" },
|
||||
],
|
||||
adminThumbnail: "thumbnail",
|
||||
formatOptions: { format: "webp", options: { quality: 82 } },
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
label: "Alt-Text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurze Beschreibung des Bildes für Screenreader und Suchmaschinen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caption",
|
||||
type: "textarea",
|
||||
label: "Bildbeschreibung",
|
||||
admin: {
|
||||
description: "Optionale, längere Beschreibung (z. B. für eine Bildunterschrift).",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
|
||||
// Groups the 7 individual offers for the mega menu and the /angebote
|
||||
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
|
||||
// bundles the three singing circles — matching the site's navigation).
|
||||
export const OFFER_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
||||
{ label: "Kindergruppen", value: "kindergruppen" },
|
||||
{ label: "Singkreise", value: "singkreise" },
|
||||
] as const;
|
||||
|
||||
export const Offers: CollectionConfig = {
|
||||
slug: "offers",
|
||||
labels: {
|
||||
singular: "Angebot",
|
||||
plural: "Angebote",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "category", "order", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
|
||||
},
|
||||
defaultSort: "order",
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "shortDescription",
|
||||
type: "textarea",
|
||||
label: "Kurzbeschreibung",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurzer Satz für Karten, Menü und Übersicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
label: "Beschreibung",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Bild",
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
label: "Kategorie",
|
||||
required: true,
|
||||
options: [...OFFER_CATEGORIES],
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
type: "number",
|
||||
label: "Reihenfolge",
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Kleinere Zahl erscheint zuerst.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
type: "select",
|
||||
label: "Sichtbarkeit",
|
||||
required: true,
|
||||
defaultValue: "public",
|
||||
options: [
|
||||
{ label: "Öffentlich (Menü, Übersicht)", value: "public" },
|
||||
{ label: "Privat (nur über direkten Link/Buchung)", value: "private" },
|
||||
],
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Private Angebote wie „Einzelbegleitung“ erscheinen nicht im Menü oder auf /angebote.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bookable",
|
||||
type: "checkbox",
|
||||
label: "Buchbar",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "price",
|
||||
type: "text",
|
||||
label: "Preis bzw. Preistext",
|
||||
admin: { width: "50%", description: "z. B. „80 €“ oder „auf Anfrage“." },
|
||||
},
|
||||
{
|
||||
name: "durationMinutes",
|
||||
type: "number",
|
||||
label: "Dauer (Minuten)",
|
||||
min: 5,
|
||||
admin: { width: "50%", condition: (data) => Boolean(data?.bookable) },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
||||
import { slugField } from "@/fields/slug";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
labels: {
|
||||
singular: "Beitrag",
|
||||
plural: "Beiträge",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "publishDate", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Neuigkeiten und Beiträge für die Seite „Aktuelles“.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: { interval: 1500 },
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: publishedOrAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
label: "Titel",
|
||||
required: true,
|
||||
},
|
||||
slugField(),
|
||||
{
|
||||
name: "teaser",
|
||||
type: "textarea",
|
||||
label: "Teaser",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Kurzer Anrisstext für die Übersicht.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
label: "Inhalt",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "coverImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Titelbild",
|
||||
},
|
||||
{
|
||||
name: "publishDate",
|
||||
type: "date",
|
||||
label: "Veröffentlichungsdatum",
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
labels: {
|
||||
singular: "Benutzer:in",
|
||||
plural: "Benutzer:innen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "role"],
|
||||
description: "Zugänge für den geschützten Admin-Bereich.",
|
||||
},
|
||||
auth: {
|
||||
// Payload shows a "create first user" screen automatically when this
|
||||
// collection is empty, bypassing normal access control just for that
|
||||
// one-time setup — no hardcoded default password is ever needed.
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
label: "Rolle",
|
||||
required: true,
|
||||
defaultValue: "admin",
|
||||
// Only "admin" exists today; add further roles here later and extend
|
||||
// the checks in access/index.ts to match.
|
||||
options: [{ label: "Administrator:in", value: "admin" }],
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
},
|
||||
// "email" and "password" are added automatically by `auth: true`-style config.
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
type AngebotCardProps = {
|
||||
href: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
mood: ImageMood;
|
||||
imageSrc?: string;
|
||||
imageAlt?: string;
|
||||
size?: "default" | "large";
|
||||
};
|
||||
|
||||
export function AngebotCard({
|
||||
href,
|
||||
title,
|
||||
tagline,
|
||||
mood,
|
||||
imageSrc,
|
||||
imageAlt,
|
||||
size = "default",
|
||||
}: AngebotCardProps) {
|
||||
return (
|
||||
<Link href={href} className="group block">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[2.5rem] ${
|
||||
size === "large" ? "aspect-[4/3]" : "aspect-[4/5]"
|
||||
}`}
|
||||
>
|
||||
<ImagePlaceholder
|
||||
mood={mood}
|
||||
label={title}
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
shape="soft"
|
||||
className="h-full w-full transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-anouma-plum/70 via-anouma-plum/0 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-7">
|
||||
<h3 className="font-serif text-2xl font-medium text-white">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
|
||||
Mehr erfahren
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
|
||||
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type Crumb = { title: string; href?: string };
|
||||
|
||||
export function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-2 text-sm text-anouma-plum/80">
|
||||
<li>
|
||||
<Link href="/" className="hover:text-anouma-mauve-dark">
|
||||
Startseite
|
||||
</Link>
|
||||
</li>
|
||||
{items.map((item, i) => (
|
||||
<li key={item.title} className="flex items-center gap-2">
|
||||
<span aria-hidden="true" className="text-anouma-taupe">
|
||||
/
|
||||
</span>
|
||||
{item.href && i !== items.length - 1 ? (
|
||||
<Link href={item.href} className="hover:text-anouma-mauve-dark">
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" className="text-anouma-plum">
|
||||
{item.title}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
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.
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
||||
secondary:
|
||||
"border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white",
|
||||
ghost:
|
||||
"text-anouma-plum underline underline-offset-4 decoration-anouma-mauve-dark/50 hover:decoration-anouma-mauve-dark",
|
||||
// For use on dark (plum/mauve-dark) section backgrounds, e.g. CTA.
|
||||
invert: "bg-anouma-cream-light text-anouma-plum hover:bg-white",
|
||||
invertOutline: "border border-white/50 text-white hover:bg-white hover:text-anouma-plum",
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Button({ href, children, variant = "primary", className = "" }: ButtonProps) {
|
||||
const isExternal = href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:");
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
|
||||
const classes = `${base} ${variants[variant]} ${className}`;
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} className={classes} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { Container } from "@/components/Section";
|
||||
|
||||
type CTAProps = {
|
||||
title: string;
|
||||
lead?: string;
|
||||
primaryHref?: string;
|
||||
primaryLabel?: string;
|
||||
secondaryHref?: string;
|
||||
secondaryLabel?: string;
|
||||
};
|
||||
|
||||
export function CTA({
|
||||
title,
|
||||
lead,
|
||||
primaryHref = "/termin-buchen",
|
||||
primaryLabel = "Termin buchen",
|
||||
secondaryHref = "/kontakt",
|
||||
secondaryLabel = "Kontakt aufnehmen",
|
||||
}: CTAProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-anouma-plum py-20 text-anouma-cream-light sm:py-24">
|
||||
<OrganicBlob tone="mauve" className="-left-20 -top-20 h-80 w-80 opacity-30" />
|
||||
<OrganicBlob tone="rose" className="-bottom-24 -right-16 h-72 w-72 opacity-20" />
|
||||
<Container className="relative text-center">
|
||||
<h2 className="mx-auto max-w-2xl text-balance font-serif text-4xl font-medium leading-tight sm:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{lead && (
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-anouma-cream-light/85">
|
||||
{lead}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-9 flex flex-wrap items-center justify-center gap-4">
|
||||
<Button href={primaryHref} variant="invert">
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
<Button href={secondaryHref} variant="invertOutline">
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
type ContactFormProps = {
|
||||
toEmail: string;
|
||||
subjectPrefix?: string;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the visitor's email client with a pre-filled message. There is no
|
||||
* backend configured yet — swap this for a Server Action once a mail
|
||||
* provider is connected.
|
||||
*/
|
||||
export function ContactForm({
|
||||
toEmail,
|
||||
subjectPrefix = "Nachricht über anouma.org",
|
||||
submitLabel = "Nachricht senden",
|
||||
}: ContactFormProps) {
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const name = String(data.get("name") ?? "");
|
||||
const email = String(data.get("email") ?? "");
|
||||
const message = String(data.get("message") ?? "");
|
||||
|
||||
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
|
||||
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
|
||||
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Deine Nachricht
|
||||
</label>
|
||||
<textarea id="message" name="message" required rows={5} className={fieldClass} />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
{sent && (
|
||||
<p className="text-sm text-anouma-olive" role="status">
|
||||
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
|
||||
{toEmail}.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import type { Event } from "@/payload-types";
|
||||
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
|
||||
|
||||
export function EventTeaserCard({ event }: { event: Event }) {
|
||||
const { day, month } = formatEventDateParts(event.date);
|
||||
const time = formatTimeRange(event.startTime, event.endTime);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/termine/${event.slug}`}
|
||||
className="group flex gap-5 rounded-3xl bg-background p-6 transition-colors hover:bg-anouma-cream-light"
|
||||
>
|
||||
<div className="flex h-16 w-16 shrink-0 flex-col items-center justify-center rounded-2xl bg-anouma-mauve-dark text-white">
|
||||
<span className="font-serif text-xl font-semibold leading-none">{day}</span>
|
||||
<span className="mt-1 text-[10px] font-medium uppercase tracking-widest">{month}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif text-lg font-medium text-anouma-plum">{event.title}</h3>
|
||||
{time && <p className="mt-1 text-sm text-anouma-plum">{time}</p>}
|
||||
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
|
||||
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
|
||||
Mehr erfahren
|
||||
<svg
|
||||
width="12"
|
||||
height="9"
|
||||
viewBox="0 0 14 10"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
d="M1 5h11.5M8 1l4.5 4L8 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import { groupOffersByCategory } from "@/lib/angebote";
|
||||
import { footerNav, mainNav, siteConfig } from "@/lib/site";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
|
||||
export async function Footer() {
|
||||
const offers = await getOffers();
|
||||
const groups = groupOffersByCategory(offers);
|
||||
|
||||
return (
|
||||
<footer className="bg-anouma-plum text-anouma-cream-light">
|
||||
<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]">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Navigation
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{mainNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-sm text-anouma-cream-light/90 hover:text-white">
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link href="/termin-buchen" className="text-sm text-anouma-cream-light/90 hover:text-white">
|
||||
Termin buchen
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Angebote
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{groups.map((group) => (
|
||||
<li key={group.category}>
|
||||
<Link
|
||||
href={group.offers.length === 1 ? `/angebote/${group.offers[0].slug}` : group.href}
|
||||
className="text-sm text-anouma-cream-light/90 hover:text-white"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Kontakt
|
||||
</h3>
|
||||
<p className="mt-4 text-sm leading-relaxed text-anouma-cream-light/90">
|
||||
Ich freue mich, von dir zu hören.
|
||||
</p>
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className="mt-3 inline-block text-sm font-medium underline underline-offset-4 hover:text-white"
|
||||
>
|
||||
Zum Kontaktformular
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-col gap-4 border-t border-anouma-cream-light/20 pt-8 text-sm text-anouma-cream-light/90 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>© {new Date().getFullYear()} {siteConfig.name}</p>
|
||||
<ul className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{footerNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="hover:text-white">
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
type HeroProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
mood?: ImageMood;
|
||||
imageSrc?: string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
|
||||
export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSrc, imageAlt }: HeroProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden pb-20 pt-16 sm:pb-28 sm:pt-24">
|
||||
<OrganicBlob tone="rose" className="-right-24 -top-24 h-[28rem] w-[28rem]" />
|
||||
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
|
||||
|
||||
<div className="relative mx-auto grid max-w-6xl gap-14 px-6 sm:px-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:px-12">
|
||||
<div className="animate-fade-up">
|
||||
{eyebrow && (
|
||||
<p className="mb-5 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-balance font-serif text-5xl font-medium leading-[1.08] text-anouma-plum sm:text-6xl lg:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
{children && (
|
||||
<div className="mt-7 max-w-xl text-lg leading-relaxed text-anouma-plum sm:text-xl">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="mt-9 flex flex-wrap gap-4">{actions}</div>}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
priority
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Image from "next/image";
|
||||
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.
|
||||
*/
|
||||
|
||||
const moodGradients: Record<ImageMood, string> = {
|
||||
plum: "linear-gradient(135deg, #a97070 0%, #66505f 55%, #453238 100%)",
|
||||
dustyrose: "linear-gradient(135deg, #ecc8be 0%, #a97070 55%, #8b616d 100%)",
|
||||
moss: "linear-gradient(135deg, #d4c1a5 0%, #89937c 55%, #4f5b45 100%)",
|
||||
caramel: "linear-gradient(135deg, #efd5c8 0%, #d4c1a5 55%, #a47c60 100%)",
|
||||
mauve: "linear-gradient(135deg, #e5c4bc 0%, #8b6f7d 55%, #66505f 100%)",
|
||||
sand: "linear-gradient(135deg, #f2e2d6 0%, #e8dcc8 55%, #a89580 100%)",
|
||||
rose: "linear-gradient(135deg, #f0e0d3 0%, #e0b4b1 55%, #d19ca1 100%)",
|
||||
peach: "linear-gradient(135deg, #f2e2d6 0%, #ecc8be 55%, #e0b4b1 100%)",
|
||||
};
|
||||
|
||||
const moodIcons: Record<ImageMood, React.ReactNode> = {
|
||||
plum: (
|
||||
<path d="M32 4c8 6 12 14 12 22 0 9-6.5 16-12 16S20 35 20 26c0-8 4-16 12-22Zm0 60V38" />
|
||||
),
|
||||
dustyrose: <path d="M32 8c9 8 18 18 18 30a18 18 0 1 1-36 0c0-12 9-22 18-30Z" />,
|
||||
moss: (
|
||||
<path d="M32 60V24M32 24c-10 0-18-8-18-18 10 0 18 8 18 18Zm0 0c0-10 8-18 18-18 0 10-8 18-18 18Z" />
|
||||
),
|
||||
caramel: (
|
||||
<path d="M32 6c6 10 14 20 14 30a14 14 0 1 1-28 0c0-10 8-20 14-30Z" />
|
||||
),
|
||||
mauve: <path d="M8 32c8-14 16-20 24-20s16 6 24 20c-8 14-16 20-24 20S16 46 8 32Z" />,
|
||||
sand: (
|
||||
<path d="M6 24c8-6 12-6 20 0s12 6 20 0M6 40c8-6 12-6 20 0s12 6 20 0" />
|
||||
),
|
||||
rose: (
|
||||
<path d="M32 34a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0 0a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm-14-10a10 10 0 1 1 14 10 10 10 0 0 1-14-10Zm28 0a10 10 0 1 0-14 10 10 10 0 0 0 14-10Z" />
|
||||
),
|
||||
peach: <path d="M8 40c6-20 18-32 24-32s18 12 24 32c-8 8-16 12-24 12s-16-4-24-12Z" />,
|
||||
};
|
||||
|
||||
type ImagePlaceholderProps = {
|
||||
mood: ImageMood;
|
||||
label: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
shape?: "blob" | "soft";
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function ImagePlaceholder({
|
||||
mood,
|
||||
label,
|
||||
src,
|
||||
alt,
|
||||
className = "",
|
||||
shape = "blob",
|
||||
priority = false,
|
||||
}: ImagePlaceholderProps) {
|
||||
const radius =
|
||||
shape === "blob"
|
||||
? "rounded-[62%_38%_53%_47%/45%_42%_58%_55%]"
|
||||
: "rounded-[2.5rem]";
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${radius} ${className}`}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
fill
|
||||
priority={priority}
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${radius} ${className}`}
|
||||
style={{ background: moodGradients[mood] }}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full opacity-[0.14] mix-blend-overlay"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<filter id={`grain-${mood}`}>
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter={`url(#grain-${mood})`} />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
className="absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 text-white/40 sm:h-20 sm:w-20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{moodIcons[mood]}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { groupOffersByCategory } from "@/lib/angebote";
|
||||
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
||||
import type { Offer } from "@/payload-types";
|
||||
|
||||
export function Navbar({ offers }: { offers: Offer[] }) {
|
||||
const pathname = usePathname();
|
||||
const groups = groupOffersByCategory(offers);
|
||||
const [megaOpen, setMegaOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobileAngeboteOpen, setMobileAngeboteOpen] = useState(false);
|
||||
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close open menus when the route changes, adjusted during render rather
|
||||
// than in an effect (see https://react.dev/learn/you-might-not-need-an-effect).
|
||||
const [lastPathname, setLastPathname] = useState(pathname);
|
||||
if (pathname !== lastPathname) {
|
||||
setLastPathname(pathname);
|
||||
setMobileOpen(false);
|
||||
setMegaOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileOpen ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setMegaOpen(false);
|
||||
setMobileOpen(false);
|
||||
}
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
if (navRef.current && !navRef.current.contains(e.target as Node)) {
|
||||
setMegaOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function openMega() {
|
||||
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||
setMegaOpen(true);
|
||||
}
|
||||
|
||||
function scheduleCloseMega() {
|
||||
closeTimeout.current = setTimeout(() => setMegaOpen(false), 150);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
|
||||
<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"
|
||||
>
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
|
||||
<nav ref={navRef} className="hidden items-center gap-8 lg:flex" aria-label="Hauptnavigation">
|
||||
{mainNav.map((item) =>
|
||||
item.title === "Angebote" ? (
|
||||
<div
|
||||
key={item.href}
|
||||
className="relative"
|
||||
onMouseEnter={openMega}
|
||||
onMouseLeave={scheduleCloseMega}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={megaOpen}
|
||||
onClick={() => setMegaOpen((v) => !v)}
|
||||
>
|
||||
{item.title}
|
||||
<svg
|
||||
width="10"
|
||||
height="6"
|
||||
viewBox="0 0 10 6"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-300 ${megaOpen ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{megaOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
|
||||
{groups.map((group) => {
|
||||
const isSingle = group.offers.length === 1;
|
||||
const groupHref = isSingle
|
||||
? `/angebote/${group.offers[0].slug}`
|
||||
: group.href;
|
||||
return (
|
||||
<div key={group.category}>
|
||||
<Link
|
||||
href={groupHref}
|
||||
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
{!isSingle && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{group.offers.map((offer) => (
|
||||
<li key={offer.slug}>
|
||||
<Link
|
||||
href={`/angebote/${offer.slug}`}
|
||||
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{offer.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isSingle && (
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
||||
{group.offers[0].shortDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
|
||||
<Link
|
||||
href="/angebote"
|
||||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
||||
>
|
||||
Alle Angebote im Überblick
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
|
||||
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
|
||||
>
|
||||
{ctaNav.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-anouma-plum lg:hidden"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<motion.path
|
||||
animate={mobileOpen ? { d: "M5 5l14 14" } : { d: "M4 7h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
<motion.path
|
||||
animate={mobileOpen ? { opacity: 0 } : { opacity: 1, d: "M4 12h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.path
|
||||
animate={mobileOpen ? { d: "M5 19l14-14" } : { d: "M4 17h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="overflow-y-auto border-t border-anouma-taupe/15 bg-background lg:hidden"
|
||||
style={{ maxHeight: "calc(100dvh - 5rem)" }}
|
||||
>
|
||||
<nav className="flex flex-col px-6 py-6" aria-label="Mobile Navigation">
|
||||
{mainNav.map((item) =>
|
||||
item.title === "Angebote" ? (
|
||||
<div key={item.href} className="border-b border-anouma-taupe/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-4 text-lg font-medium text-anouma-plum"
|
||||
aria-expanded={mobileAngeboteOpen}
|
||||
onClick={() => setMobileAngeboteOpen((v) => !v)}
|
||||
>
|
||||
{item.title}
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 10 6"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-300 ${mobileAngeboteOpen ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{mobileAngeboteOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden pb-4"
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const isSingle = group.offers.length === 1;
|
||||
const groupHref = isSingle
|
||||
? `/angebote/${group.offers[0].slug}`
|
||||
: group.href;
|
||||
return (
|
||||
<div key={group.category} className="mb-4">
|
||||
<Link
|
||||
href={groupHref}
|
||||
className="block py-2 text-base font-medium text-anouma-mauve-dark"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
{!isSingle && (
|
||||
<ul className="ml-3 space-y-1 border-l border-anouma-taupe/20 pl-4">
|
||||
{group.offers.map((offer) => (
|
||||
<li key={offer.slug}>
|
||||
<Link
|
||||
href={`/angebote/${offer.slug}`}
|
||||
className="block py-2 text-sm text-anouma-plum"
|
||||
>
|
||||
{offer.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
|
||||
>
|
||||
{ctaNav.title}
|
||||
</Link>
|
||||
</nav>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type OrganicBlobProps = {
|
||||
className?: string;
|
||||
tone?: "rose" | "sage" | "cream" | "mauve";
|
||||
};
|
||||
|
||||
const tones: Record<NonNullable<OrganicBlobProps["tone"]>, string> = {
|
||||
rose: "bg-anouma-rose-soft",
|
||||
sage: "bg-anouma-sage",
|
||||
cream: "bg-anouma-cream-beige-2",
|
||||
mauve: "bg-anouma-mauve",
|
||||
};
|
||||
|
||||
/** Purely decorative, blurred organic shape used to add warmth behind content. */
|
||||
export function OrganicBlob({ className = "", tone = "rose" }: OrganicBlobProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute rounded-[60%_40%_65%_35%/45%_55%_45%_55%] opacity-40 blur-3xl ${tones[tone]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
|
||||
type PageHeaderProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lead?: ReactNode;
|
||||
crumbs?: Crumb[];
|
||||
};
|
||||
|
||||
export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
|
||||
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
|
||||
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
|
||||
{crumbs && <Breadcrumbs items={crumbs} />}
|
||||
{eyebrow && (
|
||||
<p className="mb-4 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="max-w-3xl text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
{lead && (
|
||||
<div className="mt-5 max-w-2xl text-lg leading-relaxed text-anouma-plum">{lead}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Visibly marks a section whose real copy is not yet available in texte.txt. */
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
as?: "div" | "li";
|
||||
};
|
||||
|
||||
/** Fades and lifts content into place once it scrolls into view. */
|
||||
export function Reveal({ children, delay = 0, className, as = "div" }: RevealProps) {
|
||||
const Component = motion[as];
|
||||
return (
|
||||
<Component
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={variants}
|
||||
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
RichText as LexicalRichText,
|
||||
type JSXConvertersFunction,
|
||||
} from "@payloadcms/richtext-lexical/react";
|
||||
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
|
||||
|
||||
const converters: JSXConvertersFunction<DefaultNodeTypes> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
if (!children?.length) return null;
|
||||
return <p className="text-lg leading-relaxed text-anouma-plum">{children}</p>;
|
||||
},
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
const Tag = node.tag;
|
||||
return (
|
||||
<Tag className="mt-4 font-serif text-3xl font-medium text-anouma-plum">{children}</Tag>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
type RichTextProps = {
|
||||
data: NonNullable<Parameters<typeof LexicalRichText>[0]["data"]>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Renders a Payload Lexical richText field with the ANOUMA reading styles. */
|
||||
export function RichText({ data, className = "space-y-6" }: RichTextProps) {
|
||||
return <LexicalRichText data={data} converters={converters} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ContainerProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Container({ children, className = "" }: ContainerProps) {
|
||||
return <div className={`mx-auto w-full max-w-6xl px-6 sm:px-8 lg:px-12 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
type SectionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
tone?: "cream" | "warm" | "plain" | "mauve";
|
||||
};
|
||||
|
||||
const tones: Record<NonNullable<SectionProps["tone"]>, string> = {
|
||||
cream: "bg-anouma-cream-light",
|
||||
warm: "bg-anouma-cream-beige",
|
||||
plain: "bg-background",
|
||||
mauve: "bg-anouma-plum text-anouma-cream-light",
|
||||
};
|
||||
|
||||
export function Section({ children, className = "", id, tone = "plain" }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className={`py-20 sm:py-28 ${tones[tone]} ${className}`}>
|
||||
<Container>{children}</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionHeadingProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lead?: string;
|
||||
align?: "left" | "center";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
align = "left",
|
||||
className = "",
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-2xl ${align === "center" ? "mx-auto text-center" : ""} ${className}`}
|
||||
>
|
||||
{eyebrow && (
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{lead && (
|
||||
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">{lead}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = new FormData(e.currentTarget);
|
||||
const email = String(data.get("email") ?? "");
|
||||
const password = String(data.get("password") ?? "");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customers/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("E-Mail oder Passwort ist nicht korrekt.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
router.push(searchParams.get("next") || "/konto");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Passwort
|
||||
</label>
|
||||
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird geprüft …" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/customers/logout", { method: "POST", credentials: "include" });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleLogout} className={className}>
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("saving");
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
const res = await fetch(`/api/customers/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: String(data.get("name") ?? ""),
|
||||
phone: String(data.get("phone") ?? "") || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus("saved");
|
||||
router.refresh();
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Telefonnummer (optional)
|
||||
</label>
|
||||
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "saving"}
|
||||
className="rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{status === "saving" ? "Wird gespeichert …" : "Speichern"}
|
||||
</button>
|
||||
{status === "saved" && <p className="text-sm text-anouma-olive">Gespeichert.</p>}
|
||||
{status === "error" && <p className="text-sm text-red-700">Speichern fehlgeschlagen.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = new FormData(e.currentTarget);
|
||||
const name = String(data.get("name") ?? "");
|
||||
const email = String(data.get("email") ?? "");
|
||||
const password = String(data.get("password") ?? "");
|
||||
|
||||
try {
|
||||
const createRes = await fetch("/api/customers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.json().catch(() => null);
|
||||
setError(body?.errors?.[0]?.message || "Die Registrierung ist fehlgeschlagen. Ist die E-Mail-Adresse schon vergeben?");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRes = await fetch("/api/customers/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
router.push("/konto");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className={fieldClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird erstellt …" : "Konto erstellen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest, Event, Offer } from "@/payload-types";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
|
||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
||||
const displayStatus = resolveDisplayStatus(booking.status, booking.date);
|
||||
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
|
||||
|
||||
async function callAction(path: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Aktion fehlgeschlagen.");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">
|
||||
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
|
||||
</p>
|
||||
</div>
|
||||
<BookingStatusBadge status={displayStatus} />
|
||||
</div>
|
||||
|
||||
{booking.status === "pending" && !hasAlternative && (
|
||||
<p className="mt-4 rounded-2xl bg-anouma-cream-light p-4 text-sm leading-relaxed text-anouma-plum">
|
||||
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasAlternative && booking.proposedAlternative && (
|
||||
<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">
|
||||
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
|
||||
{fmtTime(booking.proposedAlternative.endTime!)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/accept-alternative`)}
|
||||
className="rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
Termin annehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||||
className="rounded-full border border-anouma-mauve-dark/40 px-5 py-2.5 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light disabled:opacity-60"
|
||||
>
|
||||
Anderen Termin anfragen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.status === "confirmed" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{booking.appointmentType === "online" && event && (
|
||||
<a
|
||||
href={`/termine/${event.slug}/beitreten`}
|
||||
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||||
>
|
||||
Video-Call betreten
|
||||
</a>
|
||||
)}
|
||||
{booking.appointmentType === "onsite" && (
|
||||
<p className="text-sm text-anouma-plum/80">Vor Ort — Details siehe Bestätigungs-E-Mail.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(booking.status === "pending" || booking.status === "confirmed") && displayStatus !== "past" && !hasAlternative && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||||
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
|
||||
>
|
||||
Termin stornieren
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const statusStyles: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: "Vorgeschlagen", className: "bg-anouma-sand/60 text-anouma-plum" },
|
||||
confirmed: { label: "Bestätigt", className: "bg-anouma-sage/30 text-anouma-moss" },
|
||||
rejected: { label: "Abgelehnt", className: "bg-red-100 text-red-800" },
|
||||
cancelled: { label: "Storniert", className: "bg-anouma-taupe/30 text-anouma-plum/70" },
|
||||
past: { label: "Vergangen", className: "bg-anouma-taupe/20 text-anouma-plum/60" },
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: string }) {
|
||||
const style = statusStyles[status] ?? statusStyles.pending;
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${style.className}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDisplayStatus(status: string, dateISO: string): string {
|
||||
if ((status === "pending" || status === "confirmed") && new Date(dateISO) < new Date()) {
|
||||
return "past";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
|
||||
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
|
||||
}
|
||||
|
||||
export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) {
|
||||
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const from = new Date();
|
||||
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
|
||||
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
|
||||
.finally(() => setLoading(false));
|
||||
}, [offerSlug]);
|
||||
|
||||
if (result?.ok) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-8">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-dustyrose">Deine Anfrage</p>
|
||||
{selectedSlot && (
|
||||
<p className="mt-2 font-serif text-2xl font-medium text-anouma-plum">
|
||||
{fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin vorgeschlagen</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
||||
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
|
||||
<p className="text-base text-anouma-plum">
|
||||
Melde dich an oder erstelle ein Konto, um einen Termin anzufragen.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-3">
|
||||
<a
|
||||
href={`/login?next=/angebote/${offerSlug}`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
<a
|
||||
href="/registrieren"
|
||||
className="rounded-full border border-anouma-mauve-dark/40 px-6 py-2.5 text-sm font-medium text-anouma-plum hover:bg-white"
|
||||
>
|
||||
Konto erstellen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
|
||||
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
|
||||
|
||||
async function submit() {
|
||||
if (!selectedSlot) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/booking/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ offerSlug, start: selectedSlot, appointmentType, message }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setResult({ ok: false, error: data.error || "Anfrage fehlgeschlagen." });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setResult({ ok: true });
|
||||
} catch {
|
||||
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
|
||||
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
||||
<div>
|
||||
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
|
||||
{selectedDay && daySlots.length === 0 && (
|
||||
<p className="text-sm text-anouma-plum/70">An diesem Tag ist leider kein Termin frei.</p>
|
||||
)}
|
||||
{selectedDay && daySlots.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-anouma-plum">{fmtDate(daySlots[0].start)}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{daySlots.map((slot) => (
|
||||
<button
|
||||
key={slot.start}
|
||||
type="button"
|
||||
onClick={() => setSelectedSlot(slot.start)}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
|
||||
selectedSlot === slot.start
|
||||
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
|
||||
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
|
||||
}`}
|
||||
>
|
||||
{fmtTime(slot.start)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSlot && (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-6">
|
||||
<p className="text-sm font-medium text-anouma-plum">
|
||||
Ausgewählt: {fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
||||
</p>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="text-sm font-medium text-anouma-plum">Terminart</legend>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="radio"
|
||||
name="appointmentType"
|
||||
checked={appointmentType === "onsite"}
|
||||
onChange={() => setAppointmentType("onsite")}
|
||||
/>
|
||||
Vor Ort
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="radio"
|
||||
name="appointmentType"
|
||||
checked={appointmentType === "online"}
|
||||
onChange={() => setAppointmentType("online")}
|
||||
/>
|
||||
Online
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="booking-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result?.error && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||
{result.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="mt-5 rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{submitting ? "Wird gesendet …" : "Terminanfrage senden"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type CalendarMarker = {
|
||||
date: Date;
|
||||
status: "pending" | "confirmed" | "rejected" | "cancelled" | "past" | "public" | "private";
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
const markerColor: Record<CalendarMarker["status"], string> = {
|
||||
pending: "bg-anouma-sand",
|
||||
confirmed: "bg-anouma-sage",
|
||||
rejected: "bg-red-400",
|
||||
cancelled: "bg-anouma-taupe",
|
||||
past: "bg-anouma-taupe/50",
|
||||
public: "bg-anouma-rose",
|
||||
private: "bg-anouma-mauve-dark",
|
||||
};
|
||||
|
||||
function startOfMonth(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function MonthCalendar({
|
||||
markers,
|
||||
onSelectDay,
|
||||
selectedDay,
|
||||
}: {
|
||||
markers: CalendarMarker[];
|
||||
onSelectDay?: (day: Date) => void;
|
||||
selectedDay?: Date | null;
|
||||
}) {
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||
|
||||
const markersByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarMarker[]>();
|
||||
for (const marker of markers) {
|
||||
const key = dateKey(marker.date);
|
||||
map.set(key, [...(map.get(key) ?? []), marker]);
|
||||
}
|
||||
return map;
|
||||
}, [markers]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = startOfMonth(month);
|
||||
const firstWeekday = (first.getDay() + 6) % 7; // Monday = 0
|
||||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
||||
|
||||
const cells: (Date | null)[] = Array(firstWeekday).fill(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push(new Date(month.getFullYear(), month.getMonth(), d));
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
const result: (Date | null)[][] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) result.push(cells.slice(i, i + 7));
|
||||
return result;
|
||||
}, [month]);
|
||||
|
||||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||
const today = dateKey(new Date());
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<p className="font-serif text-lg font-medium capitalize text-anouma-plum">{monthLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs font-medium uppercase tracking-wide text-anouma-plum/50">
|
||||
{WEEKDAY_LABELS.map((d) => (
|
||||
<div key={d}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-1">
|
||||
{weeks.map((week, i) => (
|
||||
<div key={i} className="grid grid-cols-7 gap-1">
|
||||
{week.map((day, j) => {
|
||||
if (!day) return <div key={j} />;
|
||||
const key = dateKey(day);
|
||||
const dayMarkers = markersByDay.get(key) ?? [];
|
||||
const isSelected = selectedDay && dateKey(selectedDay) === key;
|
||||
return (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
onClick={() => onSelectDay?.(day)}
|
||||
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
|
||||
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
|
||||
} ${key === today && !isSelected ? "font-semibold" : ""}`}
|
||||
>
|
||||
<span>{day.getDate()}</span>
|
||||
{dayMarkers.length > 0 && (
|
||||
<span className="mt-0.5 flex gap-0.5">
|
||||
{dayMarkers.slice(0, 3).map((m, k) => (
|
||||
<span
|
||||
key={k}
|
||||
className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : markerColor[m.status]}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
import { BookingCard } from "./BookingCard";
|
||||
import { resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest } from "@/payload-types";
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
|
||||
const markers: CalendarMarker[] = bookings.map((b) => ({
|
||||
date: new Date(b.date),
|
||||
status: resolveDisplayStatus(b.status, b.date) as CalendarMarker["status"],
|
||||
}));
|
||||
|
||||
const selectedBookings = selectedDay
|
||||
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,360px)_1fr]">
|
||||
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
||||
<div>
|
||||
{selectedDay ? (
|
||||
selectedBookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{selectedBookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-anouma-plum/70">An diesem Tag ist kein Termin von dir eingetragen.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-anouma-plum/70">Wähle einen Tag mit Markierung, um deine Termine zu sehen.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
|
||||
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
|
||||
|
||||
const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
||||
|
||||
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
|
||||
|
||||
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
||||
|
||||
export function CallRoom({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredMeetingSession | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [sharingScreen, setSharingScreen] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
||||
|
||||
const send = useCallback((message: ClientToServerMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeer = useCallback((participantId: string) => {
|
||||
pcsRef.current.get(participantId)?.close();
|
||||
pcsRef.current.delete(participantId);
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(participantId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createPeerConnection = useCallback(
|
||||
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
pcsRef.current.set(participantId, pc);
|
||||
|
||||
localStreamRef.current?.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, localStreamRef.current!);
|
||||
});
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(participantId);
|
||||
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
|
||||
}
|
||||
};
|
||||
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isInitiator) {
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
|
||||
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
}
|
||||
|
||||
return pc;
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
pcsRef.current.forEach((pc) => pc.close());
|
||||
pcsRef.current.clear();
|
||||
localStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
localStreamRef.current = null;
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Load session + acquire local media + open signaling connection. This
|
||||
// reads sessionStorage and opens external connections, so it belongs in an
|
||||
// effect; the early setState calls below are unavoidable for the
|
||||
// missing/invalid-session error paths (no server-renderable equivalent).
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
|
||||
if (!raw) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
return;
|
||||
}
|
||||
let parsed: StoredMeetingSession;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
return;
|
||||
}
|
||||
if (parsed.eventSlug !== slug) {
|
||||
setPhase("error");
|
||||
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
|
||||
return;
|
||||
}
|
||||
setSession(parsed);
|
||||
sessionRef.current = parsed;
|
||||
setPhase("connecting");
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
localStreamRef.current = stream;
|
||||
setLocalStream(stream);
|
||||
} catch {
|
||||
// Continue without local media — participant can still watch/listen to others.
|
||||
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||
handleServerMessage(message);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
|
||||
};
|
||||
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
|
||||
})();
|
||||
|
||||
function handleServerMessage(message: ServerToClientMessage) {
|
||||
const me = sessionRef.current;
|
||||
if (!me) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "welcome": {
|
||||
setPhase("connected");
|
||||
for (const p of message.participants) {
|
||||
createPeerConnection(p.participantId, p.name, p.role, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "peer-joined": {
|
||||
createPeerConnection(message.participantId, message.name, message.role, false);
|
||||
break;
|
||||
}
|
||||
case "peer-left": {
|
||||
closePeer(message.participantId);
|
||||
break;
|
||||
}
|
||||
case "offer": {
|
||||
const pc =
|
||||
pcsRef.current.get(message.from) ??
|
||||
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
|
||||
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
|
||||
.then(() => pc.createAnswer())
|
||||
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
|
||||
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
break;
|
||||
}
|
||||
case "answer": {
|
||||
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
break;
|
||||
}
|
||||
case "ice-candidate": {
|
||||
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "kicked": {
|
||||
setPhase("kicked");
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remotesLookupName(participantId: string): string {
|
||||
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug]);
|
||||
|
||||
function toggleMic() {
|
||||
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
|
||||
setMicOn((v) => !v);
|
||||
}
|
||||
|
||||
function toggleCam() {
|
||||
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
|
||||
setCamOn((v) => !v);
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
if (sharingScreen) {
|
||||
stopScreenShare();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
screenStreamRef.current = screenStream;
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
screenTrack.onended = () => stopScreenShare();
|
||||
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(screenTrack);
|
||||
});
|
||||
setSharingScreen(true);
|
||||
} catch {
|
||||
// User cancelled the share dialog — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(cameraTrack);
|
||||
});
|
||||
setSharingScreen(false);
|
||||
}
|
||||
|
||||
function kickParticipant(participantId: string) {
|
||||
send({ type: "kick", targetParticipantId: participantId });
|
||||
}
|
||||
|
||||
function leaveMeeting() {
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
router.push(`/termine/${slug}`);
|
||||
}
|
||||
|
||||
if (phase === "loading" || phase === "connecting") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
|
||||
<p className="text-sm text-white/70">Verbindung wird aufgebaut …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "error" || !session) {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zur Beitrittsseite
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "kicked") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Du wurdest vom Meeting entfernt.</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Erneut beitreten
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "ended") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Die Verbindung zum Meeting wurde beendet.</p>
|
||||
<a
|
||||
href={`/termine/${slug}`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zum Termin
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHost = session.role === "host";
|
||||
const remoteList = [...remotes.values()];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||
<span className="text-sm font-medium">{session.eventTitle}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowParticipants((v) => !v)}
|
||||
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
|
||||
>
|
||||
Teilnehmer ({remoteList.length + 1})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
|
||||
{remoteList.map((r) => (
|
||||
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showParticipants && (
|
||||
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
|
||||
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center justify-between text-sm">
|
||||
<span>● {session.name} (du)</span>
|
||||
</li>
|
||||
{remoteList.map((r) => (
|
||||
<li key={r.participantId} className="flex items-center justify-between text-sm">
|
||||
<span>● {r.name}</span>
|
||||
{isHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => kickParticipant(r.participantId)}
|
||||
className="text-xs text-white/50 hover:text-red-300"
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
||||
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
||||
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
||||
{isHost && (
|
||||
<ControlButton
|
||||
active={sharingScreen}
|
||||
onClick={toggleScreenShare}
|
||||
label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"}
|
||||
icon="screen"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaveMeeting}
|
||||
className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800"
|
||||
>
|
||||
Meeting verlassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
icon: "mic" | "cam" | "screen";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${
|
||||
active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon name={icon} off={!active} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
|
||||
if (name === "mic") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<path
|
||||
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
|
||||
stroke="white"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (name === "cam") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||
|
||||
export type StoredMeetingSession = {
|
||||
token: string;
|
||||
participantId: string;
|
||||
role: "host" | "participant";
|
||||
name: string;
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
};
|
||||
|
||||
export function JoinForm({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Beitritt leider nicht möglich.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const session: StoredMeetingSession = {
|
||||
token: data.token,
|
||||
participantId: data.participantId,
|
||||
role: data.role,
|
||||
name,
|
||||
eventTitle: data.eventTitle,
|
||||
eventSlug: slug,
|
||||
};
|
||||
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
|
||||
router.push(`/termine/${slug}/call`);
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Meeting-Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="text"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird geprüft …" : "Meeting betreten"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function RegisterForm({ slug }: { slug: string }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${slug}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Anmeldung leider nicht möglich.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("done");
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
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!
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={fieldClass}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={fieldClass}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "loading"}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{status === "loading" ? "Wird gesendet …" : "Zum Termin anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type VideoTileProps = {
|
||||
stream: MediaStream | null;
|
||||
name: string;
|
||||
isLocal?: boolean;
|
||||
isHost?: boolean;
|
||||
muted?: boolean;
|
||||
videoOff?: boolean;
|
||||
};
|
||||
|
||||
export function VideoTile({ stream, name, isLocal, isHost, muted, videoOff }: VideoTileProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||
}, [stream]);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-2xl bg-neutral-800">
|
||||
{stream && !videoOff ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isLocal}
|
||||
className="h-full w-full object-cover [transform:scaleX(var(--flip,1))]"
|
||||
style={isLocal ? ({ "--flip": -1 } as React.CSSProperties) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-700 font-serif text-2xl text-white">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
{name}
|
||||
{isLocal && " (du)"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isHost && (
|
||||
<span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Host
|
||||
</span>
|
||||
)}
|
||||
{muted && (
|
||||
<span aria-label="Mikrofon stumm" className="text-white/80">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1l22 22M9 9v3a3 3 0 0 0 4.6 2.55M15 9.34V5a3 3 0 0 0-5.94-.6M5 10v1a7 7 0 0 0 10.54 6.02M12 18v3m-4 0h8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: anouma
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Optional: run the Next.js app in Docker too. For local development it's
|
||||
# usually simpler to run `npm run dev` on the host against the Postgres
|
||||
# container above (DATABASE_URI host: 127.0.0.1, as in .env.example).
|
||||
# To run the whole stack in Docker instead, uncomment this service and set
|
||||
# DATABASE_URI's host to `postgres` (the service name) in your .env file.
|
||||
# app:
|
||||
# build: .
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
# env_file:
|
||||
# - .env
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Field } from "payload";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A "Slug" field that auto-fills itself from `sourceField` (usually the
|
||||
* title) whenever left empty, so the admin never has to think about URLs.
|
||||
*/
|
||||
export function slugField(sourceField = "title"): Field {
|
||||
return {
|
||||
name: "slug",
|
||||
type: "text",
|
||||
label: "Slug (URL)",
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Wird automatisch aus dem Titel erzeugt, kann aber angepasst werden.",
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, data }) => {
|
||||
if (value) return slugify(String(value));
|
||||
const source = data?.[sourceField];
|
||||
return typeof source === "string" ? slugify(source) : value;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const About: GlobalConfig = {
|
||||
slug: "about",
|
||||
label: "Über mich",
|
||||
admin: {
|
||||
description: "Inhalt der Seite „Über mich“.",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", label: "Kicker" },
|
||||
{ name: "title", type: "text", label: "Überschrift", required: true },
|
||||
{ name: "portrait", type: "upload", relationTo: "media", label: "Portraitbild" },
|
||||
{ name: "body", type: "richText", label: "Haupttext", required: true },
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Abschließendes Zitat",
|
||||
fields: [
|
||||
{ name: "closingLead", type: "text", label: "Einleitung" },
|
||||
{ name: "closingHighlight", type: "text", label: "Hervorgehobenes Zitat" },
|
||||
{ name: "closingParagraph", type: "textarea", label: "Abschlusstext" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const AktuellesIntro: GlobalConfig = {
|
||||
slug: "aktuelles-intro",
|
||||
label: "Aktuelles (Einleitung)",
|
||||
admin: {
|
||||
description: "Kopftext der Aktuelles-Seite. Die Beiträge selbst werden in der Sammlung „Beiträge“ gepflegt.",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", label: "Kicker" },
|
||||
{ name: "title", type: "text", label: "Überschrift", required: true },
|
||||
{ name: "lead", type: "textarea", label: "Einleitungstext" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const AngeboteIntro: GlobalConfig = {
|
||||
slug: "angebote-intro",
|
||||
label: "Angebote (Einleitung)",
|
||||
admin: {
|
||||
description: "Kopftext der Angebote-Übersichtsseite. Die Angebote selbst werden in der Sammlung „Angebote“ gepflegt.",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", label: "Kicker" },
|
||||
{ name: "title", type: "text", label: "Überschrift", required: true },
|
||||
{ name: "lead", type: "textarea", label: "Einleitungstext" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const Booking: GlobalConfig = {
|
||||
slug: "booking",
|
||||
label: "Termin buchen",
|
||||
admin: {
|
||||
description: "Inhalt der Seite „Termin buchen“.",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", label: "Kicker" },
|
||||
{ name: "title", type: "text", label: "Überschrift", required: true },
|
||||
{ name: "lead", type: "textarea", label: "Einleitungstext" },
|
||||
{
|
||||
name: "steps",
|
||||
type: "array",
|
||||
label: "Ablaufschritte",
|
||||
minRows: 1,
|
||||
maxRows: 6,
|
||||
fields: [
|
||||
{ name: "title", type: "text", label: "Titel", required: true },
|
||||
{ name: "text", type: "textarea", label: "Beschreibung", required: true },
|
||||
],
|
||||
},
|
||||
{ name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const BookingSettings: GlobalConfig = {
|
||||
slug: "booking-settings",
|
||||
label: "Buchungseinstellungen",
|
||||
admin: {
|
||||
description: "Adresse für Vor-Ort-Termine (wird für den Maps-Link verwendet).",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" },
|
||||
{ name: "street", type: "text", label: "Straße und Hausnummer" },
|
||||
{ name: "postalCode", type: "text", label: "PLZ" },
|
||||
{ name: "city", type: "text", label: "Ort" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const Contact: GlobalConfig = {
|
||||
slug: "contact",
|
||||
label: "Kontakt",
|
||||
admin: {
|
||||
description: "Inhalt und Kontaktdaten der Kontakt-Seite.",
|
||||
group: "Seiteninhalte",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", label: "Kicker" },
|
||||
{ name: "title", type: "text", label: "Überschrift", required: true },
|
||||
{ name: "lead", type: "textarea", label: "Einleitungstext" },
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "34%" } },
|
||||
{ name: "phone", type: "text", label: "Telefon", admin: { width: "33%" } },
|
||||
{ name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user