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

+87
View File
@@ -0,0 +1,87 @@
import { CERT_COLORS, type PathElement } from "@/lib/certificate/elements";
import { compose, cubicXAtY, ellipse, scale, transformPath, translate, type Point } from "@/lib/certificate/geometry";
/**
* Biene in Draufsicht als feine Strichzeichnung — symmetrisch, damit sie auf
* einer zentrierten Achse ruhig steht. Selbst gezeichnet (keine fremden
* Bildrechte), im Strich der Marken-Grafik (components/bee.tsx).
*
* Gezeichnet im Raster 0–100 (Mitte x = 50, Fluegelspanne 17,5–82,5).
* Koerperteile tragen eine Papierfuellung: so verdecken sie die darunter
* liegenden Fluegelansaetze wie bei einer Federzeichnung, ohne dass sich
* Linien kreuzen. Alle Linien haben dieselbe Staerke in Seitenpunkt.
*/
const SPAN = { min: 17.5, max: 82.5 };
const mirror = (d: string): string => transformPath(d, [-1, 0, 0, 1, 100, 0]);
const FOREWING = "M45.6 28.2C39 21.5 27 11.5 18.5 11.8C13.6 12 14.2 18.2 21 22.6C28 27.2 38.5 30.2 45.6 30.2Z";
const FOREWING_VEIN = "M43.6 29.3C36 25.6 28 20.4 22 16.2M34.5 25.3C30.5 26.2 26.5 25.6 23.2 23.4";
const HINDWING = "M45.2 32.8C39 32.4 29 33.8 25.6 37.6C23.4 40.2 26.6 42.2 31.6 41C37 39.6 42.4 36.6 45.2 34.6Z";
const HINDWING_VEIN = "M43.4 34.4C37.6 35.8 32 37.4 27.6 38.8";
// Drei Beinpaare, die unter dem Koerper hervorkommen (vom Koerper verdeckt).
const LEGS = "M45.4 34.4C43 35.8 41.4 37.6 40.4 40M46 36.8C43.8 39.4 42.8 42 42.8 45M47.2 38.6C45.6 42 45 46 45.6 50";
// Hinterleib: rechte Haelfte aus zwei Kurven, linke gespiegelt.
const ABDOMEN_TOP: Point = { x: 50, y: 38.6 };
const ABDOMEN_WIDEST: Point = { x: 59.2, y: 55 };
const ABDOMEN_TIP: Point = { x: 50, y: 72 };
const UPPER = [ABDOMEN_TOP, { x: 58.8, y: 38.6 }, { x: 60.2, y: 48.5 }, ABDOMEN_WIDEST] as const;
const LOWER = [ABDOMEN_WIDEST, { x: 58, y: 63 }, { x: 53, y: 69.5 }, ABDOMEN_TIP] as const;
const ABDOMEN =
"M50 38.6C58.8 38.6 60.2 48.5 59.2 55C58 63 53 69.5 50 72C47 69.5 42 63 40.8 55C39.8 48.5 41.2 38.6 50 38.6Z";
/** Querstreifen, die genau am Umriss des Hinterleibs enden (leicht gewoelbt). */
function stripe(y: number): string {
const segment = y <= ABDOMEN_WIDEST.y ? UPPER : LOWER;
const right = cubicXAtY(segment[0], segment[1], segment[2], segment[3], y);
const left = 100 - right;
return `M${left} ${y}Q50 ${y + 2.6} ${right} ${y}`;
}
// Gekniete Fuehler wie bei der Honigbiene.
const ANTENNA = "M48.4 16.2L47 11.6C45.6 9 43.4 7.6 40.8 7.4";
export interface BeeOptions {
/** Mitte der Biene auf der x-Achse. */
cx: number;
/** Oberkante (Fuehlerspitzen). */
top: number;
/** Fluegelspanne in Punkt. */
width: number;
strokeWidth: number;
color?: string;
}
/** Hoehe der Zeichnung bei gegebener Spannweite (Fuehler bis Stachel). */
export function beeHeight(width: number): number {
return ((75 - 7) / (SPAN.max - SPAN.min)) * width;
}
export function beePaths({ cx, top, width, strokeWidth, color = CERT_COLORS.honeyDark }: BeeOptions): PathElement[] {
const unit = width / (SPAN.max - SPAN.min);
const place = compose(translate(-50, -7), scale(unit), translate(cx, top));
const stroke = (d: string, fill?: string): PathElement => ({
kind: "path",
role: "illustration",
d: transformPath(d, place),
stroke: color,
strokeWidth,
fill,
lineCap: "round",
lineJoin: "round",
});
const paper = CERT_COLORS.paper;
return [
stroke([FOREWING, mirror(FOREWING), HINDWING, mirror(HINDWING)].join("")),
stroke([FOREWING_VEIN, mirror(FOREWING_VEIN), HINDWING_VEIN, mirror(HINDWING_VEIN)].join("")),
stroke(LEGS + mirror(LEGS)),
stroke(ABDOMEN, paper),
stroke(`${[47, 53.5, 60].map(stripe).join("")}M50 72L50 75`),
stroke(ellipse(50, 31.6, 6.8, 6.6), paper),
stroke(ellipse(50, 20.2, 4.6, 4.4), paper),
stroke(ANTENNA + mirror(ANTENNA)),
];
}
+69
View File
@@ -0,0 +1,69 @@
import { CERT_COLORS, type ImageElement, type PathElement } from "@/lib/certificate/elements";
import { compose, polyline, scale, transformPath, translate, rect } from "@/lib/certificate/geometry";
/**
* Das Waben-Zeichen (components/logomark.tsx, lib/invoice-pdf-brand.ts) als
* Vektorpfade: Hexagon in Honiggold, darin drei Tannen in Waldgruen. Gleiche
* Geometrie wie im Web-Logo (viewBox 100). Die Strichstaerken skalieren mit,
* genau wie dort — das Zeichen sieht in jeder Groesse gleich aus.
*
* Wird gezeichnet, wenn kein Logo hochgeladen ist. Das hochgeladene Logo
* selbst wird nie nachgezeichnet oder umgefaerbt (Spec "Markengrundlage").
*/
const HEXAGON = polyline(
[
{ x: 50, y: 5 },
{ x: 88.97, y: 27.5 },
{ x: 88.97, y: 72.5 },
{ x: 50, y: 95 },
{ x: 11.03, y: 72.5 },
{ x: 11.03, y: 27.5 },
],
true
);
const TREES = [
{ x: 20, y: 45, scale: 0.7 },
{ x: 38, y: 32, scale: 0.95 },
{ x: 56, y: 45, scale: 0.7 },
] as const;
const TREE_PARTS = [
polyline([{ x: 10, y: 2 }, { x: 5, y: 13 }, { x: 15, y: 13 }], true),
polyline([{ x: 10, y: 10 }, { x: 2, y: 22 }, { x: 18, y: 22 }], true),
rect({ x: 8, y: 22, width: 4, height: 4 }),
];
/** Waben-Zeichen in einem Quadrat der Kantenlaenge `size` mit linker oberer Ecke (x, y). */
export function logomarkPaths(x: number, y: number, size: number): PathElement[] {
const unit = size / 100;
const place = compose(scale(unit), translate(x, y));
const hexagon: PathElement = {
kind: "path",
role: "logo",
d: transformPath(HEXAGON, place),
stroke: CERT_COLORS.honey,
strokeWidth: 2.5 * unit,
lineJoin: "miter",
};
const trees = TREES.map((tree): PathElement => {
const matrix = compose(scale(tree.scale), translate(tree.x, tree.y), place);
return {
kind: "path",
role: "logo",
d: TREE_PARTS.map((part) => transformPath(part, matrix)).join(""),
stroke: CERT_COLORS.forest,
strokeWidth: 1.4 * tree.scale * unit,
lineJoin: "round",
};
});
return [hexagon, ...trees];
}
/** Quadrat fuer das Waben-Zeichen innerhalb der Logo-Box, nach deren Ausrichtung. */
export function logoFallbackPaths(box: ImageElement): PathElement[] {
const size = Math.min(box.width, box.height);
const offset = box.align === "left" ? 0 : box.align === "right" ? box.width - size : (box.width - size) / 2;
return logomarkPaths(box.x + offset, box.y + (box.height - size) / 2, size);
}