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