Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system
- Next.js 16 App Router site with the ANOUMA design system - Payload CMS (PostgreSQL) for offers, events, posts and page content - WebRTC video-call system with custom signaling server - SMTP email reminders and booking-request notifications - Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
type AngebotCardProps = {
|
||||
href: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
mood: ImageMood;
|
||||
imageSrc?: string;
|
||||
imageAlt?: string;
|
||||
size?: "default" | "large";
|
||||
};
|
||||
|
||||
export function AngebotCard({
|
||||
href,
|
||||
title,
|
||||
tagline,
|
||||
mood,
|
||||
imageSrc,
|
||||
imageAlt,
|
||||
size = "default",
|
||||
}: AngebotCardProps) {
|
||||
return (
|
||||
<Link href={href} className="group block">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[2.5rem] ${
|
||||
size === "large" ? "aspect-[4/3]" : "aspect-[4/5]"
|
||||
}`}
|
||||
>
|
||||
<ImagePlaceholder
|
||||
mood={mood}
|
||||
label={title}
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
shape="soft"
|
||||
className="h-full w-full transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-anouma-plum/70 via-anouma-plum/0 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-7">
|
||||
<h3 className="font-serif text-2xl font-medium text-white">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
|
||||
Mehr erfahren
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
|
||||
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type Crumb = { title: string; href?: string };
|
||||
|
||||
export function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-2 text-sm text-anouma-plum/80">
|
||||
<li>
|
||||
<Link href="/" className="hover:text-anouma-mauve-dark">
|
||||
Startseite
|
||||
</Link>
|
||||
</li>
|
||||
{items.map((item, i) => (
|
||||
<li key={item.title} className="flex items-center gap-2">
|
||||
<span aria-hidden="true" className="text-anouma-taupe">
|
||||
/
|
||||
</span>
|
||||
{item.href && i !== items.length - 1 ? (
|
||||
<Link href={item.href} className="hover:text-anouma-mauve-dark">
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" className="text-anouma-plum">
|
||||
{item.title}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
|
||||
|
||||
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
|
||||
// tone the button appears on — see components/Section.tsx for the tones.
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
||||
secondary:
|
||||
"border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white",
|
||||
ghost:
|
||||
"text-anouma-plum underline underline-offset-4 decoration-anouma-mauve-dark/50 hover:decoration-anouma-mauve-dark",
|
||||
// For use on dark (plum/mauve-dark) section backgrounds, e.g. CTA.
|
||||
invert: "bg-anouma-cream-light text-anouma-plum hover:bg-white",
|
||||
invertOutline: "border border-white/50 text-white hover:bg-white hover:text-anouma-plum",
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Button({ href, children, variant = "primary", className = "" }: ButtonProps) {
|
||||
const isExternal = href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:");
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
|
||||
const classes = `${base} ${variants[variant]} ${className}`;
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} className={classes} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { Container } from "@/components/Section";
|
||||
|
||||
type CTAProps = {
|
||||
title: string;
|
||||
lead?: string;
|
||||
primaryHref?: string;
|
||||
primaryLabel?: string;
|
||||
secondaryHref?: string;
|
||||
secondaryLabel?: string;
|
||||
};
|
||||
|
||||
export function CTA({
|
||||
title,
|
||||
lead,
|
||||
primaryHref = "/termin-buchen",
|
||||
primaryLabel = "Termin buchen",
|
||||
secondaryHref = "/kontakt",
|
||||
secondaryLabel = "Kontakt aufnehmen",
|
||||
}: CTAProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-anouma-plum py-20 text-anouma-cream-light sm:py-24">
|
||||
<OrganicBlob tone="mauve" className="-left-20 -top-20 h-80 w-80 opacity-30" />
|
||||
<OrganicBlob tone="rose" className="-bottom-24 -right-16 h-72 w-72 opacity-20" />
|
||||
<Container className="relative text-center">
|
||||
<h2 className="mx-auto max-w-2xl text-balance font-serif text-4xl font-medium leading-tight sm:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{lead && (
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-anouma-cream-light/85">
|
||||
{lead}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-9 flex flex-wrap items-center justify-center gap-4">
|
||||
<Button href={primaryHref} variant="invert">
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
<Button href={secondaryHref} variant="invertOutline">
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
type ContactFormProps = {
|
||||
toEmail: string;
|
||||
subjectPrefix?: string;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the visitor's email client with a pre-filled message. There is no
|
||||
* backend configured yet — swap this for a Server Action once a mail
|
||||
* provider is connected.
|
||||
*/
|
||||
export function ContactForm({
|
||||
toEmail,
|
||||
subjectPrefix = "Nachricht über anouma.org",
|
||||
submitLabel = "Nachricht senden",
|
||||
}: ContactFormProps) {
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const name = String(data.get("name") ?? "");
|
||||
const email = String(data.get("email") ?? "");
|
||||
const message = String(data.get("message") ?? "");
|
||||
|
||||
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
|
||||
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
|
||||
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Deine Nachricht
|
||||
</label>
|
||||
<textarea id="message" name="message" required rows={5} className={fieldClass} />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
{sent && (
|
||||
<p className="text-sm text-anouma-olive" role="status">
|
||||
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
|
||||
{toEmail}.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import type { Event } from "@/payload-types";
|
||||
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
|
||||
|
||||
export function EventTeaserCard({ event }: { event: Event }) {
|
||||
const { day, month } = formatEventDateParts(event.date);
|
||||
const time = formatTimeRange(event.startTime, event.endTime);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/termine/${event.slug}`}
|
||||
className="group flex gap-5 rounded-3xl bg-background p-6 transition-colors hover:bg-anouma-cream-light"
|
||||
>
|
||||
<div className="flex h-16 w-16 shrink-0 flex-col items-center justify-center rounded-2xl bg-anouma-mauve-dark text-white">
|
||||
<span className="font-serif text-xl font-semibold leading-none">{day}</span>
|
||||
<span className="mt-1 text-[10px] font-medium uppercase tracking-widest">{month}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif text-lg font-medium text-anouma-plum">{event.title}</h3>
|
||||
{time && <p className="mt-1 text-sm text-anouma-plum">{time}</p>}
|
||||
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
|
||||
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
|
||||
Mehr erfahren
|
||||
<svg
|
||||
width="12"
|
||||
height="9"
|
||||
viewBox="0 0 14 10"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
d="M1 5h11.5M8 1l4.5 4L8 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import { groupOffersByCategory } from "@/lib/angebote";
|
||||
import { footerNav, mainNav, siteConfig } from "@/lib/site";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
|
||||
export async function Footer() {
|
||||
const offers = await getOffers();
|
||||
const groups = groupOffersByCategory(offers);
|
||||
|
||||
return (
|
||||
<footer className="bg-anouma-plum text-anouma-cream-light">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
|
||||
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
|
||||
Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Navigation
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{mainNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-sm text-anouma-cream-light/90 hover:text-white">
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link href="/termin-buchen" className="text-sm text-anouma-cream-light/90 hover:text-white">
|
||||
Termin buchen
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Angebote
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{groups.map((group) => (
|
||||
<li key={group.category}>
|
||||
<Link
|
||||
href={group.offers.length === 1 ? `/angebote/${group.offers[0].slug}` : group.href}
|
||||
className="text-sm text-anouma-cream-light/90 hover:text-white"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
|
||||
Kontakt
|
||||
</h3>
|
||||
<p className="mt-4 text-sm leading-relaxed text-anouma-cream-light/90">
|
||||
Ich freue mich, von dir zu hören.
|
||||
</p>
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className="mt-3 inline-block text-sm font-medium underline underline-offset-4 hover:text-white"
|
||||
>
|
||||
Zum Kontaktformular
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-col gap-4 border-t border-anouma-cream-light/20 pt-8 text-sm text-anouma-cream-light/90 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>© {new Date().getFullYear()} {siteConfig.name}</p>
|
||||
<ul className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{footerNav.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="hover:text-white">
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
type HeroProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
mood?: ImageMood;
|
||||
imageSrc?: string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
|
||||
export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSrc, imageAlt }: HeroProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden pb-20 pt-16 sm:pb-28 sm:pt-24">
|
||||
<OrganicBlob tone="rose" className="-right-24 -top-24 h-[28rem] w-[28rem]" />
|
||||
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
|
||||
|
||||
<div className="relative mx-auto grid max-w-6xl gap-14 px-6 sm:px-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:px-12">
|
||||
<div className="animate-fade-up">
|
||||
{eyebrow && (
|
||||
<p className="mb-5 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-balance font-serif text-5xl font-medium leading-[1.08] text-anouma-plum sm:text-6xl lg:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
{children && (
|
||||
<div className="mt-7 max-w-xl text-lg leading-relaxed text-anouma-plum sm:text-xl">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="mt-9 flex flex-wrap gap-4">{actions}</div>}
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
|
||||
<ImagePlaceholder
|
||||
mood={mood}
|
||||
label="Anouma — warme, naturverbundene Begleitung"
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
priority
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Image from "next/image";
|
||||
import type { ImageMood } from "@/lib/angebote";
|
||||
|
||||
/**
|
||||
* Renders a photo when `src` is provided, otherwise falls back to a warm,
|
||||
* organic gradient placeholder in the given mood. Pages should always pass
|
||||
* `src` once real photography exists — no other prop changes are needed.
|
||||
*/
|
||||
|
||||
const moodGradients: Record<ImageMood, string> = {
|
||||
plum: "linear-gradient(135deg, #a97070 0%, #66505f 55%, #453238 100%)",
|
||||
dustyrose: "linear-gradient(135deg, #ecc8be 0%, #a97070 55%, #8b616d 100%)",
|
||||
moss: "linear-gradient(135deg, #d4c1a5 0%, #89937c 55%, #4f5b45 100%)",
|
||||
caramel: "linear-gradient(135deg, #efd5c8 0%, #d4c1a5 55%, #a47c60 100%)",
|
||||
mauve: "linear-gradient(135deg, #e5c4bc 0%, #8b6f7d 55%, #66505f 100%)",
|
||||
sand: "linear-gradient(135deg, #f2e2d6 0%, #e8dcc8 55%, #a89580 100%)",
|
||||
rose: "linear-gradient(135deg, #f0e0d3 0%, #e0b4b1 55%, #d19ca1 100%)",
|
||||
peach: "linear-gradient(135deg, #f2e2d6 0%, #ecc8be 55%, #e0b4b1 100%)",
|
||||
};
|
||||
|
||||
const moodIcons: Record<ImageMood, React.ReactNode> = {
|
||||
plum: (
|
||||
<path d="M32 4c8 6 12 14 12 22 0 9-6.5 16-12 16S20 35 20 26c0-8 4-16 12-22Zm0 60V38" />
|
||||
),
|
||||
dustyrose: <path d="M32 8c9 8 18 18 18 30a18 18 0 1 1-36 0c0-12 9-22 18-30Z" />,
|
||||
moss: (
|
||||
<path d="M32 60V24M32 24c-10 0-18-8-18-18 10 0 18 8 18 18Zm0 0c0-10 8-18 18-18 0 10-8 18-18 18Z" />
|
||||
),
|
||||
caramel: (
|
||||
<path d="M32 6c6 10 14 20 14 30a14 14 0 1 1-28 0c0-10 8-20 14-30Z" />
|
||||
),
|
||||
mauve: <path d="M8 32c8-14 16-20 24-20s16 6 24 20c-8 14-16 20-24 20S16 46 8 32Z" />,
|
||||
sand: (
|
||||
<path d="M6 24c8-6 12-6 20 0s12 6 20 0M6 40c8-6 12-6 20 0s12 6 20 0" />
|
||||
),
|
||||
rose: (
|
||||
<path d="M32 34a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0 0a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm-14-10a10 10 0 1 1 14 10 10 10 0 0 1-14-10Zm28 0a10 10 0 1 0-14 10 10 10 0 0 0 14-10Z" />
|
||||
),
|
||||
peach: <path d="M8 40c6-20 18-32 24-32s18 12 24 32c-8 8-16 12-24 12s-16-4-24-12Z" />,
|
||||
};
|
||||
|
||||
type ImagePlaceholderProps = {
|
||||
mood: ImageMood;
|
||||
label: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
shape?: "blob" | "soft";
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function ImagePlaceholder({
|
||||
mood,
|
||||
label,
|
||||
src,
|
||||
alt,
|
||||
className = "",
|
||||
shape = "blob",
|
||||
priority = false,
|
||||
}: ImagePlaceholderProps) {
|
||||
const radius =
|
||||
shape === "blob"
|
||||
? "rounded-[62%_38%_53%_47%/45%_42%_58%_55%]"
|
||||
: "rounded-[2.5rem]";
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${radius} ${className}`}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
fill
|
||||
priority={priority}
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${radius} ${className}`}
|
||||
style={{ background: moodGradients[mood] }}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full opacity-[0.14] mix-blend-overlay"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<filter id={`grain-${mood}`}>
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter={`url(#grain-${mood})`} />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
className="absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 text-white/40 sm:h-20 sm:w-20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{moodIcons[mood]}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { groupOffersByCategory } from "@/lib/angebote";
|
||||
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
||||
import type { Offer } from "@/payload-types";
|
||||
|
||||
export function Navbar({ offers }: { offers: Offer[] }) {
|
||||
const pathname = usePathname();
|
||||
const groups = groupOffersByCategory(offers);
|
||||
const [megaOpen, setMegaOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobileAngeboteOpen, setMobileAngeboteOpen] = useState(false);
|
||||
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close open menus when the route changes, adjusted during render rather
|
||||
// than in an effect (see https://react.dev/learn/you-might-not-need-an-effect).
|
||||
const [lastPathname, setLastPathname] = useState(pathname);
|
||||
if (pathname !== lastPathname) {
|
||||
setLastPathname(pathname);
|
||||
setMobileOpen(false);
|
||||
setMegaOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileOpen ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setMegaOpen(false);
|
||||
setMobileOpen(false);
|
||||
}
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
if (navRef.current && !navRef.current.contains(e.target as Node)) {
|
||||
setMegaOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function openMega() {
|
||||
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||
setMegaOpen(true);
|
||||
}
|
||||
|
||||
function scheduleCloseMega() {
|
||||
closeTimeout.current = setTimeout(() => setMegaOpen(false), 150);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
|
||||
>
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
|
||||
<nav ref={navRef} className="hidden items-center gap-8 lg:flex" aria-label="Hauptnavigation">
|
||||
{mainNav.map((item) =>
|
||||
item.title === "Angebote" ? (
|
||||
<div
|
||||
key={item.href}
|
||||
className="relative"
|
||||
onMouseEnter={openMega}
|
||||
onMouseLeave={scheduleCloseMega}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={megaOpen}
|
||||
onClick={() => setMegaOpen((v) => !v)}
|
||||
>
|
||||
{item.title}
|
||||
<svg
|
||||
width="10"
|
||||
height="6"
|
||||
viewBox="0 0 10 6"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-300 ${megaOpen ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{megaOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
|
||||
{groups.map((group) => {
|
||||
const isSingle = group.offers.length === 1;
|
||||
const groupHref = isSingle
|
||||
? `/angebote/${group.offers[0].slug}`
|
||||
: group.href;
|
||||
return (
|
||||
<div key={group.category}>
|
||||
<Link
|
||||
href={groupHref}
|
||||
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
{!isSingle && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{group.offers.map((offer) => (
|
||||
<li key={offer.slug}>
|
||||
<Link
|
||||
href={`/angebote/${offer.slug}`}
|
||||
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
|
||||
>
|
||||
{offer.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isSingle && (
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
||||
{group.offers[0].shortDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
|
||||
<Link
|
||||
href="/angebote"
|
||||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
||||
>
|
||||
Alle Angebote im Überblick
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
|
||||
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
|
||||
>
|
||||
{ctaNav.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-anouma-plum lg:hidden"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<motion.path
|
||||
animate={mobileOpen ? { d: "M5 5l14 14" } : { d: "M4 7h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
<motion.path
|
||||
animate={mobileOpen ? { opacity: 0 } : { opacity: 1, d: "M4 12h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.path
|
||||
animate={mobileOpen ? { d: "M5 19l14-14" } : { d: "M4 17h16" }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="overflow-y-auto border-t border-anouma-taupe/15 bg-background lg:hidden"
|
||||
style={{ maxHeight: "calc(100dvh - 5rem)" }}
|
||||
>
|
||||
<nav className="flex flex-col px-6 py-6" aria-label="Mobile Navigation">
|
||||
{mainNav.map((item) =>
|
||||
item.title === "Angebote" ? (
|
||||
<div key={item.href} className="border-b border-anouma-taupe/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-4 text-lg font-medium text-anouma-plum"
|
||||
aria-expanded={mobileAngeboteOpen}
|
||||
onClick={() => setMobileAngeboteOpen((v) => !v)}
|
||||
>
|
||||
{item.title}
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 10 6"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-300 ${mobileAngeboteOpen ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{mobileAngeboteOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden pb-4"
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const isSingle = group.offers.length === 1;
|
||||
const groupHref = isSingle
|
||||
? `/angebote/${group.offers[0].slug}`
|
||||
: group.href;
|
||||
return (
|
||||
<div key={group.category} className="mb-4">
|
||||
<Link
|
||||
href={groupHref}
|
||||
className="block py-2 text-base font-medium text-anouma-mauve-dark"
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
{!isSingle && (
|
||||
<ul className="ml-3 space-y-1 border-l border-anouma-taupe/20 pl-4">
|
||||
{group.offers.map((offer) => (
|
||||
<li key={offer.slug}>
|
||||
<Link
|
||||
href={`/angebote/${offer.slug}`}
|
||||
className="block py-2 text-sm text-anouma-plum"
|
||||
>
|
||||
{offer.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Link
|
||||
href={ctaNav.href}
|
||||
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
|
||||
>
|
||||
{ctaNav.title}
|
||||
</Link>
|
||||
</nav>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type OrganicBlobProps = {
|
||||
className?: string;
|
||||
tone?: "rose" | "sage" | "cream" | "mauve";
|
||||
};
|
||||
|
||||
const tones: Record<NonNullable<OrganicBlobProps["tone"]>, string> = {
|
||||
rose: "bg-anouma-rose-soft",
|
||||
sage: "bg-anouma-sage",
|
||||
cream: "bg-anouma-cream-beige-2",
|
||||
mauve: "bg-anouma-mauve",
|
||||
};
|
||||
|
||||
/** Purely decorative, blurred organic shape used to add warmth behind content. */
|
||||
export function OrganicBlob({ className = "", tone = "rose" }: OrganicBlobProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute rounded-[60%_40%_65%_35%/45%_55%_45%_55%] opacity-40 blur-3xl ${tones[tone]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
|
||||
type PageHeaderProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lead?: ReactNode;
|
||||
crumbs?: Crumb[];
|
||||
};
|
||||
|
||||
export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
|
||||
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
|
||||
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
|
||||
{crumbs && <Breadcrumbs items={crumbs} />}
|
||||
{eyebrow && (
|
||||
<p className="mb-4 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="max-w-3xl text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
{lead && (
|
||||
<div className="mt-5 max-w-2xl text-lg leading-relaxed text-anouma-plum">{lead}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Visibly marks a section whose real copy is not yet available in texte.txt. */
|
||||
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
|
||||
<span className="font-medium">Platzhalter — noch zu ergänzen:</span> {children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
as?: "div" | "li";
|
||||
};
|
||||
|
||||
/** Fades and lifts content into place once it scrolls into view. */
|
||||
export function Reveal({ children, delay = 0, className, as = "div" }: RevealProps) {
|
||||
const Component = motion[as];
|
||||
return (
|
||||
<Component
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={variants}
|
||||
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
RichText as LexicalRichText,
|
||||
type JSXConvertersFunction,
|
||||
} from "@payloadcms/richtext-lexical/react";
|
||||
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
|
||||
|
||||
const converters: JSXConvertersFunction<DefaultNodeTypes> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
if (!children?.length) return null;
|
||||
return <p className="text-lg leading-relaxed text-anouma-plum">{children}</p>;
|
||||
},
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
const Tag = node.tag;
|
||||
return (
|
||||
<Tag className="mt-4 font-serif text-3xl font-medium text-anouma-plum">{children}</Tag>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
type RichTextProps = {
|
||||
data: NonNullable<Parameters<typeof LexicalRichText>[0]["data"]>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Renders a Payload Lexical richText field with the ANOUMA reading styles. */
|
||||
export function RichText({ data, className = "space-y-6" }: RichTextProps) {
|
||||
return <LexicalRichText data={data} converters={converters} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ContainerProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Container({ children, className = "" }: ContainerProps) {
|
||||
return <div className={`mx-auto w-full max-w-6xl px-6 sm:px-8 lg:px-12 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
type SectionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
tone?: "cream" | "warm" | "plain" | "mauve";
|
||||
};
|
||||
|
||||
const tones: Record<NonNullable<SectionProps["tone"]>, string> = {
|
||||
cream: "bg-anouma-cream-light",
|
||||
warm: "bg-anouma-cream-beige",
|
||||
plain: "bg-background",
|
||||
mauve: "bg-anouma-plum text-anouma-cream-light",
|
||||
};
|
||||
|
||||
export function Section({ children, className = "", id, tone = "plain" }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className={`py-20 sm:py-28 ${tones[tone]} ${className}`}>
|
||||
<Container>{children}</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionHeadingProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lead?: string;
|
||||
align?: "left" | "center";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
align = "left",
|
||||
className = "",
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-2xl ${align === "center" ? "mx-auto text-center" : ""} ${className}`}
|
||||
>
|
||||
{eyebrow && (
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{lead && (
|
||||
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">{lead}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = new FormData(e.currentTarget);
|
||||
const email = String(data.get("email") ?? "");
|
||||
const password = String(data.get("password") ?? "");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/customers/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("E-Mail oder Passwort ist nicht korrekt.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
router.push(searchParams.get("next") || "/konto");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Passwort
|
||||
</label>
|
||||
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird geprüft …" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/customers/logout", { method: "POST", credentials: "include" });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleLogout} className={className}>
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("saving");
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
const res = await fetch(`/api/customers/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: String(data.get("name") ?? ""),
|
||||
phone: String(data.get("phone") ?? "") || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus("saved");
|
||||
router.refresh();
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Telefonnummer (optional)
|
||||
</label>
|
||||
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "saving"}
|
||||
className="rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{status === "saving" ? "Wird gespeichert …" : "Speichern"}
|
||||
</button>
|
||||
{status === "saved" && <p className="text-sm text-anouma-olive">Gespeichert.</p>}
|
||||
{status === "error" && <p className="text-sm text-red-700">Speichern fehlgeschlagen.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = new FormData(e.currentTarget);
|
||||
const name = String(data.get("name") ?? "");
|
||||
const email = String(data.get("email") ?? "");
|
||||
const password = String(data.get("password") ?? "");
|
||||
|
||||
try {
|
||||
const createRes = await fetch("/api/customers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.json().catch(() => null);
|
||||
setError(body?.errors?.[0]?.message || "Die Registrierung ist fehlgeschlagen. Ist die E-Mail-Adresse schon vergeben?");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRes = await fetch("/api/customers/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
router.push("/konto");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className={fieldClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird erstellt …" : "Konto erstellen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest, Event, Offer } from "@/payload-types";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
|
||||
}
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
|
||||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
||||
const displayStatus = resolveDisplayStatus(booking.status, booking.date);
|
||||
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
|
||||
|
||||
async function callAction(path: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Aktion fehlgeschlagen.");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
|
||||
<p className="mt-1 text-sm text-anouma-plum/80">
|
||||
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
|
||||
</p>
|
||||
</div>
|
||||
<BookingStatusBadge status={displayStatus} />
|
||||
</div>
|
||||
|
||||
{booking.status === "pending" && !hasAlternative && (
|
||||
<p className="mt-4 rounded-2xl bg-anouma-cream-light p-4 text-sm leading-relaxed text-anouma-plum">
|
||||
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasAlternative && booking.proposedAlternative && (
|
||||
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
|
||||
<p className="mt-1 text-base text-anouma-plum">
|
||||
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
|
||||
{fmtTime(booking.proposedAlternative.endTime!)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/accept-alternative`)}
|
||||
className="rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
Termin annehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||||
className="rounded-full border border-anouma-mauve-dark/40 px-5 py-2.5 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light disabled:opacity-60"
|
||||
>
|
||||
Anderen Termin anfragen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.status === "confirmed" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{booking.appointmentType === "online" && event && (
|
||||
<a
|
||||
href={`/termine/${event.slug}/beitreten`}
|
||||
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||||
>
|
||||
Video-Call betreten
|
||||
</a>
|
||||
)}
|
||||
{booking.appointmentType === "onsite" && (
|
||||
<p className="text-sm text-anouma-plum/80">Vor Ort — Details siehe Bestätigungs-E-Mail.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(booking.status === "pending" || booking.status === "confirmed") && displayStatus !== "past" && !hasAlternative && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||||
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
|
||||
>
|
||||
Termin stornieren
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const statusStyles: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: "Vorgeschlagen", className: "bg-anouma-sand/60 text-anouma-plum" },
|
||||
confirmed: { label: "Bestätigt", className: "bg-anouma-sage/30 text-anouma-moss" },
|
||||
rejected: { label: "Abgelehnt", className: "bg-red-100 text-red-800" },
|
||||
cancelled: { label: "Storniert", className: "bg-anouma-taupe/30 text-anouma-plum/70" },
|
||||
past: { label: "Vergangen", className: "bg-anouma-taupe/20 text-anouma-plum/60" },
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: string }) {
|
||||
const style = statusStyles[status] ?? statusStyles.pending;
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${style.className}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDisplayStatus(status: string, dateISO: string): string {
|
||||
if ((status === "pending" || status === "confirmed") && new Date(dateISO) < new Date()) {
|
||||
return "past";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
|
||||
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
|
||||
}
|
||||
|
||||
export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) {
|
||||
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const from = new Date();
|
||||
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
|
||||
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
|
||||
.finally(() => setLoading(false));
|
||||
}, [offerSlug]);
|
||||
|
||||
if (result?.ok) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-8">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-dustyrose">Deine Anfrage</p>
|
||||
{selectedSlot && (
|
||||
<p className="mt-2 font-serif text-2xl font-medium text-anouma-plum">
|
||||
{fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin vorgeschlagen</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
||||
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
|
||||
<p className="text-base text-anouma-plum">
|
||||
Melde dich an oder erstelle ein Konto, um einen Termin anzufragen.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-3">
|
||||
<a
|
||||
href={`/login?next=/angebote/${offerSlug}`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
<a
|
||||
href="/registrieren"
|
||||
className="rounded-full border border-anouma-mauve-dark/40 px-6 py-2.5 text-sm font-medium text-anouma-plum hover:bg-white"
|
||||
>
|
||||
Konto erstellen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
|
||||
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
|
||||
|
||||
async function submit() {
|
||||
if (!selectedSlot) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/booking/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ offerSlug, start: selectedSlot, appointmentType, message }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setResult({ ok: false, error: data.error || "Anfrage fehlgeschlagen." });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setResult({ ok: true });
|
||||
} catch {
|
||||
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
|
||||
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
||||
<div>
|
||||
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
|
||||
{selectedDay && daySlots.length === 0 && (
|
||||
<p className="text-sm text-anouma-plum/70">An diesem Tag ist leider kein Termin frei.</p>
|
||||
)}
|
||||
{selectedDay && daySlots.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-anouma-plum">{fmtDate(daySlots[0].start)}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{daySlots.map((slot) => (
|
||||
<button
|
||||
key={slot.start}
|
||||
type="button"
|
||||
onClick={() => setSelectedSlot(slot.start)}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
|
||||
selectedSlot === slot.start
|
||||
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
|
||||
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
|
||||
}`}
|
||||
>
|
||||
{fmtTime(slot.start)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSlot && (
|
||||
<div className="rounded-3xl bg-anouma-cream-light p-6">
|
||||
<p className="text-sm font-medium text-anouma-plum">
|
||||
Ausgewählt: {fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
||||
</p>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="text-sm font-medium text-anouma-plum">Terminart</legend>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="radio"
|
||||
name="appointmentType"
|
||||
checked={appointmentType === "onsite"}
|
||||
onChange={() => setAppointmentType("onsite")}
|
||||
/>
|
||||
Vor Ort
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="radio"
|
||||
name="appointmentType"
|
||||
checked={appointmentType === "online"}
|
||||
onChange={() => setAppointmentType("online")}
|
||||
/>
|
||||
Online
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="booking-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result?.error && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||
{result.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="mt-5 rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{submitting ? "Wird gesendet …" : "Terminanfrage senden"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type CalendarMarker = {
|
||||
date: Date;
|
||||
status: "pending" | "confirmed" | "rejected" | "cancelled" | "past" | "public" | "private";
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
const markerColor: Record<CalendarMarker["status"], string> = {
|
||||
pending: "bg-anouma-sand",
|
||||
confirmed: "bg-anouma-sage",
|
||||
rejected: "bg-red-400",
|
||||
cancelled: "bg-anouma-taupe",
|
||||
past: "bg-anouma-taupe/50",
|
||||
public: "bg-anouma-rose",
|
||||
private: "bg-anouma-mauve-dark",
|
||||
};
|
||||
|
||||
function startOfMonth(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function MonthCalendar({
|
||||
markers,
|
||||
onSelectDay,
|
||||
selectedDay,
|
||||
}: {
|
||||
markers: CalendarMarker[];
|
||||
onSelectDay?: (day: Date) => void;
|
||||
selectedDay?: Date | null;
|
||||
}) {
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||
|
||||
const markersByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarMarker[]>();
|
||||
for (const marker of markers) {
|
||||
const key = dateKey(marker.date);
|
||||
map.set(key, [...(map.get(key) ?? []), marker]);
|
||||
}
|
||||
return map;
|
||||
}, [markers]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = startOfMonth(month);
|
||||
const firstWeekday = (first.getDay() + 6) % 7; // Monday = 0
|
||||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
||||
|
||||
const cells: (Date | null)[] = Array(firstWeekday).fill(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push(new Date(month.getFullYear(), month.getMonth(), d));
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
const result: (Date | null)[][] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) result.push(cells.slice(i, i + 7));
|
||||
return result;
|
||||
}, [month]);
|
||||
|
||||
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
|
||||
const today = dateKey(new Date());
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<p className="font-serif text-lg font-medium capitalize text-anouma-plum">{monthLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs font-medium uppercase tracking-wide text-anouma-plum/50">
|
||||
{WEEKDAY_LABELS.map((d) => (
|
||||
<div key={d}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-1">
|
||||
{weeks.map((week, i) => (
|
||||
<div key={i} className="grid grid-cols-7 gap-1">
|
||||
{week.map((day, j) => {
|
||||
if (!day) return <div key={j} />;
|
||||
const key = dateKey(day);
|
||||
const dayMarkers = markersByDay.get(key) ?? [];
|
||||
const isSelected = selectedDay && dateKey(selectedDay) === key;
|
||||
return (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
onClick={() => onSelectDay?.(day)}
|
||||
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
|
||||
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
|
||||
} ${key === today && !isSelected ? "font-semibold" : ""}`}
|
||||
>
|
||||
<span>{day.getDate()}</span>
|
||||
{dayMarkers.length > 0 && (
|
||||
<span className="mt-0.5 flex gap-0.5">
|
||||
{dayMarkers.slice(0, 3).map((m, k) => (
|
||||
<span
|
||||
key={k}
|
||||
className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : markerColor[m.status]}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||
import { BookingCard } from "./BookingCard";
|
||||
import { resolveDisplayStatus } from "./BookingStatusBadge";
|
||||
import type { BookingRequest } from "@/payload-types";
|
||||
|
||||
function dateKey(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
|
||||
const markers: CalendarMarker[] = bookings.map((b) => ({
|
||||
date: new Date(b.date),
|
||||
status: resolveDisplayStatus(b.status, b.date) as CalendarMarker["status"],
|
||||
}));
|
||||
|
||||
const selectedBookings = selectedDay
|
||||
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,360px)_1fr]">
|
||||
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
||||
<div>
|
||||
{selectedDay ? (
|
||||
selectedBookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{selectedBookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-anouma-plum/70">An diesem Tag ist kein Termin von dir eingetragen.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-anouma-plum/70">Wähle einen Tag mit Markierung, um deine Termine zu sehen.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
|
||||
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
|
||||
|
||||
const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
||||
|
||||
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
|
||||
|
||||
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
|
||||
|
||||
export function CallRoom({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<StoredMeetingSession | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [sharingScreen, setSharingScreen] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
||||
|
||||
const send = useCallback((message: ClientToServerMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeer = useCallback((participantId: string) => {
|
||||
pcsRef.current.get(participantId)?.close();
|
||||
pcsRef.current.delete(participantId);
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(participantId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createPeerConnection = useCallback(
|
||||
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
pcsRef.current.set(participantId, pc);
|
||||
|
||||
localStreamRef.current?.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, localStreamRef.current!);
|
||||
});
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(participantId);
|
||||
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
|
||||
}
|
||||
};
|
||||
|
||||
setRemotes((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isInitiator) {
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
|
||||
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
}
|
||||
|
||||
return pc;
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
pcsRef.current.forEach((pc) => pc.close());
|
||||
pcsRef.current.clear();
|
||||
localStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
localStreamRef.current = null;
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Load session + acquire local media + open signaling connection. This
|
||||
// reads sessionStorage and opens external connections, so it belongs in an
|
||||
// effect; the early setState calls below are unavoidable for the
|
||||
// missing/invalid-session error paths (no server-renderable equivalent).
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
|
||||
if (!raw) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
return;
|
||||
}
|
||||
let parsed: StoredMeetingSession;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
setPhase("error");
|
||||
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
|
||||
return;
|
||||
}
|
||||
if (parsed.eventSlug !== slug) {
|
||||
setPhase("error");
|
||||
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
|
||||
return;
|
||||
}
|
||||
setSession(parsed);
|
||||
sessionRef.current = parsed;
|
||||
setPhase("connecting");
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
localStreamRef.current = stream;
|
||||
setLocalStream(stream);
|
||||
} catch {
|
||||
// Continue without local media — participant can still watch/listen to others.
|
||||
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||
handleServerMessage(message);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
|
||||
};
|
||||
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
|
||||
})();
|
||||
|
||||
function handleServerMessage(message: ServerToClientMessage) {
|
||||
const me = sessionRef.current;
|
||||
if (!me) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "welcome": {
|
||||
setPhase("connected");
|
||||
for (const p of message.participants) {
|
||||
createPeerConnection(p.participantId, p.name, p.role, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "peer-joined": {
|
||||
createPeerConnection(message.participantId, message.name, message.role, false);
|
||||
break;
|
||||
}
|
||||
case "peer-left": {
|
||||
closePeer(message.participantId);
|
||||
break;
|
||||
}
|
||||
case "offer": {
|
||||
const pc =
|
||||
pcsRef.current.get(message.from) ??
|
||||
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
|
||||
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
|
||||
.then(() => pc.createAnswer())
|
||||
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
|
||||
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
|
||||
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
|
||||
break;
|
||||
}
|
||||
case "answer": {
|
||||
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
break;
|
||||
}
|
||||
case "ice-candidate": {
|
||||
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "kicked": {
|
||||
setPhase("kicked");
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remotesLookupName(participantId: string): string {
|
||||
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug]);
|
||||
|
||||
function toggleMic() {
|
||||
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
|
||||
setMicOn((v) => !v);
|
||||
}
|
||||
|
||||
function toggleCam() {
|
||||
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
|
||||
setCamOn((v) => !v);
|
||||
}
|
||||
|
||||
async function toggleScreenShare() {
|
||||
if (sharingScreen) {
|
||||
stopScreenShare();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
screenStreamRef.current = screenStream;
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
screenTrack.onended = () => stopScreenShare();
|
||||
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(screenTrack);
|
||||
});
|
||||
setSharingScreen(true);
|
||||
} catch {
|
||||
// User cancelled the share dialog — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function stopScreenShare() {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
|
||||
pcsRef.current.forEach((pc) => {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
sender?.replaceTrack(cameraTrack);
|
||||
});
|
||||
setSharingScreen(false);
|
||||
}
|
||||
|
||||
function kickParticipant(participantId: string) {
|
||||
send({ type: "kick", targetParticipantId: participantId });
|
||||
}
|
||||
|
||||
function leaveMeeting() {
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
router.push(`/termine/${slug}`);
|
||||
}
|
||||
|
||||
if (phase === "loading" || phase === "connecting") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
|
||||
<p className="text-sm text-white/70">Verbindung wird aufgebaut …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "error" || !session) {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zur Beitrittsseite
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "kicked") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Du wurdest vom Meeting entfernt.</p>
|
||||
<a
|
||||
href={`/termine/${slug}/beitreten`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Erneut beitreten
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "ended") {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
|
||||
<p className="text-lg">Die Verbindung zum Meeting wurde beendet.</p>
|
||||
<a
|
||||
href={`/termine/${slug}`}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
|
||||
>
|
||||
Zurück zum Termin
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHost = session.role === "host";
|
||||
const remoteList = [...remotes.values()];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||
<span className="text-sm font-medium">{session.eventTitle}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowParticipants((v) => !v)}
|
||||
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
|
||||
>
|
||||
Teilnehmer ({remoteList.length + 1})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
|
||||
{remoteList.map((r) => (
|
||||
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showParticipants && (
|
||||
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
|
||||
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center justify-between text-sm">
|
||||
<span>● {session.name} (du)</span>
|
||||
</li>
|
||||
{remoteList.map((r) => (
|
||||
<li key={r.participantId} className="flex items-center justify-between text-sm">
|
||||
<span>● {r.name}</span>
|
||||
{isHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => kickParticipant(r.participantId)}
|
||||
className="text-xs text-white/50 hover:text-red-300"
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
|
||||
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
|
||||
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
|
||||
{isHost && (
|
||||
<ControlButton
|
||||
active={sharingScreen}
|
||||
onClick={toggleScreenShare}
|
||||
label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"}
|
||||
icon="screen"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={leaveMeeting}
|
||||
className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800"
|
||||
>
|
||||
Meeting verlassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
icon: "mic" | "cam" | "screen";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${
|
||||
active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon name={icon} off={!active} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
|
||||
if (name === "mic") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<path
|
||||
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
|
||||
stroke="white"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (name === "cam") {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
|
||||
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||
|
||||
export type StoredMeetingSession = {
|
||||
token: string;
|
||||
participantId: string;
|
||||
role: "host" | "participant";
|
||||
name: string;
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
};
|
||||
|
||||
export function JoinForm({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Beitritt leider nicht möglich.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const session: StoredMeetingSession = {
|
||||
token: data.token,
|
||||
participantId: data.participantId,
|
||||
role: data.role,
|
||||
name,
|
||||
eventTitle: data.eventTitle,
|
||||
eventSlug: slug,
|
||||
};
|
||||
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
|
||||
router.push(`/termine/${slug}/call`);
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Meeting-Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="text"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Wird geprüft …" : "Meeting betreten"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
|
||||
export function RegisterForm({ slug }: { slug: string }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${slug}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Anmeldung leider nicht möglich.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("done");
|
||||
} catch {
|
||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "done") {
|
||||
return (
|
||||
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
|
||||
Danke für deine Anmeldung — wir freuen uns auf dich!
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={fieldClass}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={fieldClass}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "loading"}
|
||||
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
|
||||
>
|
||||
{status === "loading" ? "Wird gesendet …" : "Zum Termin anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type VideoTileProps = {
|
||||
stream: MediaStream | null;
|
||||
name: string;
|
||||
isLocal?: boolean;
|
||||
isHost?: boolean;
|
||||
muted?: boolean;
|
||||
videoOff?: boolean;
|
||||
};
|
||||
|
||||
export function VideoTile({ stream, name, isLocal, isHost, muted, videoOff }: VideoTileProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||
}, [stream]);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-2xl bg-neutral-800">
|
||||
{stream && !videoOff ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isLocal}
|
||||
className="h-full w-full object-cover [transform:scaleX(var(--flip,1))]"
|
||||
style={isLocal ? ({ "--flip": -1 } as React.CSSProperties) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-700 font-serif text-2xl text-white">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
{name}
|
||||
{isLocal && " (du)"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isHost && (
|
||||
<span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Host
|
||||
</span>
|
||||
)}
|
||||
{muted && (
|
||||
<span aria-label="Mikrofon stumm" className="text-white/80">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1l22 22M9 9v3a3 3 0 0 0 4.6 2.55M15 9.34V5a3 3 0 0 0-5.94-.6M5 10v1a7 7 0 0 0 10.54 6.02M12 18v3m-4 0h8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user