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