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