feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto

Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
export interface ActionsMenuItem {
key: string;
label: string;
/** Entweder Link (Abo verwalten, Download) oder Aktion (Dialog oeffnen). */
href?: string;
download?: boolean;
onSelect?: () => void;
}
/**
* Kleines Aktionsmenue im Kopf der Detailseite (Muster "menu button" der
* WAI-ARIA Authoring Practices): Pfeiltasten wandern, Escape schliesst und
* gibt den Fokus an den Knopf zurueck, ein Klick daneben schliesst ebenfalls.
*/
export function ActionsMenu({ label, items }: { label: string; items: readonly ActionsMenuItem[] }) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus();
function onPointerDown(event: PointerEvent) {
const target = event.target as Node;
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function close(returnFocus: boolean) {
setOpen(false);
if (returnFocus) buttonRef.current?.focus();
}
function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const entries = Array.from(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]") ?? []);
const index = entries.indexOf(document.activeElement as HTMLElement);
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const step = event.key === "ArrowDown" ? 1 : -1;
entries[(index + step + entries.length) % entries.length]?.focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
(event.key === "Home" ? entries[0] : entries.at(-1))?.focus();
} else if (event.key === "Tab") {
setOpen(false);
}
}
const itemClass =
"flex w-full items-center px-4 py-3 text-left text-sm text-ink transition-colors hover:bg-honey/10 focus-visible:bg-honey/10 focus-visible:outline-none";
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={label}
onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
}
}}
className="inline-flex h-11 w-11 touch-manipulation items-center justify-center border border-ink/20 text-heading transition-[border-color,background-color,transform] duration-200 hover:border-heading active:scale-[0.96] aria-expanded:border-heading aria-expanded:bg-forest aria-expanded:text-cream"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{open ? (
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-full z-30 mt-2 min-w-[14rem] origin-top-right border border-ink/10 bg-background py-1.5 shadow-[0_24px_48px_-24px_rgba(36,53,43,0.45)] transition-[opacity,scale] duration-150 ease-[var(--ease-arrive)] starting:scale-[0.97] starting:opacity-0 motion-reduce:transition-none"
>
{items.map((item) =>
item.href ? (
item.download ? (
<a key={item.key} role="menuitem" tabIndex={-1} href={item.href} download className={itemClass} onClick={() => close(false)}>
{item.label}
</a>
) : (
<Link key={item.key} role="menuitem" tabIndex={-1} href={item.href} className={itemClass} onClick={() => close(false)}>
{item.label}
</Link>
)
) : (
<button
key={item.key}
type="button"
role="menuitem"
tabIndex={-1}
className={itemClass}
onClick={() => {
close(false);
item.onSelect?.();
}}
>
{item.label}
</button>
)
)}
</div>
) : null}
</div>
);
}