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]>
176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|