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

@@ -0,0 +1,175 @@
import { Button } from "@/components/ui";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSelect,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { formatPrice } from "@/lib/format";
import type { MedalTier, SponsorshipCycle, SponsorshipOfferKind } from "@/generated/prisma/client";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { MEDAL_TIERS, OFFER_CYCLES, OFFER_KINDS, centsToEuroInput } from "../_lib/offer-schema";
export interface OfferFormDefaults {
id?: string;
kind: SponsorshipOfferKind;
name: string;
nameEn: string;
slug: string;
description: string;
descriptionEn: string;
features: string[];
featuresEn: string[];
priceCents: number;
honeyShareCentsPerJar: number;
honeyShareCentsPerMonth: number;
isActive: boolean;
isPhysical: boolean;
sortOrder: number;
honeyProductId: string | null;
honeyJars: number;
cycle: SponsorshipCycle | null;
maxActiveSubscriptions: number | null;
medalTier: MedalTier;
}
export const EMPTY_OFFER: OfferFormDefaults = {
kind: "EINMALIG",
name: "",
nameEn: "",
slug: "",
description: "",
descriptionEn: "",
features: [],
featuresEn: [],
priceCents: 0,
honeyShareCentsPerJar: 0,
honeyShareCentsPerMonth: 0,
isActive: false,
isPhysical: false,
sortOrder: 0,
honeyProductId: null,
honeyJars: 0,
cycle: null,
maxActiveSubscriptions: null,
medalTier: "BRONZE",
};
interface OfferFormProps {
defaults: OfferFormDefaults;
/** Beim Bearbeiten ist die Art fest. */
isNew: boolean;
honeyOptions: { id: string; name: string; isActive: boolean }[];
action: (formData: FormData) => void | Promise<void>;
t: SponsorshipAdminStrings;
}
const euroOrEmpty = (cents: number): string => (cents > 0 ? centsToEuroInput(cents) : "");
/**
* Ein Formular fuer Einmal- und Abo-Angebote. Felder der jeweils anderen Art
* stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern
* geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik.
*/
export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFormProps) {
const tf = t.offerForm;
return (
<AdminFormPanel action={action} actions={<Button type="submit">{isNew ? tf.create : tf.save}</Button>}>
{defaults.id ? <input type="hidden" name="id" value={defaults.id} /> : null}
<AdminFieldset legend={tf.sectionBasics}>
<div className="grid gap-4 sm:grid-cols-2">
<AdminField name="name" label={tf.name} defaultValue={defaults.name} required />
<AdminField name="nameEn" label={tf.nameEn} defaultValue={defaults.nameEn} />
</div>
<AdminField name="slug" label={tf.slug} defaultValue={defaults.slug} required hint={tf.slugHint} />
{isNew ? (
<AdminSelect name="kind" label={tf.kind} defaultValue={defaults.kind} hint={tf.kindFixed}>
{OFFER_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t.kinds[kind]}
</option>
))}
</AdminSelect>
) : (
<AdminField name="kindDisplay" label={tf.kind} defaultValue={t.kinds[defaults.kind]} readOnly hint={tf.kindFixed} />
)}
</AdminFieldset>
<AdminFieldset legend={tf.sectionTexts}>
<AdminTextarea name="description" label={tf.description} defaultValue={defaults.description} required rows={3} />
<AdminTextarea name="descriptionEn" label={tf.descriptionEn} defaultValue={defaults.descriptionEn} rows={3} />
<div className="grid gap-4 sm:grid-cols-2">
<AdminTextarea name="features" label={tf.features} defaultValue={defaults.features.join("\n")} rows={5} />
<AdminTextarea name="featuresEn" label={tf.featuresEn} defaultValue={defaults.featuresEn.join("\n")} rows={5} />
</div>
</AdminFieldset>
<AdminFieldset legend={tf.sectionPrice}>
<AdminField name="price" label={tf.price} defaultValue={euroOrEmpty(defaults.priceCents)} placeholder="39,00" required />
<div className="grid gap-4 sm:grid-cols-2">
<AdminField name="honeySharePerJar" label={tf.shareJar} defaultValue={euroOrEmpty(defaults.honeyShareCentsPerJar)} placeholder="7,00" />
<AdminField name="honeySharePerMonth" label={tf.shareMonth} defaultValue={euroOrEmpty(defaults.honeyShareCentsPerMonth)} placeholder="4,00" />
</div>
{!isNew && defaults.kind === "EINMALIG" ? (
<AdminHint>
{tf.carrierPrice(formatPrice(defaults.priceCents - defaults.honeyJars * defaults.honeyShareCentsPerJar))}
</AdminHint>
) : null}
</AdminFieldset>
<AdminFieldset legend={tf.sectionHoney}>
<AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} />
<div className="grid gap-4 sm:grid-cols-2">
<AdminSelect name="honeyProductId" label={tf.honeyProduct} defaultValue={defaults.honeyProductId ?? ""}>
<option value="">{tf.honeyProductNone}</option>
{honeyOptions.map((product) => (
<option key={product.id} value={product.id}>
{product.name}
{product.isActive ? "" : tf.inactiveSuffix}
</option>
))}
</AdminSelect>
<AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} />
</div>
</AdminFieldset>
<AdminFieldset legend={tf.sectionAbo}>
<div className="grid gap-4 sm:grid-cols-2">
<AdminSelect name="cycle" label={tf.cycle} defaultValue={defaults.cycle ?? ""}>
<option value="">{tf.cycleNone}</option>
{OFFER_CYCLES.map((cycle) => (
<option key={cycle} value={cycle}>
{t.cycles[cycle]}
</option>
))}
</AdminSelect>
<AdminField
name="maxActiveSubscriptions"
label={tf.maxActive}
type="number"
min={1}
defaultValue={defaults.maxActiveSubscriptions !== null ? String(defaults.maxActiveSubscriptions) : ""}
/>
</div>
<AdminHint>{tf.stripeHint}</AdminHint>
</AdminFieldset>
<AdminFieldset legend={tf.sectionDisplay} last>
<div className="grid gap-4 sm:grid-cols-2">
<AdminSelect name="medalTier" label={tf.medalTier} defaultValue={defaults.medalTier}>
{MEDAL_TIERS.map((tier) => (
<option key={tier} value={tier}>
{t.medals[tier]}
</option>
))}
</AdminSelect>
<AdminField name="sortOrder" label={tf.sortOrder} type="number" min={0} defaultValue={String(defaults.sortOrder)} />
</div>
<AdminSwitch name="isActive" label={tf.isActive} defaultChecked={defaults.isActive} />
</AdminFieldset>
</AdminFormPanel>
);
}