- Customer accounts now require email verification (hashed, single-use, time-limited tokens) before they can request/confirm bookings, with resend flows on login/account/booking widget and rate limiting. - Admins get a private, rotatable iCalendar (ICS) subscription feed of their confirmed bookings and public events, timezone-correct for Europe/Berlin including DST, never exposing meeting passwords. - Adds a full SEO layer: per-page canonical/OG/Twitter metadata with CMS-editable overrides and content-derived fallbacks, a dynamic sitemap.xml and robots.txt driven by real published content, JSON-LD (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service, Event, BlogPosting) that never fabricates data, and a CMS-managed redirect table for changed slugs. - Global ANOUMA-naming audit: the brand name is never used to label personal account/calendar areas anywhere in the app, CMS, or emails. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
|
|
const fieldClass =
|
|
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
|
|
|
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
|
|
try {
|
|
const res = await fetch("/api/auth/resend-verification", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(email ? { email } : {}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) return { ok: false, error: data.error || "Die Bestätigungs-E-Mail konnte nicht gesendet werden." };
|
|
return { ok: true };
|
|
} catch {
|
|
return { ok: false, error: "Verbindung fehlgeschlagen." };
|
|
}
|
|
}
|
|
|
|
const GENERIC_SENT_MESSAGE =
|
|
"Falls das Konto noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet.";
|
|
|
|
/**
|
|
* variant "session": a single button that resends for the currently logged-in
|
|
* customer (used in /konto and the booking widget, where a session already
|
|
* exists).
|
|
*
|
|
* variant "email-prompt": a toggle link that reveals an email field first
|
|
* (used on /login, where the visitor may not be authenticated yet).
|
|
*/
|
|
export function ResendVerificationButton({
|
|
variant,
|
|
triggerLabel = "E-Mail noch nicht bestätigt?",
|
|
className,
|
|
}: {
|
|
variant: "session" | "email-prompt";
|
|
triggerLabel?: string;
|
|
className?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function resendForSession() {
|
|
setStatus("sending");
|
|
const result = await callResend();
|
|
if (result.ok) {
|
|
setStatus("sent");
|
|
} else {
|
|
setStatus("error");
|
|
setError(result.error ?? null);
|
|
}
|
|
}
|
|
|
|
async function handleEmailSubmit(e: FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setStatus("sending");
|
|
setError(null);
|
|
const email = String(new FormData(e.currentTarget).get("email") ?? "");
|
|
const result = await callResend(email);
|
|
if (result.ok) {
|
|
setStatus("sent");
|
|
} else {
|
|
setStatus("error");
|
|
setError(result.error ?? null);
|
|
}
|
|
}
|
|
|
|
if (variant === "session") {
|
|
return (
|
|
<div className={className}>
|
|
{status === "sent" ? (
|
|
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={resendForSession}
|
|
disabled={status === "sending"}
|
|
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4 disabled:opacity-60"
|
|
>
|
|
{status === "sending" ? "Wird gesendet …" : "Bestätigungs-E-Mail erneut senden"}
|
|
</button>
|
|
)}
|
|
{status === "error" && error && (
|
|
<p role="alert" className="mt-1 text-sm text-red-700">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={className}>
|
|
{!open ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
|
>
|
|
{triggerLabel}
|
|
</button>
|
|
) : status === "sent" ? (
|
|
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
|
|
) : (
|
|
<form onSubmit={handleEmailSubmit} className="mt-2 flex flex-wrap items-start gap-2">
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
required
|
|
placeholder="Deine E-Mail-Adresse"
|
|
className={`${fieldClass} max-w-xs flex-1`}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={status === "sending"}
|
|
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"
|
|
>
|
|
{status === "sending" ? "Wird gesendet …" : "Bestätigungs-E-Mail erneut senden"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
{status === "error" && error && (
|
|
<p role="alert" className="mt-1 text-sm text-red-700">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|