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]>
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|