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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user