Files
Wabenhain/src/lib/certificate/layout.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 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]>
2026-10-16 14:07:30 +02:00

115 lines
4.6 KiB
TypeScript

import type { Locale } from "@/i18n/config";
import { CertificateLayoutError, type CertificateField, type CertificateLayout } from "@/lib/certificate/elements";
import { FONT_KEYS, findUnsupportedCharacters } from "@/lib/certificate/fonts";
import { fixedTermFor, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
import { composeCertificateText, type CertificateText } from "@/lib/certificate/text";
import { CURRENT_TEMPLATE_VERSION, getTemplate } from "@/lib/certificate/templates";
/**
* Einstieg in die Layout-Berechnung (Spec "Ein Layout, mehrere Ausgaben"):
*
* composeCertificateText() → aufgeloester Text
* layoutCertificate() → positionierte Elemente (diese Datei)
* renderCertificatePdf() / renderCertificateSvg() / renderCertificateImage()
*
* Reine Funktionen ohne Datenbank: dieselbe Eingabe ergibt dasselbe Layout,
* auf dem Server fuer Vorschau, Textur und PDF gleichermassen.
*/
export {
CertificateLayoutError,
PAGE_HEIGHT,
PAGE_WIDTH,
SAFE_CONTENT,
SAFE_FRAME,
MIN_TEXT_SIZE,
MIN_BODY_SIZE,
textStartX,
elementBounds,
type CertificateField,
type CertificateLayout,
type LayoutElement,
type TextElement,
type PathElement,
type ImageElement,
type TextRole,
} from "@/lib/certificate/elements";
export { CURRENT_TEMPLATE_VERSION, getTemplate } from "@/lib/certificate/templates";
export function layoutCertificate(text: CertificateText, templateVersion: string = CURRENT_TEMPLATE_VERSION): CertificateLayout {
return getTemplate(templateVersion).layout(text);
}
/** Alle Textzeilen in Zeichenreihenfolge als "rolle: text" — Grundlage fuer den Zeilenvergleich der Ausgaben. */
export function layoutLines(layout: CertificateLayout): string[] {
return layout.elements.flatMap((el) => (el.kind === "text" ? [`${el.role}: ${el.text}`] : []));
}
export type FitCheck =
| { ok: true; layout: CertificateLayout }
| { ok: false; field: CertificateField; reason: "glyphs"; characters: string[]; message: string }
| { ok: false; field: CertificateField; reason: "fit"; message: string };
const FIELD_LABELS: Record<CertificateField, string> = {
beeName: "Der Name der Patenbiene",
recipientName: "Der Name auf der Urkunde",
giverNames: "Die Angabe der Schenkenden",
dedication: "Die Widmung",
occasion: "Die Anlasszeile",
layout: "Der Urkundentext",
};
/** Deutsche Meldung fuer Formular und Checkout; die Felder field/reason erlauben eine eigene Uebersetzung. */
function fitMessage(field: CertificateField, reason: "glyphs" | "fit", characters: string[] = []): string {
if (reason === "glyphs") {
return `${FIELD_LABELS[field]} enthält Zeichen, die auf der Urkunde nicht gedruckt werden können: ${characters.join(" ")}`;
}
return `${FIELD_LABELS[field]} passt so nicht auf die Urkunde. Bitte kürzen oder an einer Leerstelle teilen.`;
}
// Breiteste plausible Nummer fuer die Pruefung vor der Bestellung (die echte
// Nummer entsteht erst nach der Zahlung).
const SAMPLE_NUMBER = "WH-P-2026-00000";
const CUSTOMER_FIELDS: ReadonlyArray<[CertificateField, keyof Personalization]> = [
["beeName", "beeName"],
["recipientName", "recipientName"],
["giverNames", "giverNames"],
["dedication", "dedication"],
];
/**
* Serverseitige Pruefung einer bereits per zod validierten Personalisierung:
* 1. jedes Zeichen der Kundenfelder hat in allen Urkundenschriften eine
* Glyphe (bewusst alle Schnitte, nicht nur die der aktuellen Vorlage —
* so bleibt eine Bestellung auch mit einer spaeteren Vorlage druckbar);
* 2. das Layout passt in die kleinste Stufe.
* Vorschau-API und Checkout nutzen dieselbe Funktion.
*/
export function checkCertificateFits(
personalization: Personalization,
locale: Locale,
opts?: { term?: CertificateTerm; templateVersion?: string; number?: string }
): FitCheck {
for (const [field, key] of CUSTOMER_FIELDS) {
const value = personalization[key];
if (!value) continue;
const characters = findUnsupportedCharacters(value, FONT_KEYS);
if (characters.length > 0) return { ok: false, field, reason: "glyphs", characters, message: fitMessage(field, "glyphs", characters) };
}
const text = composeCertificateText({
personalization,
locale,
term: opts?.term ?? fixedTermFor(personalization.startDate),
number: opts?.number ?? SAMPLE_NUMBER,
});
try {
return { ok: true, layout: layoutCertificate(text, opts?.templateVersion) };
} catch (error: unknown) {
if (error instanceof CertificateLayoutError) {
return { ok: false, field: error.field, reason: "fit", message: fitMessage(error.field, "fit") };
}
throw error;
}
}