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:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+123
View File
@@ -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>
);
}
+23
View File
@@ -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;
}
+204
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}