Add email verification, personal calendar feed, and full SEO implementation
- 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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user