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
+53
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+92
View File
@@ -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>&copy; {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>
);
}
+53
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+315
View File
@@ -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>
);
}
+21
View File
@@ -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}`}
/>
);
}
+32
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+31
View File
@@ -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} />;
}
+66
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+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>
);
}
+458
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}