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

+5
View File
@@ -15,3 +15,8 @@ export * from "./actions/content";
export * from "./actions/apicore-sync";
export * from "./actions/dhl";
export * from "./actions/archive";
export * from "./actions/sponsorship";
export * from "./actions/sponsorship-offers";
export * from "./actions/sponsorship-harvest";
export * from "./actions/sponsorship-subscriptions";
export * from "./actions/sponsorship-loyalty";
@@ -0,0 +1,124 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { isIsoDate } from "@/lib/certificate/schema";
import { requireAdmin, fail, succeed, str } from "./_shared";
const windowSchema = z
.object({
startsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Beginndatum angeben."),
endsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Enddatum angeben."),
plannedJars: z.coerce
.number({ error: "Geplante Gläser: bitte eine ganze Zahl." })
.int("Geplante Gläser: bitte eine ganze Zahl.")
.min(0, "Geplante Gläser: mindestens 0.")
.max(100_000, "Geplante Gläser: höchstens 100000."),
})
.refine((value) => value.endsOn >= value.startsOn, "Das Ende darf nicht vor dem Beginn liegen.");
function windowPath(offerId: string): string {
return `/admin/patenschaften/angebote/${offerId}/erntefenster`;
}
function toDate(isoDate: string): Date {
return new Date(`${isoDate}T00:00:00.000Z`);
}
/** Erntefenster gibt es nur fuer Abo-Angebote mit Zyklus ERNTE. */
async function loadHarvestOffer(offerId: string) {
const offer = await prisma.sponsorshipOffer.findUnique({ where: { id: offerId }, select: { id: true, name: true, kind: true, cycle: true } });
return offer && offer.kind === "ABO" && offer.cycle === "ERNTE" ? offer : null;
}
/**
* Fenster desselben Angebots duerfen sich nicht ueberschneiden: sonst waere
* unklar, in welchem Fenster eine Lieferung faellig wird, und die geplanten
* Glaeser wuerden doppelt reserviert.
*/
async function overlaps(offerId: string, startsOn: Date, endsOn: Date, exceptId?: string): Promise<boolean> {
const count = await prisma.harvestWindow.count({
where: {
offerId,
...(exceptId ? { id: { not: exceptId } } : {}),
startsOn: { lte: endsOn },
endsOn: { gte: startsOn },
},
});
return count > 0;
}
function parseWindow(formData: FormData) {
return windowSchema.safeParse({
startsOn: str(formData, "startsOn"),
endsOn: str(formData, "endsOn"),
plannedJars: str(formData, "plannedJars") || "0",
});
}
export async function createHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const offer = await loadHarvestOffer(str(formData, "offerId"));
if (!offer) return fail("/admin/patenschaften/angebote", "Erntefenster gibt es nur für Abo-Angebote mit dem Zyklus „Nach der Ernte“.");
const back = windowPath(offer.id);
const parsed = parseWindow(formData);
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
const startsOn = toDate(parsed.data.startsOn);
const endsOn = toDate(parsed.data.endsOn);
if (await overlaps(offer.id, startsOn, endsOn)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
const window = await prisma.harvestWindow.create({
data: { offerId: offer.id, startsOn, endsOn, plannedJars: parsed.data.plannedJars },
});
await logAdminAction(admin, {
action: "harvest_window.create",
entityType: "HarvestWindow",
entityId: window.id,
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
});
revalidatePath(back);
return succeed(back, "Erntefenster angelegt.");
}
export async function updateHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.harvestWindow.findUnique({ where: { id }, select: { offerId: true } });
const offer = existing ? await loadHarvestOffer(existing.offerId) : null;
if (!existing || !offer) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden.");
const back = windowPath(offer.id);
const parsed = parseWindow(formData);
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
const startsOn = toDate(parsed.data.startsOn);
const endsOn = toDate(parsed.data.endsOn);
if (await overlaps(offer.id, startsOn, endsOn, id)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
await prisma.harvestWindow.update({ where: { id }, data: { startsOn, endsOn, plannedJars: parsed.data.plannedJars } });
await logAdminAction(admin, {
action: "harvest_window.update",
entityType: "HarvestWindow",
entityId: id,
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
});
revalidatePath(back);
return succeed(back, "Erntefenster gespeichert.");
}
export async function deleteHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.harvestWindow.findUnique({ where: { id }, include: { offer: { select: { name: true } } } });
if (!existing) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden — möglicherweise bereits gelöscht.");
const back = windowPath(existing.offerId);
await prisma.harvestWindow.deleteMany({ where: { id } });
await logAdminAction(admin, {
action: "harvest_window.delete",
entityType: "HarvestWindow",
entityId: id,
detail: `${existing.offer.name}: ${existing.startsOn.toISOString().slice(0, 10)} – ${existing.endsOn.toISOString().slice(0, 10)}`,
});
revalidatePath(back);
return succeed(back, "Erntefenster gelöscht.");
}
@@ -0,0 +1,110 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { isUniqueViolation } from "./sponsorship-shared";
const LOYALTY_PATH = "/admin/patenschaften/treuestufen";
const nameFields = {
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(60, "Name: höchstens 60 Zeichen."),
nameEn: z
.string()
.trim()
.max(60, "Name (Englisch): höchstens 60 Zeichen.")
.transform((value) => (value === "" ? null : value)),
};
const thresholdField = z.coerce
.number({ error: "Schwelle: bitte eine ganze Zahl." })
.int("Schwelle: bitte eine ganze Zahl.")
.min(1, "Schwelle: mindestens 1.")
.max(1000, "Schwelle: höchstens 1000.");
const levelSchema = z.object({ ...nameFields, threshold: thresholdField });
const newLevelSchema = levelSchema.extend({ kind: z.enum(["STAMMKUNDE", "ABO"], { error: "Unbekannte Stufenart." }) });
function readLevel(formData: FormData) {
return {
name: str(formData, "name"),
nameEn: str(formData, "nameEn"),
threshold: str(formData, "threshold"),
};
}
const DUPLICATE_THRESHOLD = "Für diese Schwelle gibt es bereits eine Stufe.";
export async function createLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const parsed = newLevelSchema.safeParse({ ...readLevel(formData), kind: str(formData, "kind") });
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
try {
const level = await prisma.loyaltyLevel.create({ data: parsed.data });
await logAdminAction(admin, {
action: "loyalty_level.create",
entityType: "LoyaltyLevel",
entityId: level.id,
detail: `${level.kind} ab ${level.threshold}: ${level.name}`,
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
throw error;
}
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe angelegt.");
}
/**
* Speichert Namen und Schwelle einer Stufe. Die VIP-Stufe (Abo, ohne
* Schwelle) behaelt ihre fehlende Schwelle — sie folgt immer nach der
* hoechsten Zahl, nur ihr Name ist pflegbar.
*/
export async function updateLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden.");
const isVip = existing.threshold === null;
const parsed = isVip
? z.object(nameFields).safeParse(readLevel(formData))
: levelSchema.safeParse(readLevel(formData));
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
try {
await prisma.loyaltyLevel.update({ where: { id }, data: parsed.data });
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
throw error;
}
const threshold = "threshold" in parsed.data ? parsed.data.threshold : null;
await logAdminAction(admin, {
action: "loyalty_level.update",
entityType: "LoyaltyLevel",
entityId: id,
detail: `${existing.kind} ${existing.threshold ?? "VIP"} → ${threshold ?? "VIP"}: ${existing.name} → ${parsed.data.name}`,
});
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe gespeichert.");
}
export async function deleteLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden — möglicherweise bereits gelöscht.");
if (existing.threshold === null) return fail(LOYALTY_PATH, "Die VIP-Stufe kann nicht gelöscht werden.");
await prisma.loyaltyLevel.deleteMany({ where: { id, threshold: { not: null } } });
await logAdminAction(admin, {
action: "loyalty_level.delete",
entityType: "LoyaltyLevel",
entityId: id,
detail: `${existing.kind} ab ${existing.threshold}: ${existing.name}`,
});
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe gelöscht.");
}
+191
View File
@@ -0,0 +1,191 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { formatPrice } from "@/lib/format";
import type { Prisma } from "@/generated/prisma/client";
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
const OFFERS_PATH = "/admin/patenschaften/angebote";
function carrierSlug(slug: string): string {
return `patenschaft-${slug}`;
}
/**
* Traegerprodukt einer Einmal-Patenschaft (Kategorie PATENSCHAFT): haelt
* Preis, Steuersatz und Rechnungsposition, damit Warenkorb, Checkout und
* Rechnung keinen Sonderweg brauchen. Sein Preis ist Paketpreis minus
* Honiganteil — der Honig wird im Checkout als eigene Position zum
* Honig-Steuersatz angelegt.
*/
function carrierProductData(values: OfferFormValues) {
return {
name: `Bienenpatenschaft ${values.name}`,
slug: carrierSlug(values.slug),
category: "PATENSCHAFT" as const,
description: values.description,
weightGrams: 0,
priceCents: carrierPriceCents(values),
pricePerKgCents: 0,
isActive: true,
};
}
function offerData(values: OfferFormValues) {
return {
slug: values.slug,
name: values.name,
nameEn: values.nameEn,
description: values.description,
descriptionEn: values.descriptionEn,
features: values.features,
featuresEn: values.featuresEn,
priceCents: values.priceCents,
isActive: values.isActive,
sortOrder: values.sortOrder,
isPhysical: values.isPhysical,
honeyProductId: values.honeyProductId,
honeyJars: values.honeyJars,
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
cycle: values.cycle,
maxActiveSubscriptions: values.maxActiveSubscriptions,
medalTier: values.medalTier,
} satisfies Prisma.SponsorshipOfferUncheckedUpdateInput;
}
/**
* Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes
* Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst
* sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst
* bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig.
*/
async function findConflict(
values: OfferFormValues,
existing: { id: string; productId: string | null } | null
): Promise<string | null> {
const slugOwner = await prisma.sponsorshipOffer.findUnique({ where: { slug: values.slug }, select: { id: true } });
if (slugOwner && slugOwner.id !== existing?.id) return `Die Adresse "${values.slug}" hat bereits ein anderes Angebot.`;
if (values.kind === "EINMALIG") {
const product = await prisma.product.findUnique({ where: { slug: carrierSlug(values.slug) }, select: { id: true } });
if (product && product.id !== existing?.productId) {
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
}
}
if (values.honeyProductId) {
const honey = await prisma.product.findUnique({
where: { id: values.honeyProductId },
select: { category: true, isActive: true, name: true },
});
if (!honey || honey.category !== "HONIG") return "Bitte ein Honigprodukt aus der Liste wählen.";
if (values.isActive && !honey.isActive) {
return `Das Angebot kann nicht aktiviert werden: das Honigprodukt "${honey.name}" ist inaktiv.`;
}
} else if (values.isActive && (values.isPhysical || values.honeyJars > 0)) {
return "Ein physisches Angebot ohne Honigprodukt kann nicht aktiviert werden.";
}
return null;
}
function auditDetail(values: OfferFormValues): string {
const state = values.isActive ? "aktiv" : "inaktiv";
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
}
/** Nach dem Speichern eines Abo-Angebots: Stripe-Preis nachziehen und passend melden. */
async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
revalidatePath(OFFERS_PATH);
revalidatePath("/patenschaft");
const target = `${OFFERS_PATH}/${offerId}`;
if (values.kind === "ABO") {
const stripeError = await syncStripePriceSafely(offerId);
if (stripeError) {
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
}
}
return succeed(target, message);
}
export async function createSponsorshipOfferAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const back = `${OFFERS_PATH}/neu`;
const parsed = parseOfferForm(formData);
if (!parsed.ok) return fail(back, parsed.message);
const values = parsed.value;
const conflict = await findConflict(values, null);
if (conflict) return fail(back, conflict);
let offerId: string;
try {
offerId = await prisma.$transaction(async (tx) => {
const productId = values.kind === "EINMALIG" ? (await tx.product.create({ data: carrierProductData(values) })).id : null;
const offer = await tx.sponsorshipOffer.create({ data: { ...offerData(values), kind: values.kind, productId } });
return offer.id;
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
throw error;
}
await logAdminAction(admin, {
action: "sponsorship_offer.create",
entityType: "SponsorshipOffer",
entityId: offerId,
detail: auditDetail(values),
});
return finishSave(offerId, values, "Angebot angelegt.");
}
export async function updateSponsorshipOfferAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.sponsorshipOffer.findUnique({
where: { id },
select: { id: true, kind: true, productId: true, isActive: true, priceCents: true },
});
if (!existing) return fail(OFFERS_PATH, "Das Angebot wurde nicht gefunden.");
const back = `${OFFERS_PATH}/${id}`;
const parsed = parseOfferForm(formData, existing.kind);
if (!parsed.ok) return fail(back, parsed.message);
const values = parsed.value;
const conflict = await findConflict(values, existing);
if (conflict) return fail(back, conflict);
try {
await prisma.$transaction(async (tx) => {
let productId = existing.productId;
if (values.kind === "EINMALIG") {
// Fehlt das Traegerprodukt (Altbestand), wird es hier nachgeholt —
// ohne Produkt ist eine Einmal-Patenschaft nicht bestellbar.
productId = productId
? (await tx.product.update({ where: { id: productId }, data: carrierProductData(values) })).id
: (await tx.product.create({ data: carrierProductData(values) })).id;
}
await tx.sponsorshipOffer.update({ where: { id }, data: { ...offerData(values), productId } });
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
throw error;
}
const changes = [
existing.isActive !== values.isActive ? (values.isActive ? "aktiviert" : "deaktiviert") : null,
existing.priceCents !== values.priceCents ? `Preis ${formatPrice(existing.priceCents)} → ${formatPrice(values.priceCents)}` : null,
].filter(Boolean);
await logAdminAction(admin, {
action: "sponsorship_offer.update",
entityType: "SponsorshipOffer",
entityId: id,
detail: `${auditDetail(values)}${changes.length > 0 ? `; ${changes.join(", ")}` : ""}`,
});
return finishSave(id, values, "Angebot gespeichert.");
}
@@ -0,0 +1,38 @@
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
/**
* Gemeinsame Helfer der Patenschafts-Actions (bewusst ohne "use server":
* hier liegen keine aufrufbaren Endpunkte, nur Bausteine).
*/
/** Prisma-Fehler P2002: eindeutiger Wert (Slug, Schwelle) bereits vergeben, etwa durch einen parallelen Speichervorgang. */
export function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Legt Stripe-Produkt und -Preis eines Abo-Angebots an bzw. zieht sie nach.
* Scheitert das (Stripe nicht erreichbar, kein Schluessel), bleibt das
* Angebot gespeichert — der Admin bekommt eine klare Meldung und kann erneut
* speichern; syncOfferStripePrice ist idempotent. Gibt die Fehlermeldung
* zurueck oder null.
*/
export async function syncStripePriceSafely(offerId: string): Promise<string | null> {
try {
await syncOfferStripePrice(offerId);
return null;
} catch (error: unknown) {
const message = errorMessage(error);
console.error(`[patenschaft] Stripe-Preis fuer Angebot ${offerId} nicht synchronisiert: ${message}`);
return message;
}
}
/** Ganze Zahl aus einem Formularfeld, sonst null (leer, Komma, Buchstaben). */
export function intOrNull(value: string): number | null {
return /^\d+$/.test(value.trim()) ? Number(value.trim()) : null;
}
@@ -0,0 +1,54 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { MAX_SEPA_HOLD_DAYS, SEPA_HOLD_DAYS_KEY, getSepaHoldDays } from "@/lib/sponsorship/settings";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { intOrNull } from "./sponsorship-shared";
const SUBSCRIPTIONS_PATH = "/admin/patenschaften/abos";
/**
* Hebt die SEPA-Sperre eines Kontos auf (gesetzt nach einer Rueckbuchung).
* Danach kann das Konto neue Abos wieder per Lastschrift abschliessen.
*/
export async function liftSepaBlockAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const customerId = str(formData, "customerId");
const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { email: true, sepaBlockedAt: true } });
if (!customer) return fail(SUBSCRIPTIONS_PATH, "Das Konto wurde nicht gefunden.");
if (!customer.sepaBlockedAt) return succeed(SUBSCRIPTIONS_PATH, "Für dieses Konto besteht keine SEPA-Sperre.");
await prisma.customer.update({ where: { id: customerId }, data: { sepaBlockedAt: null } });
await logAdminAction(admin, {
action: "customer.sepa_unblock",
entityType: "Customer",
entityId: customerId,
detail: `${customer.email} (gesperrt seit ${customer.sepaBlockedAt.toISOString().slice(0, 10)})`,
});
revalidatePath(SUBSCRIPTIONS_PATH);
return succeed(SUBSCRIPTIONS_PATH, `SEPA-Sperre für ${customer.email} aufgehoben.`);
}
export async function updateSepaHoldDaysAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const days = intOrNull(str(formData, "sepaHoldDays"));
if (days === null || days > MAX_SEPA_HOLD_DAYS) {
return fail(SUBSCRIPTIONS_PATH, `Bitte eine ganze Zahl von 0 bis ${MAX_SEPA_HOLD_DAYS} Tagen angeben.`);
}
const previous = await getSepaHoldDays();
await prisma.siteContent.upsert({
where: { key: SEPA_HOLD_DAYS_KEY },
create: { key: SEPA_HOLD_DAYS_KEY, valueDe: String(days) },
update: { valueDe: String(days) },
});
await logAdminAction(admin, {
action: "sponsorship.sepa_hold_days",
entityType: "SiteContent",
entityId: SEPA_HOLD_DAYS_KEY,
detail: `${previous} → ${days} Tage`,
});
revalidatePath(SUBSCRIPTIONS_PATH);
return succeed(SUBSCRIPTIONS_PATH, days === 0 ? "SEPA-Neukunden-Frist abgeschaltet." : `SEPA-Neukunden-Frist auf ${days} Tage gesetzt.`);
}
+201
View File
@@ -0,0 +1,201 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { MAIL_FAILED_HINT } from "@/lib/flash";
import { getBaseUrl } from "@/lib/url";
import { defaultLocale, isLocale } from "@/i18n/config";
import { processSponsorship, resetSponsorshipForRetry, type ProcessResult } from "@/lib/sponsorship/issue";
import { sponsorshipReadyEmail } from "@/lib/sponsorship/emails";
import { reviseSponsorship, type ReviseSponsorshipError } from "@/lib/sponsorship/revise";
import { beeNameFromJson } from "@/app/admin/patenschaften/_lib/revision-data";
import { requireAdmin, fail, succeed, str } from "./_shared";
const LIST_PATH = "/admin/patenschaften";
function detailPath(id: string): string {
return `${LIST_PATH}/${id}`;
}
export async function markSponsorshipPrintedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
// Atomar an den Zustand gebunden: ein Doppelklick oder ein zweiter Tab
// setzt printedAt nicht ein zweites Mal neu.
const updated = await prisma.sponsorship.updateMany({
where: { id, status: "BEREIT", printedAt: null, offer: { isPhysical: true } },
data: { status: "GEDRUCKT", printedAt: new Date() },
});
if (updated.count === 0) {
return fail(detailPath(id), "Nur bereite, noch nicht gedruckte Urkunden physischer Pakete lassen sich als gedruckt markieren.");
}
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true } });
await logAdminAction(admin, {
action: "sponsorship.mark_printed",
entityType: "Sponsorship",
entityId: id,
detail: sponsorship?.number,
});
revalidatePath(LIST_PATH);
return succeed(detailPath(id), "Als gedruckt markiert.");
}
const PROCESS_MESSAGES: Record<ProcessResult, string> = {
ready: "Urkunde erzeugt und Mail versendet.",
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
missing: "Die Patenschaft wurde nicht gefunden.",
};
/**
* "Erneut erzeugen": bei FEHLER werden die Versuche zurueckgesetzt, bei
* AUSSTEHEND wird nur sofort ein Versuch gestartet, statt auf den CronJob zu
* warten. Beides laeuft ueber processSponsorship — Revision 1 entsteht dort
* nur, solange es noch keine gibt, eine Wiederholung erzeugt also nie eine
* zweite Urkunde.
*/
export async function retrySponsorshipAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true, status: true } });
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
if (sponsorship.status !== "FEHLER" && sponsorship.status !== "AUSSTEHEND") {
return fail(detailPath(id), "Die Urkunde ist bereits erzeugt.");
}
if (sponsorship.status === "FEHLER") await resetSponsorshipForRetry(id);
const result = await processSponsorship(id);
await logAdminAction(admin, {
action: "sponsorship.retry",
entityType: "Sponsorship",
entityId: id,
detail: `${sponsorship.number}: ${sponsorship.status} → ${result}`,
});
revalidatePath(LIST_PATH);
if (result !== "ready") return fail(detailPath(id), PROCESS_MESSAGES[result]);
return succeed(detailPath(id), PROCESS_MESSAGES.ready);
}
/**
* "Mail erneut senden" — nur der Versand, sonst nichts. Bewusst NICHT ueber
* processSponsorship: das bricht bei BEREIT/GEDRUCKT sofort ab, und den
* Status dafuer zurueckzusetzen wuerde einen gedruckten Stand verlieren.
* Revisionen werden hier weder gelesen noch angelegt, ausser zum Auslesen
* des aktuellen Bienennamens.
*/
export async function resendSponsorshipMailAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: {
id: true,
number: true,
status: true,
locale: true,
currentRevision: true,
owner: { select: { email: true, firstName: true } },
},
});
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
if ((sponsorship.status !== "BEREIT" && sponsorship.status !== "GEDRUCKT") || sponsorship.currentRevision === 0) {
return fail(detailPath(id), "Die Mail lässt sich erst erneut senden, wenn die Urkunde zugestellt wurde.");
}
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
select: { personalization: true },
});
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const { subject, html, text } = await sponsorshipReadyEmail({
locale,
firstName: sponsorship.owner.firstName,
beeName: beeNameFromJson(revision?.personalization) ?? "",
number: sponsorship.number,
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
});
const sent = await sendMail({ to: sponsorship.owner.email, subject, html, text });
if (!sent) return fail(detailPath(id), MAIL_FAILED_HINT);
await prisma.sponsorship.update({ where: { id }, data: { mailSentAt: new Date() } });
await logAdminAction(admin, {
action: "sponsorship.resend_mail",
entityType: "Sponsorship",
entityId: id,
detail: `${sponsorship.number} an ${sponsorship.owner.email}`,
});
return succeed(detailPath(id), `Mail erneut an ${sponsorship.owner.email} gesendet.`);
}
const FIELD_LABELS: Record<string, string> = {
beeName: "Name der Patenbiene",
recipientName: "Name auf der Urkunde",
giverNames: "Schenkende",
dedication: "Widmung",
occasion: "Anlass",
startDate: "Startdatum",
layout: "Gesamtlayout",
};
const SCHEMA_MESSAGES: Record<string, string> = {
required: "ist ein Pflichtfeld",
too_short: "ist zu kurz",
too_long: "ist zu lang",
too_many_line_breaks: "hat zu viele Zeilenumbrüche",
no_letter: "braucht mindestens einen Buchstaben",
invalid_date: "ist kein gültiges Datum",
};
function revisionErrorMessage(error: ReviseSponsorshipError): string {
switch (error.code) {
case "not_found":
return "Die Patenschaft wurde nicht gefunden.";
case "no_revision":
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
case "reason":
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
case "unknown_template":
return "Unbekannte Vorlagenversion.";
case "unchanged":
return "Keine Änderung gegenüber der aktuellen Revision.";
case "start_date_out_of_range":
return `Das Startdatum muss zwischen ${error.min} und ${error.max} liegen.`;
case "invalid": {
const label = error.field ? FIELD_LABELS[error.field] : "Eingabe";
return `${label} ${SCHEMA_MESSAGES[error.message] ?? "ist ungültig"}.`;
}
case "glyphs":
return `${FIELD_LABELS[error.field] ?? "Text"}: diese Zeichen kann die Urkunde nicht drucken: ${error.characters.join(" ")}`;
case "fit":
return `${FIELD_LABELS[error.field] ?? "Text"} passt so nicht auf die Urkunde — bitte kürzen.`;
case "conflict":
return "Die Urkunde wurde gerade von jemand anderem korrigiert. Bitte die Seite neu laden und erneut prüfen.";
}
}
export async function reviseSponsorshipAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const back = `${detailPath(id)}/korrigieren`;
const result = await reviseSponsorship({
sponsorshipId: id,
personalization: {
beeName: String(formData.get("beeName") ?? ""),
recipientName: String(formData.get("recipientName") ?? ""),
giverNames: String(formData.get("giverNames") ?? ""),
dedication: String(formData.get("dedication") ?? ""),
occasion: str(formData, "occasion"),
startDate: str(formData, "startDate"),
},
reason: String(formData.get("reason") ?? ""),
templateVersion: str(formData, "templateVersion") || null,
notifyCustomer: formData.get("notifyCustomer") === "on",
admin,
});
if (!result.ok) return fail(back, revisionErrorMessage(result.error));
revalidatePath(LIST_PATH);
const message = `Revision ${result.revision} gespeichert.`;
if (result.mailSent === false) return fail(detailPath(id), `${message} ${MAIL_FAILED_HINT}`);
return succeed(detailPath(id), result.mailSent ? `${message} Kunde benachrichtigt.` : message);
}
+6 -1
View File
@@ -11,6 +11,7 @@ import { checkUploadFiles } from "@/lib/upload-health";
import { checkUploadVolume } from "@/lib/uploads";
import { getCurrentAdmin } from "@/lib/auth";
import { HexMark } from "@/components/honeycomb";
import { getSponsorshipAttentionItems } from "@/app/admin/patenschaften/_lib/attention";
const DASHBOARD_ICON_PROPS = {
width: 20,
@@ -266,7 +267,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const stockRejected = stockIssues.rejected;
const unlinkedProducts = stockIssues.unlinked;
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
getCurrentAdmin(),
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
@@ -274,6 +275,9 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: pendingReviewWhere() }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
// Bienenpatenschaft: gescheiterte Ausstellung, liegengebliebener Druck,
// Honigdeckung der Abos, Abos im Verzug (siehe patenschaften/_lib/attention.ts).
getSponsorshipAttentionItems(locale),
]);
const toDo = [
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
@@ -283,6 +287,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
...sponsorshipItems,
].filter((item) => item.count > 0);
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
@@ -0,0 +1,75 @@
import { prisma } from "@/lib/prisma";
import { defaultLocale, isLocale } from "@/i18n/config";
import { personalizationSchema } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { listTemplateVersions } from "@/lib/certificate/templates";
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
import { REVISABLE_FIELDS, revisionTerm } from "@/lib/sponsorship/revise";
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
const PREVIEW_WIDTH_PX = 900;
const WATERMARK = "Korrekturvorschau";
/** Ein Korrekturformular ist klein; alles darueber ist kein legitimer Aufruf. */
const MAX_BODY_BYTES = 8 * 1024;
function invalid(body: Record<string, unknown>): Response {
return Response.json(body, { status: 422, headers: { "Cache-Control": "no-store" } });
}
/**
* Live-Vorschau der Korrektur: dieselbe Pruefung (Schema, Glyphen, Platz)
* und dieselbe Laufzeitregel wie reviseSponsorship, gerendert mit dem
* Wasserzeichen "Korrekturvorschau". Ungueltige Eingaben ergeben 422 mit
* Feld und Fehlercode, damit das Formular die Stelle markieren kann.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function POST(request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/korrekturvorschau">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id } = await ctx.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: { number: true, locale: true, startDate: true, endDate: true, subscriptionId: true, currentRevision: true },
});
if (!sponsorship || sponsorship.currentRevision === 0) return notFoundResponse();
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) return invalid({ code: "too_large" });
let body: Record<string, unknown>;
try {
const parsedBody: unknown = JSON.parse(raw);
body = parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : {};
} catch {
return invalid({ code: "bad_json" });
}
const isSubscription = sponsorship.subscriptionId !== null;
const startDate = sponsorship.startDate.toISOString().slice(0, 10);
const parsed = personalizationSchema.safeParse(isSubscription ? { ...body, startDate } : body);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const field = REVISABLE_FIELDS.find((name) => name === issue?.path[0]) ?? null;
return invalid({ field, code: issue?.message ?? "invalid" });
}
const templateVersion = typeof body.templateVersion === "string" ? body.templateVersion : "";
if (!listTemplateVersions().includes(templateVersion)) return invalid({ field: null, code: "unknown_template" });
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const term = revisionTerm({
isSubscription,
personalization: parsed.data,
startDate: sponsorship.startDate,
endDate: sponsorship.endDate,
});
const fit = checkCertificateFits(parsed.data, locale, { term, templateVersion, number: sponsorship.number });
if (!fit.ok) {
return invalid({ field: fit.field, code: fit.reason, characters: fit.reason === "glyphs" ? fit.characters : [] });
}
const image = await renderCertificateDocumentImage(
{ personalization: parsed.data, locale, term, number: sponsorship.number, templateVersion },
{ widthPx: PREVIEW_WIDTH_PX, format: "webp", watermark: WATERMARK }
);
return new Response(new Uint8Array(image), {
headers: { "Content-Type": "image/webp", "Cache-Control": "private, no-store" },
});
}
@@ -0,0 +1,69 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { startDateRange } from "@/lib/certificate/schema";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import { parseStoredPersonalization, toIsoDate } from "@/lib/sponsorship/certificate-document";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { reviseSponsorshipAction } from "@/app/admin/actions";
import { CorrectionForm } from "../../_components/correction-form";
export default async function AdminSponsorshipCorrectionPage(props: PageProps<"/admin/patenschaften/[id]/korrigieren">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale).correction;
const { id } = await props.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: {
id: true,
number: true,
currentRevision: true,
subscriptionId: true,
createdAt: true,
orderItem: { select: { order: { select: { createdAt: true } } } },
},
});
if (!sponsorship || sponsorship.currentRevision === 0) notFound();
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
select: { personalization: true, templateVersion: true },
});
if (!revision) notFound();
const current = parseStoredPersonalization({ personalization: revision.personalization, templateVersion: revision.templateVersion });
const isSubscription = sponsorship.subscriptionId !== null;
// Dasselbe Fenster wie beim Kauf, gerechnet ab dem Bestelltag (reviseSponsorship prueft es erneut).
const orderDate = toIsoDate(sponsorship.orderItem?.order.createdAt ?? sponsorship.createdAt);
const templateOptions = [
{ value: current.templateVersion, label: t.templatePrevious(current.templateVersion) },
...(current.templateVersion !== CURRENT_TEMPLATE_VERSION
? [{ value: CURRENT_TEMPLATE_VERSION, label: t.templateLatest(CURRENT_TEMPLATE_VERSION) }]
: []),
];
return (
<div className="max-w-6xl">
<BackLink href={`/admin/patenschaften/${id}`} label={t.backToDetail} />
<h1 className="mb-2 text-2xl text-heading">{t.title(sponsorship.number)}</h1>
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.intro}</p>
<CorrectionForm
sponsorshipId={id}
locale={locale}
action={reviseSponsorshipAction}
isSubscription={isSubscription}
startRange={isSubscription ? null : startDateRange(orderDate)}
templateOptions={templateOptions}
initial={{
beeName: current.personalization.beeName,
recipientName: current.personalization.recipientName,
giverNames: current.personalization.giverNames ?? "",
dedication: current.personalization.dedication ?? "",
occasion: current.personalization.occasion,
startDate: current.personalization.startDate,
templateVersion: current.templateVersion,
}}
/>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import { formatDate, formatDateTime } from "@/lib/format";
import { MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipStatusBadge } from "../_components/status-badge";
import { SponsorshipActions } from "../_components/sponsorship-actions";
import { RevisionTable, TransferTable } from "../_components/history-tables";
import { beeNameFromJson } from "../_lib/revision-data";
// A4 hochkant: 595.28 × 841.89 pt — die Vorschau wird in 900 px Breite gerendert.
const PREVIEW_WIDTH = 900;
const PREVIEW_HEIGHT = Math.round((PREVIEW_WIDTH * 841.89) / 595.28);
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="border border-ink/10 bg-background p-6">
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{title}</h2>
{children}
</section>
);
}
export default async function AdminSponsorshipDetailPage(props: PageProps<"/admin/patenschaften/[id]">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale);
const td = t.detail;
const { id } = await props.params;
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
include: {
owner: { select: { id: true, email: true, firstName: true, lastName: true } },
offer: { select: { id: true, name: true, kind: true, isPhysical: true } },
orderItem: { select: { order: { select: { id: true, orderNumber: true } } } },
subscription: { select: { id: true, status: true } },
revisions: { orderBy: { revision: "desc" } },
transfers: { orderBy: { createdAt: "desc" } },
},
});
if (!sponsorship) notFound();
const accountIds = [...new Set(sponsorship.transfers.flatMap((row) => [row.createdById, row.claimedById ?? ""]).filter(Boolean))];
const accounts =
accountIds.length > 0
? await prisma.customer.findMany({ where: { id: { in: accountIds } }, select: { id: true, email: true } })
: [];
const emailOf = new Map(accounts.map((account) => [account.id, account.email]));
const current = sponsorship.revisions.find((row) => row.revision === sponsorship.currentRevision) ?? null;
const beeName = beeNameFromJson(current?.personalization) ?? "—";
const start = formatDate(sponsorship.startDate, locale);
const order = sponsorship.orderItem?.order ?? null;
return (
<div className="max-w-5xl">
<BackLink href="/admin/patenschaften" label={td.backToList} />
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="font-mono text-xs text-ink/60">{sponsorship.number}</p>
<h1 className="mt-1 font-serif text-3xl text-heading">{beeName}</h1>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SponsorshipStatusBadge status={sponsorship.status} label={t.statuses[sponsorship.status]} />
<AdminBadge tone="neutral">{t.kinds[sponsorship.offer.kind]}</AdminBadge>
</div>
</div>
</div>
<div className="mb-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Panel title={td.ownerHeading}>
<p>
{sponsorship.owner.firstName} {sponsorship.owner.lastName}
</p>
<p className="break-all text-sm text-ink/70">{sponsorship.owner.email}</p>
<Link href={`/admin/benutzer/${sponsorship.owner.id}`} className="link-draw mt-2 inline-block text-sm text-heading">
{td.openAccount}
</Link>
</Panel>
<Panel title={td.sourceHeading}>
{order ? (
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw text-heading">
{td.order(order.orderNumber)}
</Link>
) : sponsorship.subscription ? (
<Link href="/admin/patenschaften/abos" className="link-draw text-heading">
{td.subscription} · {t.subscriptionStatuses[sponsorship.subscription.status]}
</Link>
) : (
<p className="text-sm text-ink/65">{td.noSource}</p>
)}
</Panel>
<Panel title={td.offerHeading}>
<Link href={`/admin/patenschaften/angebote/${sponsorship.offer.id}`} className="link-draw text-heading">
{sponsorship.offer.name}
</Link>
<p className="mt-1 text-sm text-ink/65">{sponsorship.offer.isPhysical ? td.physical : td.digital}</p>
</Panel>
<Panel title={td.termHeading}>
<p className="tabular-nums">
{sponsorship.endDate ? t.common.range(start, formatDate(sponsorship.endDate, locale)) : td.termOpen(start)}
</p>
</Panel>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-5">
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
<h2 className="mb-4 text-xs tracking-wide-caps text-ink/65">{td.previewHeading}</h2>
{current ? (
<Image
src={`/admin/patenschaften/${sponsorship.id}/vorschau.webp?r=${sponsorship.currentRevision}`}
alt={td.previewAlt(beeName, sponsorship.currentRevision)}
width={PREVIEW_WIDTH}
height={PREVIEW_HEIGHT}
unoptimized
className="h-auto w-full border border-ink/10 shadow-[0_18px_40px_-28px_rgba(36,53,43,0.6)]"
/>
) : (
<p className="text-sm text-ink/65">{td.noRevision}</p>
)}
</section>
<div className="flex flex-col gap-6 lg:col-span-3">
<Panel title={td.actionsHeading}>
<SponsorshipActions
id={sponsorship.id}
status={sponsorship.status}
currentRevision={sponsorship.currentRevision}
isPhysical={sponsorship.offer.isPhysical}
printed={sponsorship.printedAt !== null}
ownerEmail={sponsorship.owner.email}
t={td}
/>
</Panel>
<Panel title={td.deliveryHeading}>
<ul className="space-y-1 text-sm">
<li className="tabular-nums">{td.attempts(sponsorship.pdfAttempts, MAX_ISSUE_ATTEMPTS)}</li>
{sponsorship.nextAttemptAt ? <li>{td.nextAttempt(formatDateTime(sponsorship.nextAttemptAt, locale))}</li> : null}
<li>{sponsorship.mailSentAt ? td.mailSent(formatDateTime(sponsorship.mailSentAt, locale)) : td.mailNotSent}</li>
{sponsorship.offer.isPhysical ? (
<li>{sponsorship.printedAt ? td.printed(formatDateTime(sponsorship.printedAt, locale)) : td.notPrinted}</li>
) : null}
</ul>
{sponsorship.lastError ? (
<div className="mt-4 border border-red-200 bg-red-50 p-3">
<p className="text-xs tracking-wide-caps text-red-700">{td.lastError}</p>
<p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs text-red-800">{sponsorship.lastError}</p>
</div>
) : null}
</Panel>
</div>
</div>
<div className="mb-8">
<Panel title={td.revisionsHeading}>
<RevisionTable
sponsorshipId={sponsorship.id}
revisions={sponsorship.revisions}
currentRevision={sponsorship.currentRevision}
locale={locale}
t={td}
/>
</Panel>
</div>
<Panel title={td.transfersHeading}>
<TransferTable
now={new Date()}
locale={locale}
t={td}
transfers={sponsorship.transfers.map((row) => ({
id: row.id,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
createdBy: emailOf.get(row.createdById) ?? row.createdById,
claimedBy: row.claimedById ? (emailOf.get(row.claimedById) ?? row.claimedById) : null,
claimedAt: row.claimedAt,
revokedAt: row.revokedAt,
}))}
/>
</Panel>
</div>
);
}
@@ -0,0 +1,35 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { readCertificatePdf } from "@/lib/sponsorship/storage";
import { getAuthorizedAdmin, notFoundResponse } from "../../../_lib/admin-guard";
/**
* Gespeicherte PDF einer Revision (/admin/patenschaften/<id>/revision/3.pdf).
* Ausgeliefert wird immer die abgelegte Datei, nie ein Neu-Rendering — sie
* ist der Beleg dessen, was die Kundschaft bekommen hat bzw. was gedruckt
* wird. ?inline=1 oeffnet sie im Browser (Druck-PDF), sonst Download.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function GET(request: NextRequest, ctx: RouteContext<"/admin/patenschaften/[id]/revision/[rev]">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id, rev } = await ctx.params;
const match = /^(\d{1,6})(?:\.pdf)?$/.exec(rev);
if (!match) return notFoundResponse();
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: Number(match[1]) } },
select: { revision: true, pdfPath: true, sponsorship: { select: { number: true } } },
});
if (!revision) return notFoundResponse();
const pdf = await readCertificatePdf(revision.pdfPath);
if (!pdf) return notFoundResponse();
const filename = `${revision.sponsorship.number}-r${revision.revision}.pdf`;
const disposition = request.nextUrl.searchParams.get("inline") === "1" ? "inline" : "attachment";
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `${disposition}; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
}
@@ -0,0 +1,27 @@
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
import { loadRevisionDocument } from "../../_lib/revision-data";
const PREVIEW_WIDTH_PX = 900;
/**
* Bild der aktuellen Revision fuer die Admin-Detailseite. Gerendert aus den
* gespeicherten Revisionsdaten ueber dieselben Helfer wie PDF und Vorschau —
* ohne Wasserzeichen, weil es die tatsaechlich ausgestellte Urkunde zeigt.
* Nur fuer Admins mit 2FA, sonst 404.
*/
export async function GET(_request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/vorschau.webp">): Promise<Response> {
if (!(await getAuthorizedAdmin())) return notFoundResponse();
const { id } = await ctx.params;
const document = await loadRevisionDocument(id);
if (!document) return notFoundResponse();
const image = await renderCertificateDocumentImage(document.input, { widthPx: PREVIEW_WIDTH_PX, format: "webp" });
return new Response(new Uint8Array(image), {
headers: {
"Content-Type": "image/webp",
// Personenbezogen (Namen, Widmung): nie in geteilten Caches ablegen.
"Cache-Control": "private, no-store",
},
});
}
@@ -0,0 +1,259 @@
"use client";
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { CERTIFICATE_LIMITS, OCCASIONS, countCharacters, type Occasion } from "@/lib/certificate/schema";
import type { Locale } from "@/i18n/config";
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
export interface CorrectionValues {
beeName: string;
recipientName: string;
giverNames: string;
dedication: string;
occasion: Occasion;
startDate: string;
templateVersion: string;
}
type FieldName = keyof CorrectionValues;
interface PreviewError {
field: FieldName | "layout" | null;
message: string;
}
interface CorrectionFormProps {
sponsorshipId: string;
initial: CorrectionValues;
isSubscription: boolean;
startRange: { min: string; max: string } | null;
templateOptions: { value: string; label: string }[];
action: (formData: FormData) => void | Promise<void>;
/** Texte werden hier geholt statt als Prop gereicht: sie enthalten Funktionen, die nicht ueber die Server-Client-Grenze gehen. */
locale: Locale;
}
const PREVIEW_DEBOUNCE_MS = 400;
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2";
function errorText(code: string, characters: string[], t: SponsorshipAdminStrings["correction"]): string {
if (code === "glyphs") return t.errors.glyphs(characters.join(" "));
const known = t.errors[code as Exclude<keyof typeof t.errors, "glyphs">];
return typeof known === "string" ? known : t.previewFailed;
}
/**
* Korrekturformular mit Live-Vorschau. Die Vorschau kommt vom Server
* (/admin/patenschaften/<id>/korrekturvorschau) und nutzt dieselbe
* Layoutlogik wie die spaetere PDF. Bis eine neue gueltige Vorschau da ist,
* bleibt die letzte sichtbar — gedimmt, damit klar ist, dass sie nicht mehr
* dem Formular entspricht.
*/
export function CorrectionForm({
sponsorshipId,
initial,
isSubscription,
startRange,
templateOptions,
action,
locale,
}: CorrectionFormProps) {
const strings = getSponsorshipAdminStrings(locale);
const t = strings.correction;
const occasionLabels = strings.occasions;
const [values, setValues] = useState<CorrectionValues>(initial);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isStale, setIsStale] = useState(true);
const [error, setError] = useState<PreviewError | null>(null);
const urlRef = useRef<string | null>(null);
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(async () => {
setIsStale(true);
try {
const response = await fetch(`/admin/patenschaften/${sponsorshipId}/korrekturvorschau`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
signal: controller.signal,
});
if (response.status === 422) {
const body = (await response.json()) as { field?: PreviewError["field"]; code?: string; characters?: string[] };
setError({ field: body.field ?? null, message: errorText(body.code ?? "", body.characters ?? [], t) });
return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const url = URL.createObjectURL(await response.blob());
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = url;
setPreviewUrl(url);
setError(null);
setIsStale(false);
} catch (cause: unknown) {
if (cause instanceof DOMException && cause.name === "AbortError") return;
setError({ field: null, message: t.previewFailed });
}
}, PREVIEW_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [values, sponsorshipId, t]);
useEffect(
() => () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
},
[]
);
const update = (field: FieldName) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const value = event.target.value;
setValues((previous) => ({ ...previous, [field]: value }));
};
const fieldError = (field: FieldName) =>
error?.field === field ? (
<span role="alert" className="mt-1 block text-xs text-red-700">
{error.message}
</span>
) : null;
const counter = (value: string, max: number) => (
<span className={`mt-1 block text-right text-xs tabular-nums ${countCharacters(value) > max ? "text-red-700" : "text-ink/55"}`}>
{t.counter(countCharacters(value), max)}
</span>
);
const showGivers = values.occasion !== "SELBST";
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
<form action={action} className="space-y-8 border border-ink/10 bg-background p-6 sm:p-8">
<input type="hidden" name="id" value={sponsorshipId} />
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionText}</legend>
<label className="block text-sm">
<span className="text-ink/70">{t.beeName}</span>
<input name="beeName" value={values.beeName} onChange={update("beeName")} required className={inputClass} />
{counter(values.beeName, CERTIFICATE_LIMITS.beeName.max)}
{fieldError("beeName")}
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.recipientName}</span>
<input name="recipientName" value={values.recipientName} onChange={update("recipientName")} required className={inputClass} />
{counter(values.recipientName, CERTIFICATE_LIMITS.recipientName.max)}
{fieldError("recipientName")}
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.occasion}</span>
<select name="occasion" value={values.occasion} onChange={update("occasion")} className={inputClass}>
{OCCASIONS.map((occasion) => (
<option key={occasion} value={occasion}>
{occasionLabels[occasion]}
</option>
))}
</select>
{fieldError("occasion")}
</label>
{showGivers ? (
<label className="block text-sm">
<span className="text-ink/70">{t.giverNames}</span>
<input name="giverNames" value={values.giverNames} onChange={update("giverNames")} className={inputClass} />
{counter(values.giverNames, CERTIFICATE_LIMITS.giverNames.max)}
{fieldError("giverNames")}
</label>
) : null}
<label className="block text-sm">
<span className="text-ink/70">{t.dedication}</span>
<textarea name="dedication" value={values.dedication} onChange={update("dedication")} rows={5} className={inputClass} />
{counter(values.dedication, CERTIFICATE_LIMITS.dedication.max)}
{fieldError("dedication")}
</label>
{isSubscription || !startRange ? (
<div className="text-sm">
<span className="text-ink/70">{t.startDate}</span>
<p className="mt-1 tabular-nums">{values.startDate}</p>
<input type="hidden" name="startDate" value={values.startDate} />
<p className="mt-1 text-xs text-ink/65">{t.startDateSubscription}</p>
</div>
) : (
<label className="block text-sm">
<span className="text-ink/70">{t.startDate}</span>
<input
type="date"
name="startDate"
value={values.startDate}
min={startRange.min}
max={startRange.max}
onChange={update("startDate")}
required
className={inputClass}
/>
<span className="mt-1 block text-xs text-ink/65">{t.startDateHint(startRange.min, startRange.max)}</span>
{fieldError("startDate")}
</label>
)}
</fieldset>
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionTemplate}</legend>
<label className="block text-sm">
<span className="text-ink/70">{t.templateLabel}</span>
<select name="templateVersion" value={values.templateVersion} onChange={update("templateVersion")} className={inputClass}>
{templateOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</fieldset>
<fieldset className="space-y-4">
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionReason}</legend>
<label className="block text-sm">
<span className="text-ink/70">{t.reasonLabel}</span>
<textarea name="reason" required minLength={3} maxLength={500} rows={3} className={inputClass} />
<span className="mt-1 block text-xs text-ink/65">{t.reasonHint}</span>
</label>
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
<input type="checkbox" name="notifyCustomer" className="h-4 w-4 accent-[var(--color-forest)]" />
{t.notifyLabel}
</label>
</fieldset>
<div className="flex justify-end border-t border-ink/10 pt-6">
<button
type="submit"
className="inline-flex items-center justify-center border border-heading bg-forest px-7 py-3 text-sm tracking-wide-caps text-cream transition-colors hover:bg-honey-deep"
>
{t.submit}
</button>
</div>
</form>
<aside className="lg:sticky lg:top-6 lg:self-start">
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{t.previewHeading}</h2>
<div className="relative border border-ink/10 bg-background p-3" aria-live="polite">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- Blob-URL der Live-Vorschau, next/image kann sie nicht optimieren
<img
src={previewUrl}
alt={t.previewAlt}
className={`h-auto w-full transition-opacity duration-300 ${isStale ? "opacity-50" : "opacity-100"}`}
/>
) : (
<div className="flex aspect-[595/842] items-center justify-center text-sm text-ink/60">{t.previewLoading}</div>
)}
{error && (error.field === null || error.field === "layout") ? (
<p role="alert" className="mt-3 text-sm text-red-700">
{error.message}
</p>
) : null}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,114 @@
import { formatDateTime } from "@/lib/format";
import type { Locale } from "@/i18n/config";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
interface RevisionRow {
revision: number;
createdAt: Date;
createdByLabel: string;
reason: string | null;
templateVersion: string;
pdfSha256: string;
}
const thClass = "p-3 font-normal";
/** Alle Revisionen, neueste zuerst; jede mit eigener PDF (alte bleiben abrufbar). */
export function RevisionTable({
sponsorshipId,
revisions,
currentRevision,
locale,
t,
}: {
sponsorshipId: string;
revisions: RevisionRow[];
currentRevision: number;
locale: Locale;
t: SponsorshipAdminStrings["detail"];
}) {
if (revisions.length === 0) return <p className="text-sm text-ink/65">{t.noRevision}</p>;
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
<tr>
<th className={thClass}>{t.colRevision}</th>
<th className={thClass}>{t.colCreatedAt}</th>
<th className={thClass}>{t.colCreatedBy}</th>
<th className={thClass}>{t.colReason}</th>
<th className={thClass}>{t.colTemplate}</th>
<th className={thClass}>{t.colChecksum}</th>
<th className={thClass} />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{revisions.map((row) => (
<tr key={row.revision}>
<td className="whitespace-nowrap p-3 tabular-nums text-heading">
r{row.revision}
{row.revision === currentRevision ? <span className="ml-2 text-xs text-honey-dark">{t.current}</span> : null}
</td>
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.createdAt, locale)}</td>
<td className="p-3">{row.createdByLabel}</td>
<td className="max-w-xs p-3 text-ink/80">{row.reason ?? "—"}</td>
<td className="whitespace-nowrap p-3 font-mono text-xs">{row.templateVersion}</td>
<td className="p-3 font-mono text-xs text-ink/60" title={row.pdfSha256}>
{row.pdfSha256.slice(0, 12)}…
</td>
<td className="p-3 text-right">
<a href={`/admin/patenschaften/${sponsorshipId}/revision/${row.revision}.pdf`} className="link-draw text-heading">
{t.download}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
interface TransferRow {
id: string;
createdAt: Date;
expiresAt: Date;
createdBy: string;
claimedBy: string | null;
claimedAt: Date | null;
revokedAt: Date | null;
}
/** Uebertragungshistorie, nur lesend (Uebertragungen steuert die Kundschaft im Konto). */
export function TransferTable({ transfers, now, locale, t }: { transfers: TransferRow[]; now: Date; locale: Locale; t: SponsorshipAdminStrings["detail"] }) {
if (transfers.length === 0) return <p className="text-sm text-ink/65">{t.noTransfers}</p>;
const statusOf = (row: TransferRow): string => {
if (row.claimedAt) return t.transferClaimed(row.claimedBy ?? "—", formatDateTime(row.claimedAt, locale));
if (row.revokedAt) return t.transferRevoked(formatDateTime(row.revokedAt, locale));
return row.expiresAt < now ? t.transferExpired : t.transferOpen;
};
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
<tr>
<th className={thClass}>{t.colTransferCreated}</th>
<th className={thClass}>{t.colTransferBy}</th>
<th className={thClass}>{t.colExpires}</th>
<th className={thClass}>{t.colTransferStatus}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{transfers.map((row) => (
<tr key={row.id}>
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.createdAt, locale)}</td>
<td className="p-3">{row.createdBy}</td>
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.expiresAt, locale)}</td>
<td className="p-3">{statusOf(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,33 @@
import Link from "next/link";
interface PaginationProps {
page: number;
totalPages: number;
hrefFor: (page: number) => string;
labels: { previous: string; next: string; pageOf: (page: number, total: number) => string };
}
/** Blaettern unter einer Admin-Liste; erscheint erst ab der zweiten Seite. */
export function Pagination({ page, totalPages, hrefFor, labels }: PaginationProps) {
if (totalPages <= 1) return null;
const linkClass = "link-draw text-heading";
return (
<nav aria-label={labels.pageOf(page, totalPages)} className="mt-4 flex items-center justify-between gap-4 text-sm">
{page > 1 ? (
<Link href={hrefFor(page - 1)} className={linkClass} rel="prev">
{labels.previous}
</Link>
) : (
<span aria-hidden="true" />
)}
<span className="tabular-nums text-ink/65">{labels.pageOf(page, totalPages)}</span>
{page < totalPages ? (
<Link href={hrefFor(page + 1)} className={linkClass} rel="next">
{labels.next}
</Link>
) : (
<span aria-hidden="true" />
)}
</nav>
);
}
@@ -0,0 +1,83 @@
import Link from "next/link";
import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import { AdminActionNote } from "@/components/admin-list";
import type { SponsorshipStatus } from "@/generated/prisma/client";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { markSponsorshipPrintedAction, resendSponsorshipMailAction, retrySponsorshipAction } from "@/app/admin/actions";
interface SponsorshipActionsProps {
id: string;
status: SponsorshipStatus;
currentRevision: number;
isPhysical: boolean;
printed: boolean;
ownerEmail: string;
t: SponsorshipAdminStrings["detail"];
}
const buttonClass = "px-4 py-2 text-xs";
/**
* Aktionen einer Patenschaft. Jede Aktion steht nur da, wo sie im aktuellen
* Zustand Sinn ergibt; fehlt eine, sagt eine kurze Notiz warum. Die Server
* Actions pruefen denselben Zustand noch einmal — die Anzeige ist keine
* Sicherheitsgrenze.
*/
export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) {
const issued = currentRevision > 0;
const delivered = status === "BEREIT" || status === "GEDRUCKT";
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
{issued ? (
<a
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
target="_blank"
rel="noopener"
className="inline-flex items-center justify-center border border-heading bg-forest px-4 py-2 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-deep"
>
{t.openPrintPdf}
</a>
) : null}
{isPhysical && status === "BEREIT" && !printed ? (
<ConfirmForm action={markSponsorshipPrintedAction} confirmMessage={t.markPrintedConfirm}>
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="secondary" className={buttonClass}>
{t.markPrinted}
</Button>
</ConfirmForm>
) : null}
{status === "FEHLER" || status === "AUSSTEHEND" ? (
<ConfirmForm action={retrySponsorshipAction} confirmMessage={t.retryConfirm}>
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="secondary" className={buttonClass}>
{status === "FEHLER" ? t.retry : t.retryNow}
</Button>
</ConfirmForm>
) : null}
{delivered && issued ? (
<ConfirmForm action={resendSponsorshipMailAction} confirmMessage={t.resendConfirm(ownerEmail)}>
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="ghost" className={buttonClass}>
{t.resendMail}
</Button>
</ConfirmForm>
) : null}
{issued ? (
<Link
href={`/admin/patenschaften/${id}/korrigieren`}
className="inline-flex items-center justify-center border border-ink/20 px-4 py-2 text-xs tracking-wide-caps text-ink transition-colors hover:border-ink"
>
{t.correct}
</Link>
) : null}
</div>
<div className="flex flex-col gap-1">
{!isPhysical ? <AdminActionNote>{t.notePrintPhysical}</AdminActionNote> : null}
{isPhysical && !delivered ? <AdminActionNote>{t.notePrintReady}</AdminActionNote> : null}
{!delivered ? <AdminActionNote>{t.noteMailReady}</AdminActionNote> : null}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
export type SponsorshipSection = "overview" | "offers" | "subscriptions" | "loyalty";
const SECTIONS: { key: SponsorshipSection; href: string }[] = [
{ key: "overview", href: "/admin/patenschaften" },
{ key: "offers", href: "/admin/patenschaften/angebote" },
{ key: "subscriptions", href: "/admin/patenschaften/abos" },
{ key: "loyalty", href: "/admin/patenschaften/treuestufen" },
];
/**
* Unternavigation des Bereichs. Die Seitenleiste fuehrt mit einem Eintrag
* "Patenschaften" hierher; die vier Teile liegen als Reiter darunter — so
* bleibt die Seitenleiste kurz, und es gibt dort nie zwei aktive Eintraege.
*/
export function SponsorshipTabs({ active, labels }: { active: SponsorshipSection; labels: SponsorshipAdminStrings["tabs"] }) {
return (
<nav aria-label={labels.ariaLabel} className="mb-6 flex gap-1 overflow-x-auto border-b border-ink/10">
{SECTIONS.map((section) => {
const selected = section.key === active;
return (
<Link
key={section.key}
href={section.href}
aria-current={selected ? "page" : undefined}
className={`-mb-px whitespace-nowrap border-b-2 px-4 py-2 text-sm transition-colors ${
selected ? "border-heading text-heading" : "border-transparent text-ink/60 hover:text-heading"
}`}
>
{labels[section.key]}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,29 @@
import { AdminBadge, type AdminBadgeTone } from "@/components/admin-list";
import type { SponsorshipStatus, SubscriptionStatus } from "@/generated/prisma/client";
const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
AUSSTEHEND: "pending",
FEHLER: "danger",
BEREIT: "positive",
GEDRUCKT: "neutral",
};
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
AUSSTEHEND: "neutral",
AKTIV: "positive",
IM_VERZUG: "danger",
GEKUENDIGT: "pending",
BEENDET: "neutral",
};
export function SponsorshipStatusBadge({ status, label }: { status: SponsorshipStatus; label: string }) {
return (
<AdminBadge tone={SPONSORSHIP_TONES[status]} pulse={status === "FEHLER"}>
{label}
</AdminBadge>
);
}
export function SubscriptionStatusBadge({ status, label }: { status: SubscriptionStatus; label: string }) {
return <AdminBadge tone={SUBSCRIPTION_TONES[status]}>{label}</AdminBadge>;
}
@@ -0,0 +1,94 @@
import { ConfirmForm } from "@/components/confirm-form";
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions";
import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings";
import type { HoneyCoverageRow } from "../_lib/coverage";
type Strings = SponsorshipAdminStrings["subscriptions"];
export function SepaBlockButton({ customerId, confirmMessage, label }: { customerId: string; confirmMessage: string; label: string }) {
return (
<ConfirmForm action={liftSepaBlockAction} confirmMessage={confirmMessage}>
<input type="hidden" name="customerId" value={customerId} />
<AdminActionButton>{label}</AdminActionButton>
</ConfirmForm>
);
}
/** Honigdeckung je Produkt; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) {
return (
<section id="deckung" className="border border-ink/10 bg-background p-6">
<h2 className="mb-2 font-serif text-xl text-heading">{t.coverageHeading}</h2>
<p className="mb-4 text-xs text-ink/65">{t.coverageIntro}</p>
{rows.length === 0 ? (
<p className="text-sm text-ink/65">{t.noCoverage}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
<tr>
<th className="p-3 font-normal">{t.colProduct}</th>
<th className="p-3 text-right font-normal">{t.colStock}</th>
<th className="p-3 text-right font-normal">{t.colReserved}</th>
<th className="p-3 text-right font-normal">{t.colFree}</th>
<th className="p-3 text-right font-normal">{t.colPerDelivery}</th>
<th className="p-3 font-normal">{t.colCovers}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{rows.map((row) => (
<tr key={row.productId}>
<td className="p-3">{row.productName}</td>
<td className="p-3 text-right tabular-nums">{row.stock}</td>
<td className="p-3 text-right tabular-nums">{row.reserved}</td>
<td className="p-3 text-right tabular-nums">{row.free}</td>
<td className="p-3 text-right tabular-nums">{row.jarsPerDelivery}</td>
<td className="p-3">
{row.deliveriesCovered === null ? (
"—"
) : row.deliveriesCovered < 1 ? (
<AdminBadge tone="danger" pulse>
{t.coverageWarning}
</AdminBadge>
) : (
<span className="tabular-nums">{t.covers(row.deliveriesCovered)}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function SepaSettingsPanel({ holdDays, t }: { holdDays: number; t: Strings }) {
return (
<section className="border border-ink/10 bg-background p-6">
<h2 className="mb-2 font-serif text-xl text-heading">{t.sepaHeading}</h2>
<p className="mb-4 text-xs text-ink/65">{t.sepaIntro}</p>
<form action={updateSepaHoldDaysAction} className="flex flex-wrap items-end gap-3">
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.sepaLabel}</span>
<input
type="number"
name="sepaHoldDays"
min={0}
max={MAX_SEPA_HOLD_DAYS}
defaultValue={String(holdDays)}
required
className="mt-1 w-28 border border-ink/20 bg-background px-3 py-2 text-sm tabular-nums"
/>
</label>
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
{t.sepaSave}
</button>
</form>
<p className="mt-3 text-xs text-ink/65">{t.sepaCurrent(holdDays)}</p>
</section>
);
}
@@ -0,0 +1,19 @@
import { getCurrentAdmin } from "@/lib/auth";
/**
* Pruefung fuer die Route Handler unter /admin/patenschaften (Vorschau,
* Revisions-PDFs). Route Handler laufen nicht durch das Admin-Layout, also
* gilt hier dieselbe Regel wie in requireAdmin(): Admin-Rolle laut Datenbank
* UND aktive Zwei-Faktor-Authentifizierung. Die Aufrufer antworten bei null
* mit 404, damit von aussen nicht einmal die Existenz einer Patenschaft
* erkennbar ist.
*/
export async function getAuthorizedAdmin(): Promise<{ id: string; email: string } | null> {
const admin = await getCurrentAdmin();
if (!admin || !admin.twoFactorEnabled) return null;
return { id: admin.id, email: admin.email };
}
export function notFoundResponse(): Response {
return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } });
}
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/prisma";
import type { Locale } from "@/i18n/config";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { countUndercovered, getHoneyCoverage } from "./coverage";
import { TO_PRINT_FILTER } from "./sponsorship-filters";
export interface AttentionItem {
count: number;
href: string;
label: string;
}
/** Ab wann eine druckbereite Urkunde als liegengeblieben gilt. */
const PRINT_GRACE_MS = 2 * 24 * 60 * 60 * 1000;
/**
* Eintraege der Patenschaft fuer die Aufmerksamkeitsliste im Dashboard:
* Ausstellung endgueltig gescheitert, Urkunden, die seit ueber zwei Tagen
* auf den Druck warten, Honig, der keine Abo-Lieferung mehr deckt, und Abos
* im Verzug. Nur Eintraege mit count > 0.
*/
export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise<AttentionItem[]> {
const t = getSponsorshipAdminStrings(locale).attention;
const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([
prisma.sponsorship.count({ where: { status: "FEHLER" } }),
// mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die
// Mail ist der letzte Schritt der Ausstellung).
prisma.sponsorship.count({
where: { ...TO_PRINT_FILTER, mailSentAt: { lte: new Date(now.getTime() - PRINT_GRACE_MS) } },
}),
prisma.sponsorshipSubscription.count({ where: { status: "IM_VERZUG" } }),
getHoneyCoverage(now),
]);
const undercovered = countUndercovered(coverage);
return [
{ count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) },
{ count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) },
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage(undercovered) },
{ count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
].filter((item) => item.count > 0);
}
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/prisma";
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
/**
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
* Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts
* berechnet; hier wird nur zusammengestellt und angezeigt.
*/
/** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */
const DEMANDING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
export interface HoneyStock {
stock: number;
reserved: number;
free: number;
}
export interface HoneyCoverageRow extends HoneyStock {
productId: string;
productName: string;
isActive: boolean;
/** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
jarsPerDelivery: number;
/** Wie viele Lieferrunden der Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
deliveriesCovered: number | null;
}
/** Ganze Lieferrunden, die ein Bestand deckt; null, wenn es keinen Bedarf gibt. */
export function deliveriesCovered(stock: number, jarsPerDelivery: number): number | null {
if (jarsPerDelivery <= 0) return null;
return Math.floor(Math.max(0, stock) / jarsPerDelivery);
}
/** Bestand, Abo-Reservierung und freier Bestand je Produkt. */
export async function getHoneyStock(productIds: string[], now: Date = new Date()): Promise<Map<string, HoneyStock>> {
const ids = [...new Set(productIds)];
if (ids.length === 0) return new Map();
const inventories = await prisma.inventory.findMany({
where: { productId: { in: ids } },
select: { productId: true, quantity: true },
});
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
const entries = await Promise.all(
ids.map(async (id) => {
const stock = stockById.get(id) ?? 0;
const reserved = await reservedJarsForProduct(id, now);
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
})
);
return new Map(entries);
}
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverageRow[]> {
const offers = await prisma.sponsorshipOffer.findMany({
where: { kind: "ABO", honeyProductId: { not: null } },
select: { honeyProduct: { select: { id: true, name: true, isActive: true } } },
});
const products = new Map(
offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : []))
);
if (products.size === 0) return [];
const [stock, subscriptions] = await Promise.all([
getHoneyStock([...products.keys()], now),
prisma.sponsorshipSubscription.findMany({
where: {
status: { in: [...DEMANDING_STATUSES] },
offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } },
},
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
}),
]);
const demand = new Map<string, number>();
for (const { offer } of subscriptions) {
if (!offer.honeyProductId) continue;
demand.set(offer.honeyProductId, (demand.get(offer.honeyProductId) ?? 0) + offer.honeyJars);
}
return [...products.values()]
.map((product) => {
const amounts = stock.get(product.id) ?? { stock: 0, reserved: 0, free: 0 };
const jarsPerDelivery = demand.get(product.id) ?? 0;
return {
productId: product.id,
productName: product.name,
isActive: product.isActive,
...amounts,
jarsPerDelivery,
deliveriesCovered: deliveriesCovered(amounts.stock, jarsPerDelivery),
};
})
.sort((a, b) => a.productName.localeCompare(b.productName, "de"));
}
/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */
export function countUndercovered(rows: HoneyCoverageRow[]): number {
return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length;
}
@@ -0,0 +1,79 @@
import { prisma } from "@/lib/prisma";
import { isOfferAvailable } from "@/lib/sponsorship/offers";
import { getHoneyStock, type HoneyStock } from "./coverage";
/** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */
const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
async function loadOffers(where: { id?: string }) {
return prisma.sponsorshipOffer.findMany({
where,
include: {
honeyProduct: { select: { id: true, name: true, isActive: true } },
product: { select: { id: true, priceCents: true } },
},
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
});
}
export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number];
export interface OfferOverviewRow {
offer: OfferRow;
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
isAvailable: boolean;
activeSubscriptions: number;
honeyStock: HoneyStock | null;
}
/**
* Angebote samt Live-Verfuegbarkeit und Honigbestand. Fuer ALLE Angebote,
* auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur
* aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren
* ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable.
*/
export async function getOfferOverview(offerId?: string): Promise<OfferOverviewRow[]> {
const offers = await loadOffers(offerId ? { id: offerId } : {});
const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : []));
const [stock, groups] = await Promise.all([
getHoneyStock(honeyIds),
prisma.sponsorshipSubscription.groupBy({
by: ["offerId"],
where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } },
_count: { _all: true },
}),
]);
const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all]));
return offers.map((offer) => {
const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null;
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
return {
offer,
activeSubscriptions,
honeyStock,
isAvailable: isOfferAvailable({
isActive: offer.isActive,
kind: offer.kind,
hasCarrierProduct: offer.productId !== null,
honeyJars: offer.honeyJars,
honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null,
freeJars: honeyStock?.free ?? 0,
maxActiveSubscriptions: offer.maxActiveSubscriptions,
activeSubscriptions,
}),
};
});
}
/** Auswahl im Formular: aktive Honigprodukte, plus das aktuell verknuepfte, falls es inzwischen inaktiv ist. */
export async function getHoneyProductOptions(currentId: string | null): Promise<{ id: string; name: string; isActive: boolean }[]> {
return prisma.product.findMany({
where: {
category: "HONIG",
OR: [{ isActive: true }, ...(currentId ? [{ id: currentId }] : [])],
},
select: { id: true, name: true, isActive: true },
orderBy: { name: "asc" },
});
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { carrierPriceCents, centsToEuroInput, parseEuroToCents, parseOfferForm } from "./offer-schema";
function form(values: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(values)) data.set(key, value);
return data;
}
const oneTime = {
kind: "EINMALIG",
name: "Honigfreund",
slug: "honigfreund",
description: "Gedruckte Urkunde und ein Glas Honig.",
features: "Gedruckte Urkunde\n\n1 × 500 g Honig\n",
price: "39,00",
honeySharePerJar: "7",
isPhysical: "on",
honeyProductId: "prod_1",
honeyJars: "1",
medalTier: "SILBER",
};
const subscription = {
kind: "ABO",
name: "Abo vierteljährlich",
slug: "abo-vierteljaehrlich",
description: "Monatlich bezahlt, Honig nach drei Monaten.",
price: "12,50",
honeySharePerMonth: "4",
isPhysical: "on",
honeyProductId: "prod_1",
honeyJars: "2",
cycle: "VIERTELJAEHRLICH",
medalTier: "ABO",
};
describe("parseEuroToCents", () => {
it("versteht deutsche und englische Schreibweisen", () => {
expect(parseEuroToCents("19")).toBe(1900);
expect(parseEuroToCents("19,9")).toBe(1990);
expect(parseEuroToCents("19.90 €")).toBe(1990);
expect(parseEuroToCents("1.234,56")).toBe(123456);
});
it("lehnt Unklares ab", () => {
expect(parseEuroToCents("")).toBeNull();
expect(parseEuroToCents("19,999")).toBeNull();
expect(parseEuroToCents("-5")).toBeNull();
expect(parseEuroToCents("abc")).toBeNull();
});
it("wandelt Cent zurueck ins Formularformat", () => {
expect(centsToEuroInput(1990)).toBe("19,90");
});
});
describe("parseOfferForm", () => {
it("liest ein gueltiges Einmal-Angebot und raeumt Abo-Felder ab", () => {
const result = parseOfferForm(form({ ...oneTime, cycle: "ERNTE", honeySharePerMonth: "3" }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toMatchObject({
kind: "EINMALIG",
priceCents: 3900,
honeyShareCentsPerJar: 700,
honeyShareCentsPerMonth: 0,
cycle: null,
features: ["Gedruckte Urkunde", "1 × 500 g Honig"],
nameEn: null,
isActive: false,
});
expect(carrierPriceCents(result.value)).toBe(3200);
});
it("verlangt Honiganteil × Gläser kleiner als den Paketpreis", () => {
const result = parseOfferForm(form({ ...oneTime, honeyJars: "3", honeySharePerJar: "13" }));
expect(result).toEqual({ ok: false, message: "Honiganteil × Gläser muss kleiner als der Paketpreis sein." });
});
it("verlangt bei Glaesern im Paket einen Honiganteil", () => {
const result = parseOfferForm(form({ ...oneTime, honeySharePerJar: "" }));
expect(result).toEqual({ ok: false, message: "Bei Gläsern im Paket bitte den Honiganteil je Glas angeben." });
});
it("verlangt fuer physische Angebote Honigprodukt und Glas", () => {
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" }));
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." });
});
it("erlaubt ein rein digitales Angebot ohne Honig", () => {
const result = parseOfferForm(
form({ ...oneTime, isPhysical: "", honeyProductId: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
);
expect(result.ok).toBe(true);
if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900);
});
it("verlangt beim Abo einen Zyklus und einen Monatsanteil unter dem Monatspreis", () => {
expect(parseOfferForm(form({ ...subscription, cycle: "" }))).toEqual({
ok: false,
message: "Ein Abo braucht einen Lieferzyklus.",
});
expect(parseOfferForm(form({ ...subscription, honeySharePerMonth: "12,50" }))).toEqual({
ok: false,
message: "Der Honiganteil je Monat muss kleiner als der Monatspreis sein.",
});
});
it("liest ein Abo mit Kontingent und leert den Glasanteil", () => {
const result = parseOfferForm(form({ ...subscription, maxActiveSubscriptions: "40", honeySharePerJar: "5" }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({ priceCents: 1250, honeyShareCentsPerMonth: 400, honeyShareCentsPerJar: 0, maxActiveSubscriptions: 40 });
}
});
it("setzt beim Bearbeiten die gespeicherte Art durch", () => {
const result = parseOfferForm(form({ ...subscription, kind: "EINMALIG" }), "ABO");
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.kind).toBe("ABO");
});
it("prueft Slug und Preis", () => {
expect(parseOfferForm(form({ ...oneTime, slug: "Honig Freund" }))).toEqual({
ok: false,
message: "Adresse: nur Kleinbuchstaben, Ziffern und einzelne Bindestriche.",
});
expect(parseOfferForm(form({ ...oneTime, price: "abc" }))).toEqual({
ok: false,
message: "Preis: bitte einen Betrag in Euro angeben, z. B. 19,90.",
});
});
});
@@ -0,0 +1,186 @@
import { z } from "zod";
import type { MedalTier, SponsorshipCycle, SponsorshipOfferKind } from "@/generated/prisma/client";
/**
* Formularpruefung der Patenschafts-Angebote. Rein (ohne Datenbank), damit
* die Geschaeftsregeln — vor allem die Steueraufteilung — testbar bleiben.
* Meldungen auf Deutsch, sie erscheinen unveraendert als Flash-Meldung.
*/
export const OFFER_KINDS = ["EINMALIG", "ABO"] as const satisfies readonly SponsorshipOfferKind[];
export const OFFER_CYCLES = ["VIERTELJAEHRLICH", "ERNTE"] as const satisfies readonly SponsorshipCycle[];
export const MEDAL_TIERS = ["BRONZE", "SILBER", "GOLD", "ABO"] as const satisfies readonly MedalTier[];
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_PRICE_CENTS = 1_000_000;
const MAX_FEATURES = 12;
const MAX_FEATURE_LENGTH = 120;
/**
* Euro-Eingabe in Cent: "19", "19,9", "19,90", "19.90" und "1.234,56".
* null bei allem anderen — lieber ablehnen als einen Preis raten.
*/
export function parseEuroToCents(value: string): number | null {
let text = value.replace(/[\s€]/g, "");
if (text === "") return null;
if (text.includes(",") && text.includes(".")) text = text.replace(/\./g, "");
text = text.replace(",", ".");
if (!/^\d+(\.\d{1,2})?$/.test(text)) return null;
return Math.round(Number(text) * 100);
}
/** Cent als Formularwert, z.B. 1990 → "19,90". */
export function centsToEuroInput(cents: number): string {
return (cents / 100).toFixed(2).replace(".", ",");
}
function euro(label: string, { optional }: { optional: boolean }) {
return z.string().transform((value, ctx) => {
if (optional && value.trim() === "") return 0;
const cents = parseEuroToCents(value);
if (cents === null || cents > MAX_PRICE_CENTS) {
ctx.addIssue({ code: "custom", message: `${label}: bitte einen Betrag in Euro angeben, z. B. 19,90.` });
return z.NEVER;
}
return cents;
});
}
function optionalText(max: number, label: string) {
return z
.string()
.trim()
.max(max, `${label}: höchstens ${max} Zeichen.`)
.transform((value) => (value === "" ? null : value));
}
/** Eine Leistung je Zeile; leere Zeilen fallen weg. */
function featureLines(label: string) {
return z
.string()
.transform((value) => value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))
.pipe(
z
.array(z.string().max(MAX_FEATURE_LENGTH, `${label}: jede Zeile höchstens ${MAX_FEATURE_LENGTH} Zeichen.`))
.max(MAX_FEATURES, `${label}: höchstens ${MAX_FEATURES} Zeilen.`)
);
}
function intField(label: string, min: number, max: number) {
return z.coerce
.number({ error: `${label}: bitte eine ganze Zahl angeben.` })
.int(`${label}: bitte eine ganze Zahl angeben.`)
.min(min, `${label}: mindestens ${min}.`)
.max(max, `${label}: höchstens ${max}.`);
}
const offerFormSchema = z
.object({
kind: z.enum(OFFER_KINDS, { error: "Bitte die Art des Angebots wählen." }),
name: z.string().trim().min(2, "Bitte einen Namen angeben.").max(80, "Name: höchstens 80 Zeichen."),
nameEn: optionalText(80, "Name (Englisch)"),
slug: z
.string()
.trim()
.toLowerCase()
.min(2, "Bitte eine Adresse (Slug) angeben.")
.max(60, "Adresse: höchstens 60 Zeichen.")
.regex(SLUG, "Adresse: nur Kleinbuchstaben, Ziffern und einzelne Bindestriche."),
description: z.string().trim().min(1, "Bitte eine Beschreibung angeben.").max(2000, "Beschreibung: höchstens 2000 Zeichen."),
descriptionEn: optionalText(2000, "Beschreibung (Englisch)"),
features: featureLines("Leistungen"),
featuresEn: featureLines("Leistungen (Englisch)"),
priceCents: euro("Preis", { optional: false }),
honeyShareCentsPerJar: euro("Honiganteil je Glas", { optional: true }),
honeyShareCentsPerMonth: euro("Honiganteil je Monat", { optional: true }),
isActive: z.boolean(),
isPhysical: z.boolean(),
sortOrder: intField("Reihenfolge", 0, 9999),
honeyProductId: z
.string()
.trim()
.max(64)
.transform((value) => (value === "" ? null : value)),
honeyJars: intField("Gläser", 0, 24),
cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)),
maxActiveSubscriptions: z.union([z.literal(""), intField("Höchstzahl aktiver Abos", 1, 100_000)]).transform((value) =>
value === "" ? null : value
),
medalTier: z.enum(MEDAL_TIERS, { error: "Bitte eine Medaillenstufe wählen." }),
})
.superRefine((offer, ctx) => {
const issue = (message: string) => ctx.addIssue({ code: "custom", message });
if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein.");
if (offer.honeyJars > 0 && !offer.honeyProductId) issue("Gläser ohne Honigprodukt: bitte ein Honigprodukt wählen.");
if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) {
issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas.");
}
if (offer.kind === "EINMALIG") {
// Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts
// (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben.
if (offer.honeyJars * offer.honeyShareCentsPerJar >= offer.priceCents) {
issue("Honiganteil × Gläser muss kleiner als der Paketpreis sein.");
}
// Die Honigposition im Checkout kostet genau den Honiganteil je Glas;
// eine 0-€-Position wuerde den Honig steuerlich verschwinden lassen.
if (offer.honeyJars > 0 && offer.honeyShareCentsPerJar <= 0) {
issue("Bei Gläsern im Paket bitte den Honiganteil je Glas angeben.");
}
} else {
if (offer.honeyShareCentsPerMonth >= offer.priceCents) issue("Der Honiganteil je Monat muss kleiner als der Monatspreis sein.");
if (!offer.cycle) issue("Ein Abo braucht einen Lieferzyklus.");
}
})
// Felder, die zur jeweils anderen Art gehoeren, werden geleert — sonst
// blieben z.B. ein Lieferzyklus oder ein Monatsanteil an einem
// Einmal-Angebot haengen, die nirgends angezeigt, aber gespeichert waeren.
.transform((offer) =>
offer.kind === "EINMALIG"
? { ...offer, honeyShareCentsPerMonth: 0, cycle: null, maxActiveSubscriptions: null }
: { ...offer, honeyShareCentsPerJar: 0 }
);
export type OfferFormValues = z.output<typeof offerFormSchema>;
function field(formData: FormData, key: string): string {
return String(formData.get(key) ?? "");
}
/**
* Liest und prueft das Angebotsformular. `fixedKind` setzt beim Bearbeiten
* die gespeicherte Art durch — die Art ist nach dem Anlegen unveraenderlich,
* ein manipuliertes Formularfeld darf daran nichts aendern.
*/
export function parseOfferForm(
formData: FormData,
fixedKind?: SponsorshipOfferKind
): { ok: true; value: OfferFormValues } | { ok: false; message: string } {
const parsed = offerFormSchema.safeParse({
kind: fixedKind ?? field(formData, "kind"),
name: field(formData, "name"),
nameEn: field(formData, "nameEn"),
slug: field(formData, "slug"),
description: field(formData, "description"),
descriptionEn: field(formData, "descriptionEn"),
features: field(formData, "features"),
featuresEn: field(formData, "featuresEn"),
priceCents: field(formData, "price"),
honeyShareCentsPerJar: field(formData, "honeySharePerJar"),
honeyShareCentsPerMonth: field(formData, "honeySharePerMonth"),
isActive: formData.get("isActive") === "on",
isPhysical: formData.get("isPhysical") === "on",
sortOrder: field(formData, "sortOrder") || "0",
honeyProductId: field(formData, "honeyProductId"),
honeyJars: field(formData, "honeyJars") || "0",
cycle: field(formData, "cycle"),
maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(),
medalTier: field(formData, "medalTier"),
});
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
return { ok: true, value: parsed.data };
}
/** Preis des Traegerprodukts einer Einmal-Patenschaft: Paketpreis minus Honiganteil. */
export function carrierPriceCents(offer: Pick<OfferFormValues, "priceCents" | "honeyJars" | "honeyShareCentsPerJar">): number {
return offer.priceCents - offer.honeyJars * offer.honeyShareCentsPerJar;
}
@@ -0,0 +1,65 @@
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { defaultLocale, isLocale } from "@/i18n/config";
import type { CertificateTerm } from "@/lib/certificate/schema";
import { parseStoredPersonalization, type CertificateDocumentInput } from "@/lib/sponsorship/certificate-document";
const termSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("FIXED"), start: z.string(), end: z.string() }),
z.object({ kind: z.literal("OPEN"), start: z.string() }),
z.object({ kind: z.literal("ENDED"), start: z.string(), end: z.string() }),
]);
/** Laufzeit einer gespeicherten Revision; wirft bei einem unerwarteten Format statt still falsch zu rendern. */
export function parseStoredTerm(value: unknown): CertificateTerm {
return termSchema.parse(value);
}
/**
* Bienenname aus einem gespeicherten Personalisierungs-JSON — entweder direkt
* (Revision: { beeName, … }) oder verpackt (Bestellposition/Abo:
* { personalization: { beeName, … }, templateVersion }). Nur fuer Anzeige
* und Suche, deshalb ohne Schema-Pruefung.
*/
export function beeNameFromJson(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as { beeName?: unknown; personalization?: unknown };
if (typeof record.beeName === "string") return record.beeName;
return beeNameFromJson(record.personalization);
}
export interface RevisionDocument {
input: CertificateDocumentInput;
revision: number;
}
/**
* Daten einer gespeicherten Revision so, wie die Urkunden-Helfer sie zum
* Rendern brauchen. Ohne `revision` die aktuelle. null, wenn es die
* Patenschaft oder die Revision nicht gibt.
*/
export async function loadRevisionDocument(sponsorshipId: string, revision?: number): Promise<RevisionDocument | null> {
const sponsorship = await prisma.sponsorship.findUnique({
where: { id: sponsorshipId },
select: { number: true, locale: true, currentRevision: true },
});
if (!sponsorship) return null;
const wanted = revision ?? sponsorship.currentRevision;
if (wanted < 1) return null;
const row = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId, revision: wanted } },
select: { personalization: true, term: true, templateVersion: true },
});
if (!row) return null;
const stored = parseStoredPersonalization({ personalization: row.personalization, templateVersion: row.templateVersion });
return {
revision: wanted,
input: {
personalization: stored.personalization,
templateVersion: stored.templateVersion,
term: parseStoredTerm(row.term),
number: sponsorship.number,
locale: isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale,
},
};
}
@@ -0,0 +1,119 @@
import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search";
import type { Prisma, SponsorshipOfferKind } from "@/generated/prisma/client";
import { beeNameFromJson } from "./revision-data";
/**
* Filter der Patenschaftsliste. "ZU_DRUCKEN" ist kein gespeicherter Status,
* sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde
* bereit, noch nicht gedruckt.
*/
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "ZU_DRUCKEN"] as const;
export type ListStatus = (typeof LIST_STATUSES)[number];
export const TO_PRINT_FILTER = {
status: "BEREIT",
printedAt: null,
offer: { isPhysical: true },
} satisfies Prisma.SponsorshipWhereInput;
export const PAGE_SIZE = 50;
/** Obergrenze der Zeilen, die fuer die Bienennamen-Suche gelesen werden (siehe sponsorshipIdsByBeeName). */
const BEE_SEARCH_LIMIT = 5000;
export interface SponsorshipListFilters {
query: string;
status: ListStatus | null;
kind: SponsorshipOfferKind | null;
offerId: string | null;
page: number;
}
type SearchParams = Record<string, string | string[] | undefined>;
function single(params: SearchParams, key: string): string {
const value = params[key];
return typeof value === "string" ? value.trim() : "";
}
export function parseListFilters(params: SearchParams): SponsorshipListFilters {
const status = single(params, "status");
const kind = single(params, "art");
const page = Number.parseInt(single(params, "seite"), 10);
return {
query: single(params, "q").slice(0, 100),
status: (LIST_STATUSES as readonly string[]).includes(status) ? (status as ListStatus) : null,
kind: kind === "EINMALIG" || kind === "ABO" ? kind : null,
offerId: single(params, "angebot") || null,
page: Number.isFinite(page) && page > 1 ? page : 1,
};
}
/** URL der Liste mit den aktuellen Filtern; `page` ueberschreibt die Seite. */
export function listHref(filters: SponsorshipListFilters, page: number): string {
const params = new URLSearchParams();
if (filters.query) params.set("q", filters.query);
if (filters.status) params.set("status", filters.status);
if (filters.kind) params.set("art", filters.kind);
if (filters.offerId) params.set("angebot", filters.offerId);
if (page > 1) params.set("seite", String(page));
const query = params.toString();
return query ? `/admin/patenschaften?${query}` : "/admin/patenschaften";
}
/**
* Der Bienenname steht nur im JSON der Personalisierung (Revision,
* Bestellposition, Abo). JSON-Pfadfilter sind zwischen Postgres und MySQL
* nicht gleich formuliert und im generierten Client verschieden typisiert —
* deshalb wird hier im Speicher gesucht. Bei der Groesse einer Imkerei ist
* das billig; die Obergrenze verhindert, dass es je unbemerkt teuer wird.
* Gefunden werden auch fruehere Namen (vor einer Korrektur).
*/
async function sponsorshipIdsByBeeName(query: string): Promise<string[]> {
const needle = query.toLocaleLowerCase("de");
const matches = (value: unknown) => beeNameFromJson(value)?.toLocaleLowerCase("de").includes(needle) ?? false;
const notIssued = { sponsorship: { is: { currentRevision: 0 } } };
const [revisions, orderItems, subscriptions] = await Promise.all([
prisma.sponsorshipRevision.findMany({
select: { sponsorshipId: true, personalization: true },
orderBy: { createdAt: "desc" },
take: BEE_SEARCH_LIMIT,
}),
prisma.orderItem.findMany({
where: notIssued,
select: { personalization: true, sponsorship: { select: { id: true } } },
take: BEE_SEARCH_LIMIT,
}),
prisma.sponsorshipSubscription.findMany({
where: notIssued,
select: { personalization: true, sponsorship: { select: { id: true } } },
take: BEE_SEARCH_LIMIT,
}),
]);
const ids = new Set<string>();
for (const row of revisions) if (matches(row.personalization)) ids.add(row.sponsorshipId);
for (const row of [...orderItems, ...subscriptions]) {
if (row.sponsorship && matches(row.personalization)) ids.add(row.sponsorship.id);
}
return [...ids];
}
export async function buildSponsorshipWhere(filters: SponsorshipListFilters): Promise<Prisma.SponsorshipWhereInput> {
const and: Prisma.SponsorshipWhereInput[] = [];
if (filters.status === "ZU_DRUCKEN") and.push(TO_PRINT_FILTER);
else if (filters.status) and.push({ status: filters.status });
if (filters.kind) and.push({ offer: { kind: filters.kind } });
if (filters.offerId) and.push({ offerId: filters.offerId });
if (filters.query) {
const beeIds = await sponsorshipIdsByBeeName(filters.query);
and.push({
OR: [
{ number: insensitiveContains(filters.query) },
{ owner: { email: insensitiveContains(filters.query) } },
...(beeIds.length > 0 ? [{ id: { in: beeIds } }] : []),
],
});
}
return and.length > 0 ? { AND: and } : {};
}
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { nextDeliveryView } from "./subscription-view";
import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage";
describe("nextDeliveryView", () => {
it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => {
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 0, nextWindowStart: null })).toEqual({
kind: "quarterly",
remaining: 3,
});
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 2, nextWindowStart: null })).toEqual({
kind: "quarterly",
remaining: 1,
});
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 5, nextWindowStart: null })).toEqual({
kind: "quarterly",
remaining: 0,
});
});
it("Ernte: naechstes Fenster und Fortschritt bis sechs Monate", () => {
const start = new Date("2027-07-01T00:00:00Z");
expect(nextDeliveryView({ cycle: "ERNTE", countingPaidMonths: 8, nextWindowStart: start })).toEqual({
kind: "harvest",
windowStart: start,
paid: 6,
needed: 6,
});
});
it("ohne Zyklus keine Angabe", () => {
expect(nextDeliveryView({ cycle: null, countingPaidMonths: 4, nextWindowStart: null })).toEqual({ kind: "none" });
});
});
describe("Honigdeckung", () => {
it("rechnet ganze Lieferrunden und ignoriert fehlenden Bedarf", () => {
expect(deliveriesCovered(10, 4)).toBe(2);
expect(deliveriesCovered(3, 4)).toBe(0);
expect(deliveriesCovered(-2, 4)).toBe(0);
expect(deliveriesCovered(10, 0)).toBeNull();
});
it("zaehlt nur Produkte unter einer Lieferrunde", () => {
const row = (deliveries: number | null): HoneyCoverageRow => ({
productId: "p",
productName: "Honig",
isActive: true,
stock: 0,
reserved: 0,
free: 0,
jarsPerDelivery: deliveries === null ? 0 : 2,
deliveriesCovered: deliveries,
});
expect(countUndercovered([row(0), row(1), row(null), row(0)])).toBe(2);
});
});
@@ -0,0 +1,38 @@
import type { SponsorshipCycle } from "@/generated/prisma/client";
/**
* Anzeige "naechste Lieferung" in der Abo-Liste. Nur eine Lesehilfe fuer den
* Admin — verbindlich entscheidet der taegliche Lieferlauf
* (lib/sponsorship-subscription). Die Regeln stammen aus der Spec ("Schutz
* vor Verlust: Lieferung erst nach Bezahlung").
*/
/** Vierteljaehrlich: Anspruch nach jeweils drei bezahlten Monaten. */
export const QUARTERLY_PAID_MONTHS = 3;
/** Ernte: frueheste Lieferung nach sechs bezahlten Monaten seit der letzten. */
export const HARVEST_PAID_MONTHS = 6;
export type NextDeliveryView =
| { kind: "quarterly"; remaining: number }
| { kind: "harvest"; windowStart: Date | null; paid: number; needed: number }
| { kind: "none" };
/**
* @param countingPaidMonths bezahlte, nicht zurueckgebuchte Monate, die schon
* fuer eine Lieferung zaehlen (SEPA-Frist abgelaufen) und noch keiner
* Lieferung zugeordnet sind.
*/
export function nextDeliveryView(input: {
cycle: SponsorshipCycle | null;
countingPaidMonths: number;
nextWindowStart: Date | null;
}): NextDeliveryView {
const paid = Math.max(0, input.countingPaidMonths);
if (input.cycle === "VIERTELJAEHRLICH") {
return { kind: "quarterly", remaining: Math.max(0, QUARTERLY_PAID_MONTHS - paid) };
}
if (input.cycle === "ERNTE") {
return { kind: "harvest", windowStart: input.nextWindowStart, paid: Math.min(paid, HARVEST_PAID_MONTHS), needed: HARVEST_PAID_MONTHS };
}
return { kind: "none" };
}
+197
View File
@@ -0,0 +1,197 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminBadge, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { formatDate } from "@/lib/format";
import type { SubscriptionStatus } from "@/generated/prisma/client";
import type { Locale } from "@/i18n/config";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
import { SubscriptionStatusBadge } from "../_components/status-badge";
import { CoveragePanel, SepaSettingsPanel, SepaBlockButton } from "../_components/subscription-panels";
import { getHoneyCoverage } from "../_lib/coverage";
import { nextDeliveryView, type NextDeliveryView } from "../_lib/subscription-view";
import { getSepaHoldDays } from "@/lib/sponsorship/settings";
const SUBSCRIPTION_STATUSES = ["AUSSTEHEND", "AKTIV", "IM_VERZUG", "GEKUENDIGT", "BEENDET"] as const satisfies readonly SubscriptionStatus[];
const LIST_LIMIT = 200;
function describeNextDelivery(view: NextDeliveryView, ts: SponsorshipAdminStrings["subscriptions"], locale: Locale): string {
if (view.kind === "quarterly") return ts.nextQuarterly(view.remaining);
if (view.kind === "harvest") {
return view.windowStart ? ts.nextHarvest(formatDate(view.windowStart, locale), view.paid, view.needed) : ts.noHarvestWindow;
}
return "—";
}
export default async function AdminSponsorshipSubscriptionsPage(props: PageProps<"/admin/patenschaften/abos">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale);
const ts = t.subscriptions;
const { status } = await props.searchParams;
const statusFilter = (SUBSCRIPTION_STATUSES as readonly string[]).includes(String(status)) ? (status as SubscriptionStatus) : null;
const now = new Date();
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const [subscriptions, windows, coverage, holdDays, blockedCustomers] = await Promise.all([
prisma.sponsorshipSubscription.findMany({
where: statusFilter ? { status: statusFilter } : {},
orderBy: { createdAt: "desc" },
take: LIST_LIMIT,
include: {
customer: { select: { id: true, email: true, firstName: true, lastName: true, sepaBlockedAt: true } },
offer: { select: { id: true, name: true, cycle: true } },
sponsorship: { select: { id: true, number: true } },
payments: { select: { reversedAt: true, deliveryId: true, countsFrom: true } },
deliveries: { orderBy: { dueAt: "desc" }, take: 1, select: { dueAt: true } },
},
}),
prisma.harvestWindow.findMany({ where: { endsOn: { gte: today } }, orderBy: { startsOn: "asc" }, select: { offerId: true, startsOn: true } }),
getHoneyCoverage(now),
getSepaHoldDays(),
prisma.customer.findMany({
where: { sepaBlockedAt: { not: null } },
orderBy: { sepaBlockedAt: "desc" },
select: { id: true, email: true, firstName: true, lastName: true, sepaBlockedAt: true },
}),
]);
const nextWindowByOffer = new Map<string, Date>();
for (const window of windows) if (!nextWindowByOffer.has(window.offerId)) nextWindowByOffer.set(window.offerId, window.startsOn);
const selectClass = "border border-ink/20 bg-background px-3 py-2 text-sm";
return (
<div>
<BackLink href="/admin" label={t.common.backToDashboard} />
<AdminPageHeader title={ts.title} intro={ts.intro} />
<SponsorshipTabs active="subscriptions" labels={t.tabs} />
<form method="GET" className="mb-6 flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{ts.statusLabel}</span>
<select name="status" defaultValue={statusFilter ?? ""} className={selectClass}>
<option value="">{ts.statusAll}</option>
{SUBSCRIPTION_STATUSES.map((value) => (
<option key={value} value={value}>
{t.subscriptionStatuses[value]}
</option>
))}
</select>
</label>
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
{t.common.filter}
</button>
{statusFilter ? (
<Link href="/admin/patenschaften/abos" className="link-draw py-2 text-sm text-ink/65">
{t.common.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto border border-ink/10 bg-background">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-ink/65">
<tr>
<th className="p-4">{ts.colSubscription}</th>
<th className="p-4">{ts.colCustomer}</th>
<th className="p-4">{ts.colOffer}</th>
<th className="p-4">{ts.colStatus}</th>
<th className="p-4 text-right">{ts.colPaid}</th>
<th className="p-4">{ts.colNextDelivery}</th>
<th className="p-4">{ts.colDelinquency}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{subscriptions.map((subscription) => {
const paid = subscription.payments.filter((payment) => !payment.reversedAt);
const counting = paid.filter((payment) => !payment.deliveryId && payment.countsFrom <= now).length;
const next = nextDeliveryView({
cycle: subscription.offer.cycle,
countingPaidMonths: counting,
nextWindowStart: nextWindowByOffer.get(subscription.offer.id) ?? null,
});
const lastDelivery = subscription.deliveries[0]?.dueAt;
return (
<tr key={subscription.id} className="align-top transition-colors hover:bg-ink/[0.03]">
<td className="whitespace-nowrap p-4 font-mono text-xs">
{subscription.sponsorship ? (
<Link href={`/admin/patenschaften/${subscription.sponsorship.id}`} className="link-draw text-heading">
{subscription.sponsorship.number}
</Link>
) : (
<span className="text-ink/60">{ts.noSponsorship}</span>
)}
</td>
<td className="p-4">
{subscription.customer.firstName} {subscription.customer.lastName}
<br />
<span className="text-xs text-ink/65">{subscription.customer.email}</span>
{subscription.customer.sepaBlockedAt ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<AdminBadge tone="danger">{ts.sepaBlocked}</AdminBadge>
<SepaBlockButton customerId={subscription.customer.id} confirmMessage={ts.liftSepaConfirm(subscription.customer.email)} label={ts.liftSepa} />
</div>
) : null}
</td>
<td className="p-4">
{subscription.offer.name}
{subscription.offer.cycle ? <span className="block text-xs text-ink/65">{t.cycles[subscription.offer.cycle]}</span> : null}
</td>
<td className="p-4">
<SubscriptionStatusBadge status={subscription.status} label={t.subscriptionStatuses[subscription.status]} />
{subscription.cancelAt ? <span className="mt-1 block text-xs text-ink/65">{ts.cancelAt(formatDate(subscription.cancelAt, locale))}</span> : null}
</td>
<td className="p-4 text-right tabular-nums">{paid.length}</td>
<td className="p-4">
{subscription.status === "BEENDET" ? "—" : describeNextDelivery(next, ts, locale)}
{lastDelivery ? <span className="mt-1 block text-xs text-ink/65">{ts.lastDelivery(formatDate(lastDelivery, locale))}</span> : null}
</td>
<td className="whitespace-nowrap p-4 text-xs">
{subscription.delinquentSince ? (
<span className="text-red-700">{t.common.since(formatDate(subscription.delinquentSince, locale))}</span>
) : (
"—"
)}
</td>
</tr>
);
})}
</tbody>
</table>
{subscriptions.length === 0 ? <AdminEmptyState message={statusFilter ? ts.emptyFiltered : ts.empty} /> : null}
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<CoveragePanel rows={coverage} t={ts} />
</div>
<div className="flex flex-col gap-6 lg:col-span-2">
<SepaSettingsPanel holdDays={holdDays} t={ts} />
<section className="border border-ink/10 bg-background p-6">
<h2 className="mb-2 font-serif text-xl text-heading">{ts.blockedHeading}</h2>
<p className="mb-4 text-xs text-ink/65">{ts.blockedIntro}</p>
{blockedCustomers.length === 0 ? (
<p className="text-sm text-ink/65">{ts.noBlocked}</p>
) : (
<ul className="divide-y divide-ink/10">
{blockedCustomers.map((customer) => (
<li key={customer.id} className="flex flex-wrap items-center justify-between gap-3 py-3 text-sm">
<span>
{customer.firstName} {customer.lastName}
<span className="block text-xs text-ink/65">{customer.email}</span>
{customer.sepaBlockedAt ? (
<span className="block text-xs text-ink/65">{ts.blockedSince(formatDate(customer.sepaBlockedAt, locale))}</span>
) : null}
</span>
<SepaBlockButton customerId={customer.id} confirmMessage={ts.liftSepaConfirm(customer.email)} label={ts.liftSepa} />
</li>
))}
</ul>
)}
</section>
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { AdminActionButton, AdminEmptyState } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { createHarvestWindowAction, deleteHarvestWindowAction, updateHarvestWindowAction } from "@/app/admin/actions";
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm";
function isoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function WindowFields({ startsOn, endsOn, plannedJars, labels }: {
startsOn: string;
endsOn: string;
plannedJars: number;
labels: { startsOn: string; endsOn: string; plannedJars: string };
}) {
return (
<>
<label className="block text-sm">
<span className="text-xs text-ink/65">{labels.startsOn}</span>
<input type="date" name="startsOn" defaultValue={startsOn} required className={inputClass} />
</label>
<label className="block text-sm">
<span className="text-xs text-ink/65">{labels.endsOn}</span>
<input type="date" name="endsOn" defaultValue={endsOn} required className={inputClass} />
</label>
<label className="block text-sm">
<span className="text-xs text-ink/65">{labels.plannedJars}</span>
<input type="number" name="plannedJars" min={0} defaultValue={String(plannedJars)} className={inputClass} />
</label>
</>
);
}
export default async function AdminHarvestWindowsPage(props: PageProps<"/admin/patenschaften/angebote/[id]/erntefenster">) {
const t = getSponsorshipAdminStrings(await getLocale());
const th = t.harvest;
const { id } = await props.params;
const offer = await prisma.sponsorshipOffer.findUnique({
where: { id },
select: { id: true, name: true, kind: true, cycle: true, harvestWindows: { orderBy: { startsOn: "asc" } } },
});
if (!offer) notFound();
const back = <BackLink href={`/admin/patenschaften/angebote/${offer.id}`} label={th.backToOffer} />;
if (offer.kind !== "ABO" || offer.cycle !== "ERNTE") {
return (
<div className="max-w-3xl">
{back}
<h1 className="mb-4 text-2xl text-heading">{th.title(offer.name)}</h1>
<AdminEmptyState message={th.notHarvest} bordered />
</div>
);
}
const today = isoDate(new Date());
return (
<div className="max-w-3xl">
{back}
<h1 className="mb-2 text-2xl text-heading">{th.title(offer.name)}</h1>
<p className="mb-8 max-w-2xl text-sm text-ink/70">{th.intro}</p>
<div className="mb-10 space-y-3">
{offer.harvestWindows.length === 0 ? <AdminEmptyState message={th.empty} bordered /> : null}
{offer.harvestWindows.map((window) => {
const isPast = isoDate(window.endsOn) < today;
return (
<div key={window.id} className={`flex flex-wrap items-end gap-4 border border-ink/10 bg-background p-4 ${isPast ? "opacity-70" : ""}`}>
<form action={updateHarvestWindowAction} className="grid flex-1 grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]">
<input type="hidden" name="id" value={window.id} />
<WindowFields startsOn={isoDate(window.startsOn)} endsOn={isoDate(window.endsOn)} plannedJars={window.plannedJars} labels={th} />
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
{t.common.save}
</button>
</form>
<div className="flex items-center gap-3 pb-2">
{isPast ? <span className="text-xs text-ink/60">{th.past}</span> : null}
<ConfirmForm action={deleteHarvestWindowAction} confirmMessage={th.deleteConfirm}>
<input type="hidden" name="id" value={window.id} />
<AdminActionButton tone="danger">{t.common.delete}</AdminActionButton>
</ConfirmForm>
</div>
</div>
);
})}
</div>
<section className="border border-ink/10 bg-background p-6">
<h2 className="mb-4 font-serif text-xl text-heading">{th.newHeading}</h2>
<form action={createHarvestWindowAction} className="grid grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]">
<input type="hidden" name="offerId" value={offer.id} />
<WindowFields startsOn="" endsOn="" plannedJars={0} labels={th} />
<button type="submit" className="border border-heading bg-forest px-4 py-2 text-sm text-cream transition-colors hover:bg-honey-deep">
{th.add}
</button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,76 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import { asStringArray } from "@/lib/json-array";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { updateSponsorshipOfferAction } from "@/app/admin/actions";
import { OfferForm } from "../../_components/offer-form";
import { getHoneyProductOptions, getOfferOverview } from "../../_lib/offer-overview";
export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) {
const t = getSponsorshipAdminStrings(await getLocale());
const tf = t.offerForm;
const { id } = await props.params;
const [row] = await getOfferOverview(id);
if (!row) notFound();
const { offer, isAvailable, honeyStock, activeSubscriptions } = row;
const honeyOptions = await getHoneyProductOptions(offer.honeyProductId);
return (
<div className="max-w-3xl">
<BackLink href="/admin/patenschaften/angebote" label={tf.backToOffers} />
<h1 className="mb-6 text-2xl text-heading">{tf.editTitle(offer.name)}</h1>
{/* Live-Verfuegbarkeit: dieselbe Entscheidung wie auf der Verkaufsseite. */}
<section className="mb-8 border border-ink/10 bg-background p-6">
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{tf.availabilityHeading}</h2>
<div className="flex flex-wrap items-center gap-3">
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? t.offers.available : t.offers.unavailable}</AdminBadge>
<span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span>
</div>
{honeyStock ? (
<p className="mt-3 text-sm tabular-nums text-ink/75">{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}</p>
) : null}
{offer.kind === "ABO" ? (
<p className="mt-1 text-sm tabular-nums text-ink/75">{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}</p>
) : null}
{offer.kind === "ABO" && offer.cycle === "ERNTE" ? (
<Link href={`/admin/patenschaften/angebote/${offer.id}/erntefenster`} className="link-draw mt-3 inline-block text-sm text-heading">
{t.offers.harvestWindows} →
</Link>
) : null}
</section>
<OfferForm
isNew={false}
honeyOptions={honeyOptions}
action={updateSponsorshipOfferAction}
t={t}
defaults={{
id: offer.id,
kind: offer.kind,
name: offer.name,
nameEn: offer.nameEn ?? "",
slug: offer.slug,
description: offer.description,
descriptionEn: offer.descriptionEn ?? "",
features: asStringArray(offer.features),
featuresEn: asStringArray(offer.featuresEn),
priceCents: offer.priceCents,
honeyShareCentsPerJar: offer.honeyShareCentsPerJar,
honeyShareCentsPerMonth: offer.honeyShareCentsPerMonth,
isActive: offer.isActive,
isPhysical: offer.isPhysical,
sortOrder: offer.sortOrder,
honeyProductId: offer.honeyProductId,
honeyJars: offer.honeyJars,
cycle: offer.cycle,
maxActiveSubscriptions: offer.maxActiveSubscriptions,
medalTier: offer.medalTier,
}}
/>
</div>
);
}
@@ -0,0 +1,18 @@
import { BackLink } from "@/components/back-link";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { createSponsorshipOfferAction } from "@/app/admin/actions";
import { EMPTY_OFFER, OfferForm } from "../../_components/offer-form";
import { getHoneyProductOptions } from "../../_lib/offer-overview";
export default async function AdminSponsorshipOfferNewPage() {
const t = getSponsorshipAdminStrings(await getLocale());
const honeyOptions = await getHoneyProductOptions(null);
return (
<div className="max-w-3xl">
<BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} />
<h1 className="mb-8 text-2xl text-heading">{t.offerForm.newTitle}</h1>
<OfferForm defaults={EMPTY_OFFER} isNew honeyOptions={honeyOptions} action={createSponsorshipOfferAction} t={t} />
</div>
);
}
@@ -0,0 +1,127 @@
import { BackLink } from "@/components/back-link";
import {
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview";
/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */
function honeyStockRows(rows: OfferOverviewRow[]) {
const byProduct = new Map<string, { name: string; isActive: boolean; stock: number; reserved: number; free: number }>();
for (const { offer, honeyStock } of rows) {
if (!offer.honeyProduct || !honeyStock || byProduct.has(offer.honeyProduct.id)) continue;
byProduct.set(offer.honeyProduct.id, { name: offer.honeyProduct.name, isActive: offer.honeyProduct.isActive, ...honeyStock });
}
return [...byProduct.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name, "de"));
}
export default async function AdminSponsorshipOffersPage() {
const t = getSponsorshipAdminStrings(await getLocale());
const to = t.offers;
const rows = await getOfferOverview();
const stockRows = honeyStockRows(rows);
const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer };
return (
<div>
<BackLink href="/admin" label={t.common.backToDashboard} />
<AdminPageHeader title={to.title} intro={to.intro} action={newOffer} />
<SponsorshipTabs active="offers" labels={t.tabs} />
<div className="overflow-x-auto border border-ink/10 bg-background">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-ink/65">
<tr>
<th className="p-4">{to.colName}</th>
<th className="p-4">{to.colKind}</th>
<th className="p-4">{to.colPrice}</th>
<th className="p-4">{to.colHoney}</th>
<th className="p-4">{to.colMedal}</th>
<th className="p-4">{to.colStatus}</th>
<th className="p-4">{to.colAvailability}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{rows.map(({ offer, isAvailable }) => (
<tr key={offer.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4">
<span className="text-heading">{offer.name}</span>
<br />
<span className="font-mono text-xs text-ink/60">{offer.slug}</span>
</td>
<td className="p-4">
{t.kinds[offer.kind]}
{offer.cycle ? <span className="block text-xs text-ink/65">{t.cycles[offer.cycle]}</span> : null}
</td>
<td className="whitespace-nowrap p-4 tabular-nums">
{formatPrice(offer.priceCents)}
{offer.kind === "ABO" ? <span className="text-ink/65"> {to.perMonth}</span> : null}
</td>
<td className="p-4">
{offer.honeyProduct && offer.honeyJars > 0 ? to.jars(offer.honeyJars, offer.honeyProduct.name) : <span className="text-ink/60">{to.noHoney}</span>}
</td>
<td className="p-4">{t.medals[offer.medalTier]}</td>
<td className="p-4">
<AdminBadge tone={offer.isActive ? "positive" : "neutral"}>{offer.isActive ? to.active : to.inactive}</AdminBadge>
</td>
<td className="p-4">
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? to.available : to.unavailable}</AdminBadge>
</td>
<td className="p-4">
<AdminRowActions>
{offer.kind === "ABO" && offer.cycle === "ERNTE" ? (
<AdminActionLink href={`/admin/patenschaften/angebote/${offer.id}/erntefenster`}>{to.harvestWindows}</AdminActionLink>
) : null}
<AdminActionLink href={`/admin/patenschaften/angebote/${offer.id}`}>{to.edit}</AdminActionLink>
</AdminRowActions>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 ? <AdminEmptyState message={to.empty} action={newOffer} /> : null}
</div>
<section className="mt-10">
<h2 className="mb-3 text-lg text-heading">{to.stockHeading}</h2>
{stockRows.length === 0 ? (
<p className="text-sm text-ink/65">{to.noLinkedHoney}</p>
) : (
<div className="overflow-x-auto border border-ink/10 bg-background">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-ink/65">
<tr>
<th className="p-4">{to.colProduct}</th>
<th className="p-4 text-right">{to.colStock}</th>
<th className="p-4 text-right">{to.colReserved}</th>
<th className="p-4 text-right">{to.colFree}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{stockRows.map(([productId, row]) => (
<tr key={productId}>
<td className="p-4">
{row.name}
{row.isActive ? null : <span className="ml-2 text-xs text-ink/60">({to.inactive})</span>}
</td>
<td className="p-4 text-right tabular-nums">{row.stock}</td>
<td className="p-4 text-right tabular-nums">{row.reserved}</td>
<td className="p-4 text-right tabular-nums text-heading">{row.free}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminBadge, AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
import { formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { SponsorshipTabs } from "./_components/sponsorship-tabs";
import { SponsorshipStatusBadge } from "./_components/status-badge";
import { Pagination } from "./_components/pagination";
import { beeNameFromJson } from "./_lib/revision-data";
import { LIST_STATUSES, PAGE_SIZE, buildSponsorshipWhere, listHref, parseListFilters } from "./_lib/sponsorship-filters";
const selectClass = "border border-ink/20 bg-background px-3 py-2 text-sm";
export default async function AdminSponsorshipsPage(props: PageProps<"/admin/patenschaften">) {
const locale = await getLocale();
const t = getSponsorshipAdminStrings(locale);
const filters = parseListFilters(await props.searchParams);
const where = await buildSponsorshipWhere(filters);
const [total, offers] = await Promise.all([
prisma.sponsorship.count({ where }),
prisma.sponsorshipOffer.findMany({ select: { id: true, name: true }, orderBy: [{ kind: "asc" }, { sortOrder: "asc" }] }),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(filters.page, totalPages);
const sponsorships = await prisma.sponsorship.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
include: {
owner: { select: { email: true, firstName: true, lastName: true } },
offer: { select: { name: true, kind: true, isPhysical: true } },
revisions: { orderBy: { revision: "desc" }, take: 1, select: { personalization: true } },
orderItem: { select: { personalization: true } },
subscription: { select: { personalization: true } },
},
});
const isFiltered = Boolean(filters.query || filters.status || filters.kind || filters.offerId);
return (
<div>
<BackLink href="/admin" label={t.common.backToDashboard} />
<AdminPageHeader title={t.list.title} intro={t.list.intro} />
<SponsorshipTabs active="overview" labels={t.tabs} />
<AdminSearchForm
query={filters.query}
label={t.list.searchLabel}
placeholder={t.list.searchPlaceholder}
submitLabel={t.common.filter}
resetHref="/admin/patenschaften"
resetLabel={t.common.resetFilter}
isFiltered={isFiltered}
>
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{t.list.statusLabel}</span>
<select name="status" defaultValue={filters.status ?? ""} className={selectClass}>
<option value="">{t.list.statusAll}</option>
{LIST_STATUSES.map((status) => (
<option key={status} value={status}>
{status === "ZU_DRUCKEN" ? t.list.toPrint : t.statuses[status]}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{t.list.kindLabel}</span>
<select name="art" defaultValue={filters.kind ?? ""} className={selectClass}>
<option value="">{t.list.kindAll}</option>
<option value="EINMALIG">{t.kinds.EINMALIG}</option>
<option value="ABO">{t.kinds.ABO}</option>
</select>
</label>
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{t.list.offerLabel}</span>
<select name="angebot" defaultValue={filters.offerId ?? ""} className={selectClass}>
<option value="">{t.list.offerAll}</option>
{offers.map((offer) => (
<option key={offer.id} value={offer.id}>
{offer.name}
</option>
))}
</select>
</label>
</AdminSearchForm>
<p className="mb-3 text-xs text-ink/60">{t.list.resultCount(total)}</p>
<div className="overflow-x-auto border border-ink/10 bg-background">
<table className="w-full text-sm">
<thead className="border-b border-ink/10 text-left text-ink/65">
<tr>
<th className="p-4">{t.list.colNumber}</th>
<th className="p-4">{t.list.colBee}</th>
<th className="p-4">{t.list.colOwner}</th>
<th className="p-4">{t.list.colOffer}</th>
<th className="p-4">{t.list.colTerm}</th>
<th className="p-4">{t.list.colStatus}</th>
<th className="p-4">{t.list.colCreated}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{sponsorships.map((sponsorship) => {
const beeName =
beeNameFromJson(sponsorship.revisions[0]?.personalization) ??
beeNameFromJson(sponsorship.orderItem?.personalization ?? sponsorship.subscription?.personalization);
const toPrint = sponsorship.offer.isPhysical && sponsorship.status === "BEREIT" && !sponsorship.printedAt;
const start = formatDate(sponsorship.startDate, locale);
return (
<tr key={sponsorship.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="whitespace-nowrap p-4 font-mono text-xs text-heading">
<Link href={`/admin/patenschaften/${sponsorship.id}`} className="link-draw">
{sponsorship.number}
</Link>
</td>
<td className="p-4 font-serif text-base text-heading">{beeName ?? t.common.none}</td>
<td className="p-4">
{sponsorship.owner.firstName} {sponsorship.owner.lastName}
<br />
<span className="text-xs text-ink/65">{sponsorship.owner.email}</span>
</td>
<td className="p-4">
{sponsorship.offer.name}
<br />
<span className="text-xs text-ink/65">{t.kinds[sponsorship.offer.kind]}</span>
</td>
<td className="whitespace-nowrap p-4 tabular-nums">
{sponsorship.endDate ? t.common.range(start, formatDate(sponsorship.endDate, locale)) : t.common.since(start)}
</td>
<td className="p-4">
<div className="flex flex-col items-start gap-1.5">
<SponsorshipStatusBadge status={sponsorship.status} label={t.statuses[sponsorship.status]} />
{toPrint ? (
<AdminBadge tone="pending" pulse>
{t.list.toPrintBadge}
</AdminBadge>
) : null}
</div>
</td>
<td className="whitespace-nowrap p-4 tabular-nums text-ink/70">{formatDate(sponsorship.createdAt, locale)}</td>
</tr>
);
})}
</tbody>
</table>
{sponsorships.length === 0 ? <AdminEmptyState message={isFiltered ? t.list.emptyFiltered : t.list.empty} /> : null}
</div>
<Pagination page={page} totalPages={totalPages} hrefFor={(target) => listHref(filters, target)} labels={t.common} />
</div>
);
}
@@ -0,0 +1,128 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { AdminActionButton, AdminActionNote, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import type { LoyaltyKind } from "@/generated/prisma/client";
import { getLocale } from "@/i18n/locale";
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
import { createLoyaltyLevelAction, deleteLoyaltyLevelAction, updateLoyaltyLevelAction } from "@/app/admin/actions";
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm";
const saveClass = "border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream";
interface Level {
id: string;
kind: LoyaltyKind;
threshold: number | null;
name: string;
nameEn: string | null;
}
function LevelRow({ level, t, common }: { level: Level; t: SponsorshipAdminStrings["loyalty"]; common: SponsorshipAdminStrings["common"] }) {
const isVip = level.threshold === null;
return (
<div className="flex flex-wrap items-end gap-4 py-3">
<form action={updateLoyaltyLevelAction} className="grid flex-1 grid-cols-1 items-end gap-3 sm:grid-cols-[7rem_1fr_1fr_auto]">
<input type="hidden" name="id" value={level.id} />
{isVip ? (
<div className="text-sm">
<span className="text-xs text-ink/65">{t.threshold}</span>
<p className="mt-1 py-2 text-xs text-honey-dark">{t.vip}</p>
</div>
) : (
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.threshold}</span>
<input type="number" name="threshold" min={1} defaultValue={String(level.threshold)} required className={`${inputClass} tabular-nums`} />
</label>
)}
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.name}</span>
<input name="name" defaultValue={level.name} required className={inputClass} />
</label>
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.nameEn}</span>
<input name="nameEn" defaultValue={level.nameEn ?? ""} className={inputClass} />
</label>
<button type="submit" className={saveClass}>
{common.save}
</button>
</form>
<div className="w-28 pb-2">
{isVip ? (
<AdminActionNote>{t.vipNotDeletable}</AdminActionNote>
) : (
<ConfirmForm action={deleteLoyaltyLevelAction} confirmMessage={t.deleteConfirm(level.name)}>
<input type="hidden" name="id" value={level.id} />
<AdminActionButton tone="danger">{common.delete}</AdminActionButton>
</ConfirmForm>
)}
</div>
</div>
);
}
function LevelSection({ kind, heading, hint, levels, t, common }: {
kind: LoyaltyKind;
heading: string;
hint: string;
levels: Level[];
t: SponsorshipAdminStrings["loyalty"];
common: SponsorshipAdminStrings["common"];
}) {
return (
<section className="border border-ink/10 bg-background p-6">
<h2 className="font-serif text-xl text-heading">{heading}</h2>
<p className="mb-4 mt-1 text-xs text-ink/65">{hint}</p>
{levels.length === 0 ? <AdminEmptyState message={t.empty} /> : null}
<div className="divide-y divide-ink/10">
{levels.map((level) => (
<LevelRow key={level.id} level={level} t={t} common={common} />
))}
</div>
<form action={createLoyaltyLevelAction} className="mt-6 grid grid-cols-1 items-end gap-3 border-t border-ink/10 pt-6 sm:grid-cols-[7rem_1fr_1fr_auto]">
<input type="hidden" name="kind" value={kind} />
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.threshold}</span>
<input type="number" name="threshold" min={1} required className={`${inputClass} tabular-nums`} />
</label>
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.name}</span>
<input name="name" required className={inputClass} />
</label>
<label className="block text-sm">
<span className="text-xs text-ink/65">{t.nameEn}</span>
<input name="nameEn" className={inputClass} />
</label>
<button type="submit" className="border border-heading bg-forest px-4 py-2 text-sm text-cream transition-colors hover:bg-honey-deep">
{t.add}
</button>
</form>
</section>
);
}
/** Schwellen aufsteigend, VIP (ohne Schwelle) immer zuletzt. */
function byThreshold(a: Level, b: Level): number {
return (a.threshold ?? Number.POSITIVE_INFINITY) - (b.threshold ?? Number.POSITIVE_INFINITY);
}
export default async function AdminLoyaltyLevelsPage() {
const t = getSponsorshipAdminStrings(await getLocale());
const tl = t.loyalty;
const levels = await prisma.loyaltyLevel.findMany({ select: { id: true, kind: true, threshold: true, name: true, nameEn: true } });
const regular = levels.filter((level) => level.kind === "STAMMKUNDE").sort(byThreshold);
const subscription = levels.filter((level) => level.kind === "ABO").sort(byThreshold);
return (
<div className="max-w-5xl">
<BackLink href="/admin" label={t.common.backToDashboard} />
<AdminPageHeader title={tl.title} intro={tl.intro} />
<SponsorshipTabs active="loyalty" labels={t.tabs} />
<div className="space-y-8">
<LevelSection kind="STAMMKUNDE" heading={tl.regularHeading} hint={tl.regularHint} levels={regular} t={tl} common={t.common} />
<LevelSection kind="ABO" heading={tl.aboHeading} hint={tl.aboHint} levels={subscription} t={tl} common={t.common} />
</div>
</div>
);
}
+4 -3
View File
@@ -27,7 +27,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
const [products, activeCount, archivedCount] = await Promise.all([
prisma.product.findMany({
where: { archivedAt: isArchive ? { not: null } : null },
// Traegerprodukte der Patenschaft pflegt der Admin unter "Patenschaften".
where: { archivedAt: isArchive ? { not: null } : null, category: { not: "PATENSCHAFT" } },
include: {
harvest: true,
batch: true,
@@ -37,8 +38,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
},
orderBy: { createdAt: "desc" },
}),
prisma.product.count({ where: { archivedAt: null } }),
prisma.product.count({ where: { archivedAt: { not: null } } }),
prisma.product.count({ where: { archivedAt: null, category: { not: "PATENSCHAFT" } } }),
prisma.product.count({ where: { archivedAt: { not: null }, category: { not: "PATENSCHAFT" } } }),
]);
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
+4
View File
@@ -67,6 +67,10 @@ const db = {
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
// Patenschaften spielen in diesen Faellen keine Rolle: keine Angebote zu den
// Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: vi.fn(async () => new Map()) }));
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsForProduct: vi.fn(async () => 0) }));
const { POST } = await import("./route");
+74 -27
View File
@@ -22,6 +22,9 @@ import {
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
class OutOfStockError extends Error {}
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
@@ -221,8 +224,18 @@ export async function POST(request: Request) {
}
}
const productIds = input.items.map((item) => item.productId);
// Eindeutig machen: zwei Patenschaften desselben Pakets sind zwei
// Positionen mit derselben productId (je eigene Personalisierung). Der
// Laengenvergleich unten wuerde sonst ein vorhandenes Produkt als fehlend
// werten.
const productIds = [...new Set(input.items.map((item) => item.productId))];
const locale = await getLocale();
const offersByProduct = await getOffersByProductIds(productIds, locale);
const honeyIds = [...offersByProduct.values()]
.map((offer) => offer.honeyProduct?.id)
.filter((id): id is string => Boolean(id) && !productIds.includes(id as string));
const products = await getProductsByIds(productIds);
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
if (products.length !== productIds.length) {
return NextResponse.json(
@@ -265,31 +278,64 @@ export async function POST(request: Request) {
);
}
const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!;
return {
productId: product.id,
batchId: product.batchId,
productName: product.name,
unitPriceCents: product.priceCents,
quantity: cartItem.quantity,
weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity,
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
vatRatePercent: vatRateForProduct(product, process.env),
};
// Positionen bilden und Patenschaftspakete in Patenschaft + Honig
// aufloesen (lib/sponsorship/checkout-expand.ts). Der Steuersatz ist Teil
// des Snapshots wie der Preis: er haengt an der Art der Ware (Honig
// ermaessigt, Kerzen und Patenschaft voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
const productMap = new Map<string, CheckoutProduct>(
[...products, ...honeyProducts].map((product) => [product.id, product])
);
const expanded = expandCheckoutItems({
cartItems: input.items,
products: productMap,
offers: offersByProduct,
vatRateFor: (product) => vatRateForProduct(product, process.env),
locale,
now: new Date(),
});
if (!expanded.ok) {
return NextResponse.json({ error: expanded.error }, { status: 400 });
}
const orderItemsData = expanded.items;
// Ohne physische Ware gibt es nichts zu versenden oder abzuholen. Der
// Server entscheidet das selbst aus dem Warenkorb; ein Browser, der
// "DIGITAL" fuer einen Warenkorb mit Honig schickt, bekommt eine
// Fehlermeldung statt eines Versands ohne Versandkosten.
if (input.fulfillmentMethod === "DIGITAL" && expanded.hasPhysical) {
return NextResponse.json({ error: "Bitte wähle Versand oder Abholung." }, { status: 400 });
}
const fulfillmentMethod = expanded.hasPhysical ? input.fulfillmentMethod : "DIGITAL";
// Patenschaften gibt es nur mit Konto: Urkunde, Medaille und Downloads
// liegen im Kundenkonto, und nur ein Konto kann sie spaeter wieder abrufen.
const customer = await getCurrentCustomer();
if (expanded.hasSponsorship && !customer) {
return NextResponse.json(
{ error: "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an." },
{ status: 401 }
);
}
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
// einer Menge zusammengefasst, nicht separat berechnet) — kein
// wertbasierter Freibetrag mehr.
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
// der Gesamtmenge der PHYSISCHEN Ware ueber alle Positionen hinweg
// (mehrere Artikel werden zu einer Menge zusammengefasst) — eine Urkunde
// ohne Paket wiegt nichts und loest keinen Versand aus.
const totalQuantity = expanded.physicalQuantity;
const shippingCents =
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
fulfillmentMethod === "VERSAND" ? await getShippingCostCents(totalQuantity) : 0;
// Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket
// verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
// bleibt das bedingte Update in der Transaktion.
const reservedByProduct = new Map<string, number>();
for (const line of expanded.reserve) {
reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId));
}
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
// haelt die Rabattlogik einfach nachvollziehbar.
@@ -349,7 +395,6 @@ export async function POST(request: Request) {
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
const totalCents = subtotalCents - discountCents + shippingCents;
const customer = await getCurrentCustomer();
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
// nur fuer registrierte, eingeloggte Kund:innen.
@@ -363,8 +408,6 @@ export async function POST(request: Request) {
);
}
const locale = await getLocale();
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
@@ -385,9 +428,13 @@ export async function POST(request: Request) {
const runOrderTransaction = () =>
prisma.$transaction(
async (tx) => {
for (const item of orderItemsData) {
// Nur physische Ware reservieren (Patenschaft selbst: kein Bestand).
// Die fuer Abos reservierten Glaeser muessen nach der Buchung
// weiterhin da sein — daher "Bestand >= bestellt + Abo-Reservierung".
for (const item of expanded.reserve) {
const aboReserved = reservedByProduct.get(item.productId) ?? 0;
const reserved = await tx.inventory.updateMany({
where: { productId: item.productId, quantity: { gte: item.quantity } },
where: { productId: item.productId, quantity: { gte: item.quantity + aboReserved } },
data: { quantity: { decrement: item.quantity } },
});
if (reserved.count === 0) {
@@ -421,7 +468,7 @@ export async function POST(request: Request) {
cancelToken,
customerId: customer?.id,
locale,
fulfillmentMethod: input.fulfillmentMethod,
fulfillmentMethod,
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
@@ -88,6 +88,12 @@ const db = {
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
// Patenschaften haben eigene Tests (lib/sponsorship); hier nur sicherstellen,
// dass der Webhook sie anstoesst, ohne die Bestellung anzufassen.
vi.mock("@/lib/sponsorship/issue", () => ({
createSponsorshipsForOrder: vi.fn(async () => 0),
processPendingSponsorships: vi.fn(async () => ({ ready: 0, retry: 0, failed: 0, missing: 0 })),
}));
// Bestandsabgleich mit ApiCore: hier nur, DASS die bezahlte Bestellung als
// Verkauf in die Warteschlange geht; die Buchungslogik selbst testet
@@ -101,6 +107,13 @@ vi.mock("@/lib/apicore-stock/schedule", () => ({
scheduleApiCoreStockSync: (...args: unknown[]) => scheduleApiCoreStockSync(...(args as [])),
}));
// Abo-Ereignisse haben eigene Tests (lib/sponsorship-subscription); hier
// zaehlt nur, dass der Webhook jedes Ereignis dorthin weiterreicht.
const handleSubscriptionEvent = vi.fn(async () => undefined);
vi.mock("@/lib/sponsorship-subscription/webhook-handlers", () => ({
handleSubscriptionEvent: (...args: unknown[]) => handleSubscriptionEvent(...(args as [])),
}));
const { POST } = await import("./route");
const ORDER_ID = "order_1";
@@ -186,6 +199,17 @@ describe("Stripe-Webhook", () => {
expect(sendMail).not.toHaveBeenCalled();
});
test("reicht jedes gepruefte Ereignis an die Abo-Verarbeitung weiter", async () => {
const event = { type: "invoice.paid", data: { object: { id: "in_test_1" } } };
constructEvent.mockReturnValue(event);
const response = await post();
expect(response.status).toBe(200);
expect(handleSubscriptionEvent).toHaveBeenCalledWith(event);
expect(db.order.updateMany).not.toHaveBeenCalled();
});
test("antwortet mit 500, solange STRIPE_WEBHOOK_SECRET fehlt", async () => {
delete process.env.STRIPE_WEBHOOK_SECRET;
+27
View File
@@ -15,6 +15,8 @@ import { sendInvoiceCopy } from "@/lib/invoice-mail";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { createSponsorshipsForOrder, processPendingSponsorships } from "@/lib/sponsorship/issue";
import { handleSubscriptionEvent } from "@/lib/sponsorship-subscription/webhook-handlers";
/**
* Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und
@@ -146,6 +148,16 @@ export async function POST(request: Request) {
await recordDispute(event.data.object as Stripe.Dispute);
}
// Bienenpatenschaft im Abo (lib/sponsorship-subscription/webhook-handlers.ts):
// Abschluss, Abo-Aenderungen und -Ende, bezahlte und fehlgeschlagene
// Monatsrechnungen, Rueckbuchungen von Abo-Zahlungen. Laeuft nach den
// Zweigen oben und ignoriert alles, was kein Abo betrifft — fuer
// Einmalbestellungen aendert sich nichts. Zusaetzlich abonniert werden
// muessen dafuer customer.subscription.created/.updated/.deleted,
// invoice.paid, invoice.payment_failed und charge.dispute.closed
// (scripts/stripe-setup.ts).
await handleSubscriptionEvent(event);
return NextResponse.json({ received: true });
}
@@ -339,6 +351,11 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
// Webhook legt also nichts doppelt an.
await recordOrderSale(tx, orderId);
// Patenschaften dieser Bestellung anlegen — im selben Lauf wie der
// Statuswechsel. Idempotent ueber den eindeutigen orderItemId: ein
// erneut zugestellter Webhook legt nichts doppelt an.
await createSponsorshipsForOrder(tx, orderId);
return updated;
});
@@ -348,6 +365,16 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
scheduleApiCoreStockSync(order.items.map((item) => item.productId));
// Urkunden sofort erzeugen und zustellen, statt auf den naechsten
// CronJob-Lauf zu warten. Wirft nie: Fehler plant processSponsorship selbst
// fuer einen spaeteren Versuch ein (scripts/sponsorship-jobs.ts) — die
// Zahlung ist erfolgt, ein 500er an Stripe wuerde daran nichts aendern.
if (order.items.some((item) => item.sponsorshipRole === "MAIN")) {
await processPendingSponsorships({ orderId: order.id }).catch((error: unknown) => {
console.error(`[patenschaft] Ausstellung fuer Bestellung ${order.orderNumber} vorerst fehlgeschlagen:`, error);
});
}
// Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
+90
View File
@@ -0,0 +1,90 @@
/** Abo-Abschluss: nur mit verifiziertem Konto, nur fuer verfuegbare Abo-Angebote, mit gepruefter Personalisierung. */
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
getCurrentCustomer: vi.fn(),
getOfferBySlug: vi.fn(),
createSubscriptionCheckout: vi.fn(),
isTrustedOrigin: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer }));
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: m.isTrustedOrigin }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: () => true, getClientIp: async () => "127.0.0.1" }));
vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" }));
vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug }));
vi.mock("@/lib/sponsorship-subscription/checkout", () => ({ createSubscriptionCheckout: m.createSubscriptionCheckout }));
vi.mock("@/lib/certificate/layout", () => ({ checkCertificateFits: () => ({ ok: true }) }));
const { POST } = await import("./route");
const CUSTOMER = { id: "cust_1", email: "[email protected]", emailVerifiedAt: new Date(), stripeCustomerId: null, sepaBlockedAt: null };
const PERSONALIZATION = { beeName: "Summsi", recipientName: "Erika Mustermann", occasion: "SELBST", startDate: "2030-01-01" };
function post(body: unknown): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/patenschaft/abo", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://wabenhain.test" },
body: JSON.stringify(body),
})
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
m.isTrustedOrigin.mockReturnValue(true);
m.getCurrentCustomer.mockResolvedValue(CUSTOMER);
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true });
m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" });
});
describe("POST /api/patenschaft/abo", () => {
test("liefert das client_secret und ersetzt das Startdatum durch heute", async () => {
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ clientSecret: "cs_secret" });
const input = m.createSubscriptionCheckout.mock.calls[0][0];
expect(input.personalization.startDate).toBe(new Date().toISOString().slice(0, 10));
expect(input.offerId).toBe("offer_1");
});
test("weist fremde Herkunft ab", async () => {
m.isTrustedOrigin.mockReturnValue(false);
expect((await post({ offerSlug: "x", personalization: PERSONALIZATION })).status).toBe(403);
});
test("ohne Konto 401, ohne bestaetigte Adresse 403", async () => {
m.getCurrentCustomer.mockResolvedValueOnce(null);
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(401);
m.getCurrentCustomer.mockResolvedValueOnce({ ...CUSTOMER, emailVerifiedAt: null });
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(403);
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
});
test("ungueltige Personalisierung wird abgelehnt", async () => {
const response = await post({ offerSlug: "vierteljaehrlich", personalization: { ...PERSONALIZATION, beeName: "" } });
expect(response.status).toBe(400);
expect((await response.json()).field).toBe("beeName");
});
test("Einmal-Angebote laufen nicht ueber den Abo-Weg", async () => {
m.getOfferBySlug.mockResolvedValue({ id: "offer_2", slug: "bienenfreund", kind: "EINMALIG", isAvailable: true });
expect((await post({ offerSlug: "bienenfreund", personalization: PERSONALIZATION })).status).toBe(404);
});
test("ausgebuchtes Abo (Kontingent oder Honig) nimmt keine Abschluesse an", async () => {
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: false });
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(409);
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
});
test("ein Stripe-Fehler wird ohne Details gemeldet", async () => {
m.createSubscriptionCheckout.mockRejectedValue(new Error("Invalid API Key provided: sk_test_***"));
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(response.status).toBe(502);
expect(JSON.stringify(await response.json())).not.toContain("sk_test");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { getLocale } from "@/i18n/locale";
import { locales, type Locale } from "@/i18n/config";
import { personalizationSchema, toIsoDate } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { getOfferBySlug } from "@/lib/sponsorship/offers";
import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout";
/**
* Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und
* Angebot, legt das Abo an und oeffnet eine eingebettete Stripe-Sitzung im
* Modus "subscription". Antwort: { clientSecret } fuer das Zahlungsformular.
*
* Abo und Einmalkauf werden getrennt bezahlt (Spec "Abo-Patenschaften") —
* dieser Weg beruehrt den Warenkorb nicht.
*/
const ATTEMPTS_PER_WINDOW = 10;
const WINDOW_MS = 60 * 60 * 1000;
const bodySchema = z.object({
offerSlug: z.string().trim().min(1).max(100),
personalization: z.record(z.string(), z.unknown()),
locale: z.enum(locales).optional(),
});
function error(message: string, status: number, extra: Record<string, unknown> = {}): NextResponse {
return NextResponse.json({ error: message, ...extra }, { status, headers: { "Cache-Control": "no-store" } });
}
export async function POST(request: Request) {
// Route-Handler haben keinen eingebauten CSRF-Schutz (lib/csrf.ts): ohne
// diese Pruefung koennte eine fremde Seite im Namen eines angemeldeten
// Kontos Abos anstossen.
if (!isTrustedOrigin(request)) return error("Ungültige Anfrage.", 403);
const ip = await getClientIp();
if (!rateLimit(`abo-checkout:${ip}`, ATTEMPTS_PER_WINDOW, WINDOW_MS)) {
return error("Zu viele Versuche. Bitte versuche es später erneut.", 429);
}
const customer = await getCurrentCustomer();
if (!customer) {
return error("Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an.", 401);
}
if (!customer.emailVerifiedAt) {
return error(
"Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.",
403
);
}
const parsedBody = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsedBody.success) return error("Ungültige Eingabe.", 400);
const locale: Locale = parsedBody.data.locale ?? (await getLocale());
// Ein Abo beginnt mit der ersten Zahlung (Spec "Personalisierung"): das
// Startdatum aus dem Formular zaehlt nicht, es wird bei der ersten
// bezahlten Rechnung durch deren Tag ersetzt.
const today = toIsoDate(new Date());
const personalization = personalizationSchema.safeParse({ ...parsedBody.data.personalization, startDate: today });
if (!personalization.success) {
const issue = personalization.error.issues[0];
return error("Die Personalisierung ist ungültig.", 400, {
field: typeof issue?.path[0] === "string" ? issue.path[0] : null,
code: issue?.message ?? "invalid",
});
}
const fit = checkCertificateFits(personalization.data, locale, { term: { kind: "OPEN", start: today } });
if (!fit.ok) {
return error(fit.message, 400, { field: fit.field, code: fit.reason });
}
const offer = await getOfferBySlug(parsedBody.data.offerSlug, locale);
if (!offer || offer.kind !== "ABO") return error("Dieses Abo gibt es nicht.", 404);
// Kontingent voll oder Honig reicht nicht: keine neuen Abschluesse (Spec
// "Honigbestand und Kontingent").
if (!offer.isAvailable) return error("Dieses Abo ist derzeit ausgebucht.", 409);
try {
const { clientSecret } = await createSubscriptionCheckout({
customer,
offerId: offer.id,
personalization: personalization.data,
locale,
});
return NextResponse.json({ clientSecret }, { headers: { "Cache-Control": "no-store" } });
} catch (cause: unknown) {
// Stripe-Fehlertexte nur ins Log: sie koennen Konfigurationsdetails enthalten.
const message = cause instanceof Error ? cause.message : "Unbekannter Fehler";
console.error(`[abo] Abschluss fuer Konto ${customer.id} (${offer.slug}) fehlgeschlagen:`, message);
return error(
"Die Zahlung konnte nicht gestartet werden – es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut.",
502
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import { validatePreviewRequest, type PreviewFailure } from "./validate";
import {
PREVIEW_NUMBER,
previewWatermark,
renderCertificateDocumentImage,
} from "@/lib/sponsorship/certificate-document";
/**
* Vorschau der Urkunde vor dem Kauf (Spec "Vorschau"): dieselbe Pruefung und
* dasselbe Layout wie Checkout und Ausstellung, nur mit Wasserzeichen,
* Platzhalternummer und in Bildschirmaufloesung. Keine Anmeldung noetig —
* ausprobieren soll jeder koennen; dafuer ein Rate-Limit je Besucher, weil
* jedes Bild Rechenzeit kostet.
*
* Fehler kommen als { error, field } zurueck: error ist ein fester Code
* (siehe CertificateErrorCode bzw. "glyphs"/"fit"/"date_range"), den das
* Formular in der Seitensprache anzeigt, field das betroffene Eingabefeld.
*/
const PREVIEW_ATTEMPTS_PER_WINDOW = 60;
const PREVIEW_WINDOW_MS = 10 * 60 * 1000;
const PREVIEW_WIDTH_PX = 900;
// Grosszuegig ueber dem, was die Feldlimits je erreichen koennen; alles
// darueber ist kein Formular mehr, sondern ein Versuch, den Parser zu beschaeftigen.
const MAX_BODY_BYTES = 16 * 1024;
function failure({ status, ...payload }: PreviewFailure): NextResponse {
return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store" } });
}
export async function POST(request: Request): Promise<Response> {
// Route-Handler haben keinen eingebauten CSRF-Schutz (siehe lib/csrf.ts).
// Hier geht es weniger um Daten als um Rechenzeit: fremde Seiten sollen
// unsere Bildausgabe nicht als kostenlosen Dienst einbinden.
if (!isTrustedOrigin(request)) {
return failure({ status: 403, error: "generic", field: null });
}
const ip = await getClientIp();
if (!rateLimit(`preview:${ip}`, PREVIEW_ATTEMPTS_PER_WINDOW, PREVIEW_WINDOW_MS)) {
return failure({ status: 429, error: "rate_limited", field: null });
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (declaredLength > MAX_BODY_BYTES) {
return failure({ status: 413, error: "generic", field: null });
}
const raw = await request.text().catch(() => "");
if (raw.length > MAX_BODY_BYTES) {
return failure({ status: 413, error: "generic", field: null });
}
let body: unknown = null;
try {
body = JSON.parse(raw);
} catch {
return failure({ status: 400, error: "generic", field: null });
}
const input = validatePreviewRequest(body, new Date(), await getLocale());
if (!input.ok) return failure(input.failure);
try {
const image = await renderCertificateDocumentImage(
{
personalization: input.personalization,
locale: input.locale,
term: input.term,
number: PREVIEW_NUMBER,
templateVersion: CURRENT_TEMPLATE_VERSION,
},
{ widthPx: PREVIEW_WIDTH_PX, watermark: previewWatermark(input.locale), format: "webp" }
);
return new Response(new Uint8Array(image), {
status: 200,
headers: {
"Content-Type": "image/webp",
// Personalisierte Bilder gehoeren in keinen Zwischenspeicher.
"Cache-Control": "no-store",
},
});
} catch (error: unknown) {
console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error);
return failure({ status: 500, error: "generic", field: null });
}
}
@@ -0,0 +1,69 @@
import { describe, test, expect } from "vitest";
import { validatePreviewRequest } from "./validate";
const NOW = new Date("2026-10-16T10:00:00Z");
const valid = {
beeName: "Frieda",
recipientName: "Marie Hoffmann",
giverNames: "Anna und Ben",
dedication: "Für die Frühaufsteherin unter uns.",
occasion: "GEBURTSTAG",
startDate: "2026-11-01",
};
describe("validatePreviewRequest", () => {
test("accepts a valid personalization and uses a fixed twelve-month term", () => {
const result = validatePreviewRequest({ personalization: valid, locale: "de" }, NOW, "de");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.term).toEqual({ kind: "FIXED", start: "2026-11-01", end: "2027-10-31" });
expect(result.locale).toBe("de");
});
test("falls back to the page locale", () => {
const result = validatePreviewRequest({ personalization: valid }, NOW, "en");
expect(result.ok && result.locale).toBe("en");
});
test("names the field of a schema error", () => {
const result = validatePreviewRequest({ personalization: { ...valid, beeName: "A" } }, NOW, "de");
expect(result).toEqual({ ok: false, failure: { status: 400, error: "too_short", field: "beeName" } });
});
test("rejects characters the certificate fonts cannot print", () => {
const result = validatePreviewRequest({ personalization: { ...valid, beeName: "Summsi 🐝" } }, NOW, "de");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.failure).toMatchObject({ error: "glyphs", field: "beeName" });
expect(result.failure.characters).toContain("🐝");
});
test("rejects a start date outside the order window", () => {
const result = validatePreviewRequest({ personalization: { ...valid, startDate: "2027-06-01" } }, NOW, "de");
expect(result).toEqual({ ok: false, failure: { status: 400, error: "date_range", field: "startDate" } });
});
test("an open subscription term skips the start window", () => {
const result = validatePreviewRequest(
{ personalization: { ...valid, startDate: "2026-10-16" }, term: "OPEN" },
NOW,
"de"
);
expect(result.ok && result.term).toEqual({ kind: "OPEN", start: "2026-10-16" });
});
test("rejects a malformed body", () => {
expect(validatePreviewRequest(null, NOW, "de").ok).toBe(false);
expect(validatePreviewRequest({ personalization: valid, locale: "fr" }, NOW, "de").ok).toBe(false);
});
test("treats markup as plain text instead of rejecting or interpreting it", () => {
const result = validatePreviewRequest(
{ personalization: { ...valid, dedication: "<script>alert(1)</script>" } },
NOW,
"de"
);
expect(result.ok && result.personalization.dedication).toBe("<script>alert(1)</script>");
});
});
@@ -0,0 +1,57 @@
import { z } from "zod";
import { locales, type Locale } from "@/i18n/config";
import { personalizationSchema, fixedTermFor, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { startDateWindow } from "@/lib/sponsorship/checkout-expand";
import { PREVIEW_NUMBER } from "@/lib/sponsorship/certificate-document";
/**
* Pruefung einer Vorschau-Anfrage, getrennt vom Rendern: so laesst sie sich
* ohne sharp testen, und route.ts exportiert nur, was Next.js dort erlaubt.
*/
const requestSchema = z.object({
personalization: z.unknown(),
locale: z.enum(locales).optional(),
// Abo-Urkunden tragen "Patenschaft seit …" statt eines Zeitraums. Vorgabe
// ist die Einmal-Patenschaft mit zwoelf Monaten ab dem Startdatum.
term: z.enum(["FIXED", "OPEN"]).optional(),
});
export type PreviewFailure = { status: number; error: string; field: string | null; characters?: string[] };
export type PreviewInput =
| { ok: true; personalization: Personalization; locale: Locale; term: CertificateTerm }
| { ok: false; failure: PreviewFailure };
/** Prueft eine Vorschau-Anfrage vollstaendig, ohne zu rendern (testbar ohne sharp). */
export function validatePreviewRequest(body: unknown, now: Date, fallbackLocale: Locale): PreviewInput {
const request = requestSchema.safeParse(body);
if (!request.success) return { ok: false, failure: { status: 400, error: "generic", field: null } };
const locale = request.data.locale ?? fallbackLocale;
const parsed = personalizationSchema.safeParse(request.data.personalization);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const field = typeof issue?.path[0] === "string" ? issue.path[0] : null;
return { ok: false, failure: { status: 400, error: issue?.message ?? "generic", field } };
}
const personalization: Personalization = parsed.data;
const isOpen = request.data.term === "OPEN";
const window = startDateWindow(now);
if (!isOpen && (personalization.startDate < window.min || personalization.startDate > window.max)) {
return { ok: false, failure: { status: 400, error: "date_range", field: "startDate" } };
}
const term: CertificateTerm = isOpen
? { kind: "OPEN", start: personalization.startDate }
: fixedTermFor(personalization.startDate);
const fit = checkCertificateFits(personalization, locale, { term, number: PREVIEW_NUMBER });
if (!fit.ok) {
return fit.reason === "glyphs"
? { ok: false, failure: { status: 400, error: "glyphs", field: fit.field, characters: fit.characters } }
: { ok: false, failure: { status: 400, error: "fit", field: fit.field } };
}
return { ok: true, personalization, locale, term };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
const getProductsByIds = vi.fn();
vi.mock("@/lib/data", () => ({ getProductsByIds: (ids: string[]) => getProductsByIds(ids) }));
const getOffersByProductIds = vi.fn();
vi.mock("@/lib/sponsorship/offers", () => ({
getOffersByProductIds: (ids: string[], locale: string) => getOffersByProductIds(ids, locale),
}));
vi.mock("@/lib/rate-limit", () => ({
rateLimit: () => true,
getClientIp: async () => "127.0.0.1",
}));
const { GET } = await import("./route");
function get(ids: string): Promise<Response> {
return GET(new Request(`https://wabenhain.test/api/shop/cart-stock?ids=${ids}`));
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/shop/cart-stock", () => {
test("reports honey stock and never marks an available sponsorship as sold out", async () => {
getProductsByIds.mockResolvedValue([
{ id: "honey", category: "HONIG", isActive: true, inventory: { quantity: 3 } },
{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null },
]);
getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: true }]]));
const body = (await (await get("honey,pkg")).json()) as { stock: Record<string, number> };
expect(body.stock.honey).toBe(3);
expect(body.stock.pkg).toBeGreaterThan(5);
expect(getOffersByProductIds).toHaveBeenCalledWith(["pkg"], "de");
});
test("marks a sponsorship as sold out when its offer is not available", async () => {
getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]);
getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]]));
const body = (await (await get("pkg")).json()) as { stock: Record<string, number> };
expect(body.stock.pkg).toBe(0);
});
});
+19
View File
@@ -1,10 +1,18 @@
import { NextResponse } from "next/server";
import { getProductsByIds } from "@/lib/data";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
import { defaultLocale } from "@/i18n/config";
const MAX_IDS_PER_REQUEST = 50;
const STOCK_ATTEMPTS_PER_WINDOW = 60;
const STOCK_WINDOW_MS = 60 * 1000;
/**
* Bestand, den eine bestellbare Patenschaft meldet: eine Urkunde ist nie
* ausverkauft, und der Wert liegt bewusst ueber der "Nur noch N"-Schwelle
* der Warenkorbanzeige.
*/
const SPONSORSHIP_AVAILABLE_STOCK = 99;
/**
* Aktueller Lagerbestand fuer eine Liste von Produkt-IDs — der Warenkorb
@@ -34,8 +42,19 @@ export async function GET(request: Request) {
}
const products = await getProductsByIds(ids);
// Patenschaften haben am Traegerprodukt keinen eigenen Bestand. Ob sie
// bestellbar sind, entscheidet das Angebot (bei Paketen der freie
// Honigbestand abzueglich Abo-Reservierung, siehe lib/sponsorship/offers.ts) —
// ohne diese Weiche stuende jede Patenschaft im Warenkorb als "Ausverkauft".
const sponsorshipIds = products.filter((product) => product.category === "PATENSCHAFT").map((product) => product.id);
const offers = await getOffersByProductIds(sponsorshipIds, defaultLocale);
const stock: Record<string, number> = {};
for (const product of products) {
if (product.category === "PATENSCHAFT") {
const offer = offers.get(product.id);
stock[product.id] = product.isActive && offer?.isAvailable ? SPONSORSHIP_AVAILABLE_STOCK : 0;
continue;
}
stock[product.id] = product.isActive ? (product.inventory?.quantity ?? 0) : 0;
}
+73
View File
@@ -39,6 +39,7 @@ const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCen
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
key: "p1",
productId: "p1",
slug: "fruehtracht-2026",
name: "Frühtracht 2026",
@@ -62,6 +63,24 @@ function renderCheckout(items: CartItem[], stripePublishableKey: string | undefi
);
}
const sponsorshipItem = (key: string, beeName: string, honeyJars = 0): CartItem => ({
key,
productId: "pkg-bienenfreund",
slug: "bienenfreund",
name: "Bienenfreund",
weightGrams: 0,
priceCents: 1900,
image: null,
quantity: 1,
sponsorship: {
offerSlug: "bienenfreund",
offerName: "Bienenfreund",
isPhysical: honeyJars > 0,
honeyJars,
personalization: { beeName, recipientName: "Erika Muster", occasion: "GEBURTSTAG", startDate: "2026-11-01" },
},
});
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
@@ -179,4 +198,58 @@ describe("CheckoutForm", () => {
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
test("a cart with only digital sponsorships needs no delivery and is sent as DIGITAL with personalization", async () => {
const userEventInstance = userEvent.setup();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
} as Response);
renderCheckout([sponsorshipItem("sp_1", "Frieda"), sponsorshipItem("sp_2", "Frieda")]);
expect(screen.queryByRole("button", { name: /abholung/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText(/straße|strasse/i)).not.toBeInTheDocument();
expect(screen.getAllByText("Biene „Frieda“")).toHaveLength(2);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
await screen.findByTestId("embedded-payment");
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as {
fulfillmentMethod: string;
street?: string;
items: { productId: string; quantity: number; personalization?: { beeName: string } }[];
};
expect(body.fulfillmentMethod).toBe("DIGITAL");
expect(body.street).toBeUndefined();
// Zwei gleiche Pakete bleiben zwei Positionen, jede mit eigener Personalisierung.
expect(body.items).toHaveLength(2);
expect(body.items.every((line) => line.quantity === 1 && line.personalization?.beeName === "Frieda")).toBe(true);
});
test("a package with honey keeps the delivery choice and counts its jars for shipping", () => {
renderCheckout([sponsorshipItem("sp_1", "Lotte", 1)]);
expect(screen.getByRole("button", { name: /abholung/i })).toBeInTheDocument();
expect(screen.getByText("7,00 €")).toBeInTheDocument();
});
test("shows the account message with a login link when the server answers 401", async () => {
const userEventInstance = userEvent.setup();
const message = "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an.";
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: message }) } as Response);
renderCheckout([sponsorshipItem("sp_1", "Frieda")]);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
expect(await screen.findByText(message, { exact: false })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zur Anmeldung" })).toHaveAttribute("href", "/konto?next=%2Fcheckout");
});
});
+66 -16
View File
@@ -4,7 +4,11 @@ import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from
import dynamic from "next/dynamic";
import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import Link from "next/link";
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly } from "@/store/cart-store";
import { SponsorshipLineDetails } from "@/components/sponsorship-cart-line";
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import { loginPathWithNext } from "@/lib/safe-next-path";
import CheckoutLoading from "./loading";
import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
@@ -116,10 +120,17 @@ export function CheckoutForm({
stripePublishableKey?: string;
}) {
const { t, locale } = useI18n();
const sponsorshipStrings = getSponsorshipShopStrings(locale);
const items = useCartStore((s) => s.items);
const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
// Versandstaffel und Mengenrabatt zaehlen nur Glaeser — eine Urkunde ohne
// Paket wiegt nichts (wie physicalQuantity im Checkout-Server).
const totalQuantity = cartPhysicalQuantity(items);
// Nur Patenschaften ohne Honig: nichts zu versenden oder abzuholen. Dann
// entfaellt die Lieferart-Wahl samt Anschrift, und die Bestellung geht als
// DIGITAL an den Server (der das aus dem Warenkorb selbst noch einmal prueft).
const digitalOnly = cartIsDigitalOnly(items);
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes.
@@ -130,6 +141,9 @@ export function CheckoutForm({
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 401 vom Server: eine Patenschaft im Warenkorb braucht ein Konto. Dann
// steht neben der Meldung ein Weg zur Anmeldung, der hierher zurueckfuehrt.
const [loginRequired, setLoginRequired] = useState(false);
const [isGift, setIsGift] = useState(false);
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
@@ -146,7 +160,8 @@ export function CheckoutForm({
const [packstationError, setPackstationError] = useState<string | null>(null);
const [selectedPackstation, setSelectedPackstation] = useState<Packstation | null>(null);
const usesPackstation = fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION";
const usesPackstation = !digitalOnly && fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION";
const showsAddress = !digitalOnly && fulfillment === "VERSAND" && !usesPackstation;
async function searchPackstations() {
setPackstationSearching(true);
@@ -184,7 +199,7 @@ export function CheckoutForm({
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
const shipping =
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
const bulkDiscountCents = activeBulkDiscount
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
@@ -208,6 +223,7 @@ export function CheckoutForm({
setSubmitting(true);
setError(null);
setLoginRequired(false);
const formData = new FormData(event.currentTarget);
const payload = {
@@ -215,7 +231,7 @@ export function CheckoutForm({
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
phone: String(formData.get("phone") ?? "") || undefined,
fulfillmentMethod: fulfillment,
fulfillmentMethod: digitalOnly ? "DIGITAL" : fulfillment,
// Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular,
// sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den
// lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und
@@ -225,19 +241,31 @@ export function CheckoutForm({
? selectedPackstation
? `Packstation ${selectedPackstation.number}`
: undefined
: String(formData.get("street") ?? "") || undefined,
: showsAddress
? String(formData.get("street") ?? "") || undefined
: undefined,
zip: usesPackstation
? selectedPackstation?.zip
: String(formData.get("zip") ?? "") || undefined,
: showsAddress
? String(formData.get("zip") ?? "") || undefined
: undefined,
city: usesPackstation
? selectedPackstation?.city
: String(formData.get("city") ?? "") || undefined,
: showsAddress
? String(formData.get("city") ?? "") || undefined
: undefined,
country,
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
postNumber: usesPackstation
? String(formData.get("postNumber") ?? "").trim() || undefined
: undefined,
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
// Patenschaften tragen ihre Personalisierung mit; zwei gleiche Pakete
// bleiben zwei Positionen mit derselben productId.
items: items.map((item) =>
item.sponsorship
? { productId: item.productId, quantity: 1, personalization: item.sponsorship.personalization }
: { productId: item.productId, quantity: item.quantity }
),
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
turnstileToken: turnstileToken ?? undefined,
@@ -258,6 +286,7 @@ export function CheckoutForm({
// aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
// das niemand einloesen konnte (siehe api/checkout/route.ts).
setError(data.error ?? t.checkoutPage.genericError);
setLoginRequired(response.status === 401);
setSubmitting(false);
return;
}
@@ -365,6 +394,15 @@ export function CheckoutForm({
/>
</div>
{digitalOnly ? (
<p className="flex items-start gap-3 border border-ink/10 bg-surface px-5 py-4 text-sm leading-relaxed text-ink/75">
<svg viewBox="0 0 24 24" className="mt-0.5 h-5 w-5 flex-shrink-0 text-honey-dark" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="5" y="3" width="14" height="18" rx="1" />
<path d="M9 8h6M9 12h6M9 16h3" strokeLinecap="round" />
</svg>
{sponsorshipStrings.checkout.digitalNote}
</p>
) : (
<div>
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
<div className="grid gap-4 sm:grid-cols-2">
@@ -420,8 +458,9 @@ export function CheckoutForm({
</p>
) : null}
</div>
)}
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
{!digitalOnly && fulfillment === "VERSAND" && packstationSearchAvailable ? (
<div className="flex gap-3">
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
<button
@@ -443,7 +482,7 @@ export function CheckoutForm({
</div>
) : null}
{fulfillment === "VERSAND" && !usesPackstation ? (
{showsAddress ? (
<div className="space-y-4">
<Field
name="street"
@@ -627,6 +666,14 @@ export function CheckoutForm({
{error ? (
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
{error}
{loginRequired ? (
<>
{" "}
<Link href={loginPathWithNext("/checkout")} className="font-medium text-heading underline underline-offset-4">
{sponsorshipStrings.checkout.loginLink}
</Link>
</>
) : null}
</p>
) : null}
@@ -647,10 +694,11 @@ export function CheckoutForm({
<h2 className="text-2xl text-heading mb-6">{t.checkoutPage.yourOrderHeading}</h2>
<ul className="space-y-3 text-sm">
{items.map((item) => (
<li key={item.productId} className="flex items-baseline justify-between gap-4">
<span>
<li key={item.key} className="flex items-baseline justify-between gap-4">
<span className="min-w-0">
<span className="text-ink/55 tabular-nums">{item.quantity} ×</span>{" "}
<span className="font-serif text-lg text-heading">{item.name}</span>
{item.sponsorship ? <SponsorshipLineDetails sponsorship={item.sponsorship} /> : null}
</span>
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
</li>
@@ -663,9 +711,11 @@ export function CheckoutForm({
</div>
<div className="flex justify-between text-ink/70">
<span>
{fulfillment === "VERSAND"
? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup}
{digitalOnly
? sponsorshipStrings.checkout.digitalShipping
: fulfillment === "VERSAND"
? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup}
</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div>
+4 -1
View File
@@ -395,7 +395,10 @@ export default async function ProductDetailPage(
)}
<ul className="mt-8 grid gap-x-6 gap-y-2.5 text-sm text-ink/75 sm:grid-cols-2">
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
{(
t.productPage.checklist[product.category as keyof typeof t.productPage.checklist] ??
t.productPage.checklist.HONIG
).map((item) => (
<li key={item} className="flex items-start gap-2.5">
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
{item}
+13 -8
View File
@@ -11,6 +11,7 @@ import type { Customer } from "@/generated/prisma/client";
import { fail } from "./_shared";
import { isTurnstileValid } from "@/lib/turnstile";
import { succeed } from "@/lib/flash";
import { safeNextPath, loginPathWithNext } from "@/lib/safe-next-path";
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
@@ -36,15 +37,19 @@ async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginA
}
export async function loginAction(formData: FormData): Promise<void> {
// Ruecksprungziel (z.B. /patenschaft): Fehler fuehren zurueck auf die
// Anmeldung MIT dem Ziel, Erfolg direkt dorthin. Nur Pfade dieser Seite.
const next = safeNextPath(formData.get("next"));
const loginPage = loginPathWithNext(next);
const ip = await getClientIp();
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
return fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
return fail(loginPage, "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
}
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
if (!(await isTurnstileValid(formData, ip))) {
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail(loginPage, "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = loginSchema.safeParse({
@@ -52,24 +57,24 @@ export async function loginAction(formData: FormData): Promise<void> {
password: formData.get("password"),
});
if (!parsed.success) {
return fail("/konto", "Bitte E-Mail und Passwort angeben.");
return fail(loginPage, "Bitte E-Mail und Passwort angeben.");
}
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
return fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
return fail(loginPage, "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
}
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (customer?.blockedAt) {
return fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
return fail(loginPage, "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
}
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
return fail(
"/konto",
loginPage,
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
);
}
@@ -80,7 +85,7 @@ export async function loginAction(formData: FormData): Promise<void> {
if (!customer || !passwordValid) {
if (customer) await registerFailedLogin(customer);
return fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
return fail(loginPage, "E-Mail-Adresse oder Passwort ist falsch.");
}
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
@@ -97,7 +102,7 @@ export async function loginAction(formData: FormData): Promise<void> {
}
await createSession(customer.id, customer.role, customer.sessionVersion);
redirect("/konto");
redirect(next ?? "/konto");
}
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
+12 -8
View File
@@ -10,6 +10,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile";
import { isPasswordBreached } from "@/lib/password-breach-check";
import { fail } from "./_shared";
import { succeed } from "@/lib/flash";
import { safeNextPath, loginPathWithNext } from "@/lib/safe-next-path";
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
@@ -17,14 +18,17 @@ const VERIFY_RESEND_ATTEMPTS_PER_WINDOW = 5;
const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
export async function registerAction(formData: FormData): Promise<void> {
// Siehe loginAction: Ruecksprungziel nach der Registrierung (nur Pfade dieser Seite).
const next = safeNextPath(formData.get("next"));
const registerPage = loginPathWithNext(next);
const ip = await getClientIp();
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
return fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
return fail(registerPage, "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
}
const turnstileToken = String(formData.get("turnstileToken") ?? "");
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
return fail(registerPage, "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = registerSchema.safeParse({
@@ -34,16 +38,16 @@ export async function registerAction(formData: FormData): Promise<void> {
password: formData.get("password"),
});
if (!parsed.success) {
return fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail(registerPage, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
if (formData.get("password") !== formData.get("passwordConfirm")) {
return fail("/konto", "Die Passwörter stimmen nicht überein.");
return fail(registerPage, "Die Passwörter stimmen nicht überein.");
}
if (await isPasswordBreached(parsed.data.password)) {
return fail(
"/konto",
registerPage,
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
);
}
@@ -56,7 +60,7 @@ export async function registerAction(formData: FormData): Promise<void> {
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
await hashPassword(parsed.data.password);
return fail(
"/konto",
registerPage,
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
);
}
@@ -77,12 +81,12 @@ export async function registerAction(formData: FormData): Promise<void> {
await createSession(customer.id, customer.role, customer.sessionVersion);
if (!mailSent) {
return fail(
"/konto",
registerPage,
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
);
}
return succeed(
"/konto",
next ?? "/konto",
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
);
}
+15 -2
View File
@@ -8,6 +8,9 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getCurrentCustomer } from "@/lib/auth";
import { isEntraConfigured } from "@/lib/entra";
import { SponsorshipNavCard } from "./patenschaft/_components/nav-card";
import { redirect } from "next/navigation";
import { safeNextPath } from "@/lib/safe-next-path";
import {
registerAction,
loginAction,
@@ -18,9 +21,16 @@ import {
export const metadata: Metadata = { title: "Mein Konto" };
export const dynamic = "force-dynamic";
export default async function KontoPage() {
const t = getDictionary(await getLocale());
export default async function KontoPage({ searchParams }: PageProps<"/konto">) {
const locale = await getLocale();
const t = getDictionary(locale);
const customer = await getCurrentCustomer();
// Ruecksprungziel, z.B. von der Bienenpatenschaft (/konto?next=/patenschaft).
// Angemeldet geht es sofort dorthin, sonst reisen Login und Registrierung
// das Ziel als verstecktes Feld mit (lib/safe-next-path.ts).
const next = safeNextPath((await searchParams).next);
if (customer && next) redirect(next);
const nextField = next ? <input type="hidden" name="next" value={next} /> : null;
if (!customer) {
return (
@@ -30,6 +40,7 @@ export default async function KontoPage() {
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
<div className="md:pr-12">
<form action={loginAction} className="space-y-4">
{nextField}
<h2 className="text-3xl text-heading">{t.account.login}</h2>
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
@@ -76,6 +87,7 @@ export default async function KontoPage() {
</div>
<form action={registerAction} className="space-y-4 md:pl-12">
{nextField}
<h2 className="text-3xl text-heading">{t.account.register}</h2>
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
@@ -150,6 +162,7 @@ export default async function KontoPage() {
title={t.account.navSettings.title}
description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`}
/>
<SponsorshipNavCard customerId={customer.id} locale={locale} className="sm:col-span-2" />
{customer.role === "ADMIN" ? (
<AccountNavCard
href="/admin"
@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
export interface ActionsMenuItem {
key: string;
label: string;
/** Entweder Link (Abo verwalten, Download) oder Aktion (Dialog oeffnen). */
href?: string;
download?: boolean;
onSelect?: () => void;
}
/**
* Kleines Aktionsmenue im Kopf der Detailseite (Muster "menu button" der
* WAI-ARIA Authoring Practices): Pfeiltasten wandern, Escape schliesst und
* gibt den Fokus an den Knopf zurueck, ein Klick daneben schliesst ebenfalls.
*/
export function ActionsMenu({ label, items }: { label: string; items: readonly ActionsMenuItem[] }) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus();
function onPointerDown(event: PointerEvent) {
const target = event.target as Node;
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function close(returnFocus: boolean) {
setOpen(false);
if (returnFocus) buttonRef.current?.focus();
}
function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const entries = Array.from(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]") ?? []);
const index = entries.indexOf(document.activeElement as HTMLElement);
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const step = event.key === "ArrowDown" ? 1 : -1;
entries[(index + step + entries.length) % entries.length]?.focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
(event.key === "Home" ? entries[0] : entries.at(-1))?.focus();
} else if (event.key === "Tab") {
setOpen(false);
}
}
const itemClass =
"flex w-full items-center px-4 py-3 text-left text-sm text-ink transition-colors hover:bg-honey/10 focus-visible:bg-honey/10 focus-visible:outline-none";
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={label}
onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
}
}}
className="inline-flex h-11 w-11 touch-manipulation items-center justify-center border border-ink/20 text-heading transition-[border-color,background-color,transform] duration-200 hover:border-heading active:scale-[0.96] aria-expanded:border-heading aria-expanded:bg-forest aria-expanded:text-cream"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{open ? (
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-full z-30 mt-2 min-w-[14rem] origin-top-right border border-ink/10 bg-background py-1.5 shadow-[0_24px_48px_-24px_rgba(36,53,43,0.45)] transition-[opacity,scale] duration-150 ease-[var(--ease-arrive)] starting:scale-[0.97] starting:opacity-0 motion-reduce:transition-none"
>
{items.map((item) =>
item.href ? (
item.download ? (
<a key={item.key} role="menuitem" tabIndex={-1} href={item.href} download className={itemClass} onClick={() => close(false)}>
{item.label}
</a>
) : (
<Link key={item.key} role="menuitem" tabIndex={-1} href={item.href} className={itemClass} onClick={() => close(false)}>
{item.label}
</Link>
)
) : (
<button
key={item.key}
type="button"
role="menuitem"
tabIndex={-1}
className={itemClass}
onClick={() => {
close(false);
item.onSelect?.();
}}
>
{item.label}
</button>
)
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,110 @@
import type { ReactNode } from "react";
import type { OwnedSponsorship } from "@/lib/sponsorship/access";
import type { AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { ArchiveIcon, DocumentIcon, DownloadIcon, MedalIcon, VectorIcon } from "../../_components/icons";
import { kindLabel, sponsorshipFilePath, termText } from "../../_lib/present";
/**
* Ein Abschnitt der Detailseite. Die Seite reiht Abschnitte aneinander; ein
* spaeterer Abschnitt (etwa "Bilder", Spec "Erweiterbarkeit") kommt als
* weitere DetailSection dazu, ohne den Rest umzubauen.
*/
export function DetailSection({ id, title, children, className = "" }: { id: string; title: string; children: ReactNode; className?: string }) {
return (
<section aria-labelledby={`${id}-heading`} className={className}>
<h2 id={`${id}-heading`} className="text-3xl text-heading">
{title}
</h2>
{children}
</section>
);
}
export function SponsorshipFacts({ sponsorship, locale, t }: { sponsorship: OwnedSponsorship; locale: Locale; t: AccountSponsorshipStrings }) {
const personalization = sponsorship.revision?.personalization;
const rows: { label: string; value: string }[] = [
{ label: t.detail.fields.number, value: sponsorship.number },
{ label: t.detail.fields.offer, value: `${sponsorship.offer.name} · ${kindLabel(sponsorship, t)}` },
{ label: t.detail.fields.term, value: termText(sponsorship, locale, t) },
{ label: t.detail.fields.status, value: t.lifecycle[sponsorship.lifecycle] },
];
if (personalization) {
rows.splice(2, 0, { label: t.detail.fields.recipient, value: personalization.recipientName });
// Leere Felder erscheinen nicht — wie auf der Urkunde selbst.
if (personalization.giverNames) rows.splice(3, 0, { label: t.detail.fields.givers, value: personalization.giverNames });
rows.push({ label: t.detail.fields.occasion, value: t.detail.occasions[personalization.occasion] });
}
return (
<dl className="mt-6 divide-y divide-ink/10 border-y border-ink/10">
{rows.map((row) => (
<div key={row.label} className="grid gap-1 py-3.5 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-6">
<dt className="text-sm text-ink/65">{row.label}</dt>
<dd className="break-words text-heading tabular-nums">{row.value}</dd>
</div>
))}
</dl>
);
}
interface DownloadEntry {
key: string;
href: string;
label: string;
hint: string;
icon: ReactNode;
}
/** Downloads als normale Links (Spec: "alle Downloads sind normale Links"). */
export function SponsorshipDownloads({ sponsorship, t }: { sponsorship: OwnedSponsorship; t: AccountSponsorshipStrings }) {
const d = t.detail.downloads;
const file = (name: string) => sponsorshipFilePath(sponsorship.id, name);
const entries: DownloadEntry[] = [
{ key: "pdf", href: file("urkunde.pdf"), label: d.pdf, hint: d.pdfHint, icon: <DocumentIcon /> },
{ key: "png", href: file("medaille.png"), label: d.png, hint: d.pngHint, icon: <MedalIcon /> },
{ key: "svg", href: file("medaille.svg"), label: d.svg, hint: d.svgHint, icon: <VectorIcon /> },
];
return (
<>
<p className="mt-2 text-sm text-ink/70">{t.detail.downloadsIntro}</p>
<a
href={file("alles.zip")}
download
className="group mt-6 flex min-h-14 touch-manipulation items-center gap-4 border border-heading bg-forest px-5 py-3.5 text-cream transition-[background-color,transform,box-shadow] duration-300 ease-[var(--ease-arrive)] hover:bg-honey-deep hover:shadow-[0_14px_28px_-16px_rgba(36,53,43,0.6)] active:scale-[0.98]"
>
<span className="text-honey-pale">
<ArchiveIcon />
</span>
<span className="flex-1">
<span className="block text-sm tracking-wide-caps">{d.all}</span>
<span className="block text-xs text-cream/75">{d.allHint}</span>
</span>
<span className="transition-transform duration-300 group-hover:translate-y-0.5">
<DownloadIcon />
</span>
</a>
<ul className="mt-3 divide-y divide-ink/10 border-y border-ink/10">
{entries.map((entry) => (
<li key={entry.key}>
<a
href={entry.href}
download
className="group flex min-h-14 touch-manipulation items-center gap-4 px-1 py-3 transition-colors hover:bg-honey/5"
>
<span className="text-honey-dark">{entry.icon}</span>
<span className="flex-1">
<span className="block text-heading">{entry.label}</span>
<span className="block text-xs text-ink/65">{entry.hint}</span>
</span>
<span className="text-heading transition-transform duration-300 group-hover:translate-y-0.5">
<DownloadIcon />
</span>
</a>
</li>
))}
</ul>
</>
);
}
@@ -0,0 +1,38 @@
import { formatDateTime } from "@/lib/format";
import type { OpenTransfer } from "@/lib/sponsorship/transfer";
import type { AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { LinkIcon } from "../../_components/icons";
import { RevokeTransferButton } from "./revoke-transfer-button";
/**
* Offener Uebertragungslink: Ablaufzeit und Widerruf. Den Link selbst zeigen
* wir hier nicht — gespeichert ist nur sein Hash, er war einmalig nach dem
* Erstellen sichtbar.
*/
export function OpenTransferNotice({ transfer, locale, t }: { transfer: OpenTransfer; locale: Locale; t: AccountSponsorshipStrings }) {
return (
<section
aria-labelledby="open-transfer-heading"
className="mt-12 grid gap-5 border border-honey/40 bg-honey/5 p-5 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-start sm:p-6"
>
<span className="hidden h-11 w-11 items-center justify-center border border-honey/40 text-honey-dark sm:flex">
<LinkIcon />
</span>
<div className="min-w-0">
<h2 id="open-transfer-heading" className="font-serif text-2xl text-heading">
{t.transfer.sectionHeading}
</h2>
<p className="mt-1 text-sm text-ink/75">
{t.transfer.sectionIntro}{" "}
<span className="text-heading tabular-nums">{t.transfer.openLinkExpires(formatDateTime(transfer.expiresAt, locale))}</span>
</p>
<p className="mt-2 max-w-[62ch] text-xs leading-relaxed text-ink/65">{t.transfer.openLinkHidden}</p>
</div>
<RevokeTransferButton
transferId={transfer.id}
labels={{ revoke: t.transfer.revoke, revoked: t.transfer.revoked, revokeFailed: t.transfer.revokeFailed }}
/>
</section>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { revokeTransferAction } from "../transfer-actions";
/** Widerruft den offenen Link; die Seite laedt danach ohne ihn neu. */
export function RevokeTransferButton({
transferId,
labels,
}: {
transferId: string;
labels: { revoke: string; revoked: string; revokeFailed: string };
}) {
const router = useRouter();
const [message, setMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-col items-start gap-2 sm:items-end">
<button
type="button"
disabled={isPending}
onClick={() =>
startTransition(async () => {
const result = await revokeTransferAction(transferId);
setMessage(result.ok ? labels.revoked : labels.revokeFailed);
router.refresh();
})
}
className="inline-flex min-h-11 touch-manipulation items-center justify-center border border-ink/20 px-5 py-2 text-sm tracking-wide-caps text-heading transition-[background-color,border-color,transform] duration-200 hover:border-heading hover:bg-ink/[0.03] active:scale-[0.97] disabled:cursor-wait disabled:opacity-60"
>
{labels.revoke}
</button>
<p role="status" className="min-h-5 text-xs text-ink/70">
{message}
</p>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useRef } from "react";
import { ActionsMenu, type ActionsMenuItem } from "./actions-menu";
import { TransferDialog, type TransferDialogHandle, type TransferDialogLabels } from "./transfer-dialog";
export interface SponsorshipActionsProps {
sponsorshipId: string;
locale: "de" | "en";
canTransfer: boolean;
manageHref: string | null;
downloadAllHref: string | null;
labels: {
menuLabel: string;
menuTransfer: string;
menuManageSubscription: string;
menuDownloadAll: string;
transfer: TransferDialogLabels;
};
}
/** Menue im Kopf der Detailseite samt Uebertragungsdialog. */
export function SponsorshipActions({ sponsorshipId, locale, canTransfer, manageHref, downloadAllHref, labels }: SponsorshipActionsProps) {
const dialogRef = useRef<TransferDialogHandle>(null);
if (!canTransfer && !manageHref && !downloadAllHref) return null;
const openTransferDialog = () => dialogRef.current?.open();
const items: ActionsMenuItem[] = [
...(canTransfer ? [{ key: "transfer", label: labels.menuTransfer, onSelect: openTransferDialog }] : []),
...(manageHref ? [{ key: "manage", label: labels.menuManageSubscription, href: manageHref }] : []),
...(downloadAllHref ? [{ key: "zip", label: labels.menuDownloadAll, href: downloadAllHref, download: true }] : []),
];
return (
<>
<ActionsMenu label={labels.menuLabel} items={items} />
{canTransfer ? (
<TransferDialog ref={dialogRef} sponsorshipId={sponsorshipId} labels={labels.transfer} locale={locale} />
) : null}
</>
);
}
@@ -0,0 +1,236 @@
"use client";
import { forwardRef, useImperativeHandle, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CopyCodeButton } from "@/components/copy-code-button";
import { TRANSFER_VALIDITIES, type TransferValidity } from "@/lib/sponsorship/transfer-validity";
import { createTransferAction } from "../transfer-actions";
export interface TransferDialogLabels {
dialogTitle: string;
dialogIntro: string;
validityLegend: string;
validity: Record<TransferValidity, string>;
next: string;
back: string;
cancel: string;
close: string;
confirmTitle: string;
confirmText: string;
confirmButton: string;
creating: string;
doneTitle: string;
/** Mit Platzhalter {date}. */
doneText: string;
linkLabel: string;
copy: string;
copied: string;
onceHint: string;
errorGeneric: string;
errorNotTransferable: string;
errorRateLimited: string;
}
export interface TransferDialogHandle {
open: () => void;
}
type Step = { kind: "choose" } | { kind: "confirm" } | { kind: "done"; url: string; expiresAt: string };
const INTL_LOCALE = { de: "de-DE", en: "en-GB" } as const;
/**
* Dialog "Übertragen": Gueltigkeit waehlen → Bestaetigung mit dem Wortlaut der
* Spec → Link. Der Link entsteht erst nach der Bestaetigung und wird nur in
* diesem Dialog angezeigt; schliesst man ihn, ist er nicht mehr abrufbar.
* Natives <dialog> mit showModal(): Fokusfalle, Escape und Hintergrund-
* Sperre bringt der Browser mit.
*/
export const TransferDialog = forwardRef<
TransferDialogHandle,
{ sponsorshipId: string; labels: TransferDialogLabels; locale: "de" | "en" }
>(function TransferDialog({ sponsorshipId, labels, locale }, ref) {
const dialogRef = useRef<HTMLDialogElement>(null);
const router = useRouter();
const [step, setStep] = useState<Step>({ kind: "choose" });
const [validity, setValidity] = useState<TransferValidity>("7d");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useImperativeHandle(ref, () => ({
open() {
setStep({ kind: "choose" });
setError(null);
dialogRef.current?.showModal();
},
}));
function close() {
dialogRef.current?.close();
}
function create() {
setError(null);
startTransition(async () => {
const result = await createTransferAction(sponsorshipId, validity);
if (!result.ok) {
setError(
result.error === "NOT_TRANSFERABLE"
? labels.errorNotTransferable
: result.error === "RATE_LIMITED"
? labels.errorRateLimited
: labels.errorGeneric
);
return;
}
setStep({ kind: "done", url: `${window.location.origin}${result.path}`, expiresAt: result.expiresAt });
router.refresh();
});
}
const formatExpiry = (iso: string) =>
new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: "long", timeStyle: "short" }).format(new Date(iso));
const titleId = `transfer-title-${sponsorshipId}`;
const title = step.kind === "choose" ? labels.dialogTitle : step.kind === "confirm" ? labels.confirmTitle : labels.doneTitle;
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
onClose={() => setStep({ kind: "choose" })}
className="m-auto w-[min(34rem,calc(100vw-2rem))] max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain border border-ink/10 bg-background p-0 text-ink shadow-[0_40px_80px_-30px_rgba(20,28,23,0.55)] backdrop:bg-[color-mix(in_oklab,var(--color-forest)_45%,transparent)] backdrop:backdrop-blur-[2px] open:transition-[opacity,scale] open:duration-200 open:ease-[var(--ease-arrive)] starting:open:scale-[0.97] starting:open:opacity-0 motion-reduce:open:transition-none"
>
<div className="relative px-6 py-7 sm:px-9 sm:py-9">
<button
type="button"
onClick={close}
aria-label={labels.close}
className="absolute right-3 top-3 inline-flex h-11 w-11 touch-manipulation items-center justify-center text-ink/60 transition-colors hover:text-heading"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
<h2 id={titleId} className="pr-10 font-serif text-3xl leading-tight text-heading">
{title}
</h2>
{step.kind === "choose" ? (
<form
className="mt-4"
onSubmit={(event) => {
event.preventDefault();
setStep({ kind: "confirm" });
}}
>
<p className="text-sm leading-relaxed text-ink/75">{labels.dialogIntro}</p>
<fieldset className="mt-6">
<legend className="text-sm text-heading">{labels.validityLegend}</legend>
<div className="mt-3 grid grid-cols-3 gap-2">
{TRANSFER_VALIDITIES.map((option) => (
<label
key={option}
className="relative flex min-h-12 cursor-pointer touch-manipulation select-none items-center justify-center border border-ink/20 px-2 text-center text-sm transition-[border-color,background-color,color] duration-200 has-[:checked]:border-heading has-[:checked]:bg-forest has-[:checked]:text-cream has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark"
>
<input
type="radio"
name="validity"
value={option}
checked={validity === option}
onChange={() => setValidity(option)}
className="sr-only"
/>
{labels.validity[option]}
</label>
))}
</div>
</fieldset>
<DialogButtons>
<SecondaryButton onClick={close}>{labels.cancel}</SecondaryButton>
<PrimaryButton type="submit">{labels.next}</PrimaryButton>
</DialogButtons>
</form>
) : null}
{step.kind === "confirm" ? (
<div className="mt-4">
<p className="border-l border-honey pl-4 text-[15px] leading-relaxed text-ink/85">{labels.confirmText}</p>
<p className="mt-4 text-sm text-ink/65">
{labels.validityLegend} <span className="text-heading">{labels.validity[validity]}</span>
</p>
{error ? (
<p role="alert" className="mt-4 border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
{error}
</p>
) : null}
<DialogButtons>
<SecondaryButton onClick={() => setStep({ kind: "choose" })} disabled={isPending}>
{labels.back}
</SecondaryButton>
<PrimaryButton onClick={create} disabled={isPending} aria-busy={isPending}>
{isPending ? labels.creating : labels.confirmButton}
</PrimaryButton>
</DialogButtons>
</div>
) : null}
{step.kind === "done" ? (
<div className="mt-4">
<p className="text-sm text-ink/75">{labels.doneText.replace("{date}", formatExpiry(step.expiresAt))}</p>
<label className="mt-5 block text-sm text-heading" htmlFor={`transfer-link-${sponsorshipId}`}>
{labels.linkLabel}
</label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input
id={`transfer-link-${sponsorshipId}`}
readOnly
value={step.url}
onFocus={(event) => event.currentTarget.select()}
className="min-w-0 flex-1 border border-ink/20 bg-surface px-3 py-2.5 font-mono text-[16px] text-heading sm:text-sm"
/>
<CopyCodeButton code={step.url} label={labels.copy} copiedLabel={labels.copied} />
</div>
<p className="mt-4 flex items-start gap-2 text-sm text-honey-dark">
<svg viewBox="0 0 24 24" className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M12 3 2.5 20h19L12 3z" strokeLinejoin="round" />
<path d="M12 10v4.5M12 17.2v.3" strokeLinecap="round" />
</svg>
{labels.onceHint}
</p>
<DialogButtons>
<PrimaryButton onClick={close}>{labels.close}</PrimaryButton>
</DialogButtons>
</div>
) : null}
</div>
</dialog>
);
});
function DialogButtons({ children }: { children: React.ReactNode }) {
return <div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">{children}</div>;
}
type ButtonProps = React.ComponentPropsWithoutRef<"button">;
function PrimaryButton({ className = "", type = "button", ...props }: ButtonProps) {
return (
<button
type={type}
className={`inline-flex min-h-11 touch-manipulation items-center justify-center border border-heading bg-forest px-6 py-2.5 text-sm tracking-wide-caps text-cream transition-[background-color,transform] duration-200 hover:bg-honey-deep active:scale-[0.97] disabled:cursor-wait disabled:opacity-70 ${className}`}
{...props}
/>
);
}
function SecondaryButton({ className = "", type = "button", ...props }: ButtonProps) {
return (
<button
type={type}
className={`inline-flex min-h-11 touch-manipulation items-center justify-center border border-ink/20 px-6 py-2.5 text-sm tracking-wide-caps text-ink transition-[border-color,transform] duration-200 hover:border-ink active:scale-[0.97] disabled:opacity-50 ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,14 @@
import { loadOwnedSponsorship, allowRender } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http";
import { buildSponsorshipZip, downloadNames } from "../../_lib/bundle";
/** "Alles herunterladen": PDF, Medaille als PNG und SVG und eine kurze Erklaerung als ZIP. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
if (!owned?.sponsorship.revision) return notFoundResponse();
if (!allowRender(owned.customer.id)) return tooManyRequestsResponse();
const zip = await buildSponsorshipZip(owned.sponsorship, owned.locale);
if (!zip) return notFoundResponse();
return privateFileResponse(zip, { contentType: "application/zip", downloadName: downloadNames(owned.sponsorship).zip });
}
@@ -0,0 +1,27 @@
import { loadOwnedSponsorship, allowRender } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http";
import { downloadNames } from "../../_lib/bundle";
import { cachedMedalPng, parseMedalSize, renderAndCacheMedalPng, sponsorshipMedalInput } from "../../_lib/medal";
/**
* Patenschafts-Medaille als PNG mit transparentem Hintergrund. Standard ist
* die Downloadgroesse 2048 px; ?groesse=256|512 liefert das Vorschaubild der
* Medaillenleiste, ?groesse=1024 die Textur der 3D-Ansicht (beide ohne
* Download-Header).
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
if (!owned?.sponsorship.revision) return notFoundResponse();
const size = parseMedalSize(new URL(request.url).searchParams.get("groesse"));
const input = sponsorshipMedalInput(owned.sponsorship);
let png = cachedMedalPng(input, size);
if (!png) {
if (!allowRender(owned.customer.id)) return tooManyRequestsResponse();
png = await renderAndCacheMedalPng(input, size);
}
return privateFileResponse(png, {
contentType: "image/png",
downloadName: size === 2048 ? downloadNames(owned.sponsorship).png : undefined,
});
}
@@ -0,0 +1,16 @@
import { renderMedalSvg } from "@/lib/medals";
import { loadOwnedSponsorship } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse } from "../../_lib/http";
import { downloadNames } from "../../_lib/bundle";
import { sponsorshipMedalInput } from "../../_lib/medal";
/** Patenschafts-Medaille als Vektor (SVG, Text als Pfade) — immer als Download, nie inline. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
if (!owned?.sponsorship.revision) return notFoundResponse();
const svg = renderMedalSvg(sponsorshipMedalInput(owned.sponsorship));
return privateFileResponse(svg, {
contentType: "image/svg+xml; charset=utf-8",
downloadName: downloadNames(owned.sponsorship).svg,
});
}
+202
View File
@@ -0,0 +1,202 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Container } from "@/components/ui";
import { BackLink } from "@/components/back-link";
import { SponsorshipStageLazy } from "@/components/patenschaft-3d";
import { medalPalette, medalReliefSvg } from "@/lib/medals";
import { getCurrentCustomer } from "@/lib/auth";
import { getOwnedSponsorship, isTransferable, type OwnedSponsorship } from "@/lib/sponsorship/access";
import { getOpenTransfer } from "@/lib/sponsorship/transfer";
import { getLocale } from "@/i18n/locale";
import { getAccountSponsorshipStrings, type AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { HoneycombPattern } from "@/components/honeycomb";
import { LifecycleBadge } from "../_components/lifecycle-badge";
import { PendingRefresher } from "../_components/pending-refresher";
import { sponsorshipMedalInput } from "../_lib/medal";
import { kindLabel, sponsorshipFilePath, subscriptionManagePath } from "../_lib/present";
import { DetailSection, SponsorshipDownloads, SponsorshipFacts } from "./_components/detail-blocks";
import { SponsorshipActions } from "./_components/sponsorship-actions";
import type { TransferDialogLabels } from "./_components/transfer-dialog";
import { OpenTransferNotice } from "./_components/open-transfer-notice";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ id: string }> };
async function load(id: string): Promise<{ sponsorship: OwnedSponsorship; locale: Locale } | null> {
const customer = await getCurrentCustomer();
if (!customer) redirect(`/konto?next=${encodeURIComponent(`/konto/patenschaft/${id}`)}`);
const locale = await getLocale();
const sponsorship = await getOwnedSponsorship(customer.id, id, locale);
return sponsorship ? { sponsorship, locale } : null;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = await getLocale();
const t = getAccountSponsorshipStrings(locale);
const customer = await getCurrentCustomer();
const sponsorship = customer ? await getOwnedSponsorship(customer.id, (await params).id, locale) : null;
return { title: sponsorship ? t.detail.metaTitle(sponsorship.beeName || sponsorship.number) : t.overview.metaTitle };
}
function Stage({ sponsorship, t }: { sponsorship: OwnedSponsorship; t: AccountSponsorshipStrings }) {
const revision = sponsorship.revision?.revision ?? 0;
const file = (name: string) => `${sponsorshipFilePath(sponsorship.id, name)}?r=${revision}`;
const medal = sponsorshipMedalInput(sponsorship);
return (
<SponsorshipStageLazy
certificateTextureUrl={file("textur.webp")}
certificateBackTextureUrl={file("rueckseite.webp")}
medalPngUrl={`${file("medaille.png")}&groesse=1024`}
medalReliefSvg={medalReliefSvg(medal)}
medalPalette={medalPalette(medal)}
fallbackImageUrl={file("textur.webp")}
title={t.detail.stageTitle(sponsorship.beeName)}
labels={t.detail.stage}
/>
);
}
/** Nur Zeichenketten: Funktionen lassen sich nicht an Client-Komponenten uebergeben. */
function transferDialogLabels(t: AccountSponsorshipStrings): TransferDialogLabels {
const x = t.transfer;
return {
dialogTitle: x.dialogTitle,
dialogIntro: x.dialogIntro,
validityLegend: x.validityLegend,
validity: x.validity,
next: x.next,
back: x.back,
cancel: x.cancel,
close: x.close,
confirmTitle: x.confirmTitle,
confirmText: x.confirmText,
confirmButton: x.confirmButton,
creating: x.creating,
doneTitle: x.doneTitle,
doneText: x.doneText("{date}"),
linkLabel: x.linkLabel,
copy: x.copy,
copied: x.copied,
onceHint: x.onceHint,
errorGeneric: x.errorGeneric,
errorNotTransferable: x.errorNotTransferable,
errorRateLimited: x.errorRateLimited,
};
}
function PendingPanel({ t }: { t: AccountSponsorshipStrings }) {
return (
<div role="status" className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-20 w-[4.6rem] text-honey">
<polygon
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
fill="none"
stroke="currentColor"
strokeWidth="0.6"
strokeDasharray="2 1.6"
className="motion-pulse origin-center animate-[wh-pulse_2.4s_ease-out_infinite]"
/>
</svg>
<p className="mt-6 font-serif text-3xl text-heading">{t.detail.pendingTitle}</p>
<p className="mt-2 max-w-[52ch] text-sm text-ink/70">{t.detail.pendingText}</p>
</div>
);
}
export default async function SponsorshipDetailPage({ params }: Props) {
const { id } = await params;
const loaded = await load(id);
if (!loaded) notFound();
const { sponsorship, locale } = loaded;
const t = getAccountSponsorshipStrings(locale);
const isReady = sponsorship.revision !== null;
const canTransfer = isTransferable(sponsorship);
const openTransfer = canTransfer ? await getOpenTransfer(sponsorship.id) : null;
const name = sponsorship.beeName || sponsorship.number;
return (
<div className="pb-24 pt-20">
<Container className="max-w-5xl">
<BackLink href="/konto/patenschaft" label={t.detail.back} />
<header className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<div className="min-w-0">
<h1 className="break-words font-serif text-5xl leading-[1.02] tracking-[-0.01em] text-heading [text-wrap:balance] md:text-6xl">
{name}
</h1>
<p className="mt-3 text-sm text-ink/70">
{kindLabel(sponsorship, t)} · {sponsorship.offer.name} ·{" "}
<span className="tabular-nums">
{t.detail.numberLabel} {sponsorship.number}
</span>
</p>
</div>
<div className="flex items-center gap-3">
<LifecycleBadge
state={isReady ? sponsorship.lifecycle : "PENDING"}
label={isReady ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
/>
<SponsorshipActions
sponsorshipId={sponsorship.id}
locale={locale}
canTransfer={canTransfer}
manageHref={sponsorship.subscription ? subscriptionManagePath(sponsorship.subscription.id) : null}
downloadAllHref={isReady ? sponsorshipFilePath(sponsorship.id, "alles.zip") : null}
labels={{
menuLabel: t.detail.menuLabel,
menuTransfer: t.detail.menuTransfer,
menuManageSubscription: t.detail.menuManageSubscription,
menuDownloadAll: t.detail.menuDownloadAll,
transfer: transferDialogLabels(t),
}}
/>
</div>
</header>
</Container>
{/* Buehne: eigener, ruhiger Grund ueber die ganze Breite, damit Urkunde
und Medaille wie in einer Vitrine stehen. */}
<section aria-labelledby="stage-heading" className="relative mt-10 overflow-hidden border-y border-ink/10 bg-surface">
<div aria-hidden="true" className="pointer-events-none absolute -left-10 -top-10 h-56 w-56 opacity-70">
<HoneycombPattern className="text-honey/25" size={16} />
</div>
<Container className="relative max-w-6xl py-10 md:py-14">
<h2 id="stage-heading" className="sr-only">
{t.detail.stageHeading}
</h2>
{isReady ? (
<>
<Stage sponsorship={sponsorship} t={t} />
<p className="mx-auto mt-6 max-w-[60ch] text-center text-xs text-ink/60 [@media(pointer:coarse)]:hidden">
{t.detail.stageHint}
</p>
</>
) : (
<PendingPanel t={t} />
)}
</Container>
</section>
<Container className="max-w-5xl">
{openTransfer ? <OpenTransferNotice transfer={openTransfer} locale={locale} t={t} /> : null}
<div className="mt-14 grid gap-14 md:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] md:gap-16">
<DetailSection id="facts" title={t.detail.detailsHeading}>
<SponsorshipFacts sponsorship={sponsorship} locale={locale} t={t} />
{sponsorship.subscription ? <p className="mt-4 text-sm text-ink/65">{t.detail.subscriptionNote}</p> : null}
</DetailSection>
{isReady ? (
<DetailSection id="downloads" title={t.detail.downloadsHeading}>
<SponsorshipDownloads sponsorship={sponsorship} t={t} />
</DetailSection>
) : null}
</div>
<p className="mt-16 max-w-[70ch] text-xs leading-relaxed text-ink/60">{t.detail.symbolicNote}</p>
</Container>
{!isReady ? <PendingRefresher /> : null}
</div>
);
}
@@ -0,0 +1,15 @@
import { loadOwnedSponsorship, allowRender } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http";
import { backTexture, hasCachedTexture } from "../../_lib/textures";
/** Rueckseite der Urkunde fuer die 3D-Ansicht: Waben-Zeichen und Patenschaftsnummer. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
if (!owned?.sponsorship.revision) return notFoundResponse();
if (!(await hasCachedTexture(owned.sponsorship, "back")) && !allowRender(owned.customer.id)) {
return tooManyRequestsResponse();
}
const texture = await backTexture(owned.sponsorship);
if (!texture) return notFoundResponse();
return privateFileResponse(texture.image, { contentType: "image/webp" });
}
@@ -0,0 +1,15 @@
import { loadOwnedSponsorship, allowRender } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http";
import { frontTexture, hasCachedTexture } from "../../_lib/textures";
/** Vorderseite fuer die 3D-Ansicht: 2048 px, ohne Wasserzeichen, nur fuer den Besitzer. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
if (!owned?.sponsorship.revision) return notFoundResponse();
if (!(await hasCachedTexture(owned.sponsorship, "front")) && !allowRender(owned.customer.id)) {
return tooManyRequestsResponse();
}
const texture = await frontTexture(owned.sponsorship);
if (!texture) return notFoundResponse();
return privateFileResponse(texture.image, { contentType: "image/webp" });
}
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import {
createTransfer,
isTransferValidity,
revokeTransfer,
transferClaimPath,
type TransferValidity,
} from "@/lib/sponsorship/transfer";
export type CreateTransferActionResult =
| { ok: true; path: string; expiresAt: string }
| { ok: false; error: "NOT_TRANSFERABLE" | "RATE_LIMITED" | "GENERIC" };
export type RevokeTransferActionResult = { ok: boolean };
// Ein Link je Patenschaft reicht; wer in kurzer Zeit sehr viele erzeugt,
// probiert etwas aus. Das Limit schuetzt vor allem die Datenbank.
const LINKS_PER_WINDOW = 10;
const LINK_WINDOW_MS = 15 * 60 * 1000;
/**
* Erstellt einen Uebertragungslink. Der Pfad mit dem Token wird genau hier
* einmal zurueckgegeben und nirgends gespeichert; die Seite zeigt ihn nur in
* diesem Moment. Die Domain setzt der Browser davor — so passt der Link immer
* zu der Adresse, unter der die Seite gerade laeuft.
*/
export async function createTransferAction(
sponsorshipId: string,
validity: TransferValidity
): Promise<CreateTransferActionResult> {
const customer = await getCurrentCustomer();
if (!customer || typeof sponsorshipId !== "string" || !isTransferValidity(validity)) {
return { ok: false, error: "GENERIC" };
}
if (!rateLimit(`sponsorship-transfer:${customer.id}`, LINKS_PER_WINDOW, LINK_WINDOW_MS)) {
return { ok: false, error: "RATE_LIMITED" };
}
const result = await createTransfer({ ownerId: customer.id, sponsorshipId, validity });
if (!result.ok) {
return { ok: false, error: result.reason === "NOT_TRANSFERABLE" ? "NOT_TRANSFERABLE" : "GENERIC" };
}
revalidatePath(`/konto/patenschaft/${sponsorshipId}`);
return { ok: true, path: transferClaimPath(result.token), expiresAt: result.expiresAt.toISOString() };
}
export async function revokeTransferAction(transferId: string): Promise<RevokeTransferActionResult> {
const customer = await getCurrentCustomer();
if (!customer || typeof transferId !== "string") return { ok: false };
const ok = await revokeTransfer({ ownerId: customer.id, transferId });
revalidatePath("/konto/patenschaft", "layout");
return { ok };
}
@@ -0,0 +1,21 @@
import { readCertificatePdf } from "@/lib/sponsorship/storage";
import { loadOwnedSponsorship } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse } from "../../_lib/http";
import { downloadNames } from "../../_lib/bundle";
/**
* Urkunde als PDF: immer die GESPEICHERTE Datei der aktuellen Revision, nie
* neu gerendert — was einmal ausgestellt wurde, bleibt Byte fuer Byte gleich.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const owned = await loadOwnedSponsorship((await params).id);
const revision = owned?.sponsorship.revision;
if (!owned || !revision) return notFoundResponse();
const pdf = await readCertificatePdf(revision.pdfPath);
if (!pdf) {
console.error(`[patenschaft] PDF fehlt fuer ${owned.sponsorship.number} (Revision ${revision.revision}).`);
return notFoundResponse();
}
return privateFileResponse(pdf, { contentType: "application/pdf", downloadName: downloadNames(owned.sponsorship).pdf });
}
@@ -0,0 +1,81 @@
// Strichicons im Stil von components/account-ui.tsx (24er Raster, 1.5 Strich,
// runde Enden), damit der Patenschaftsbereich zur restlichen Kontoseite passt.
const ICON_PROPS = {
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
/** Biene in einer Wabe — Navigationskarte und leerer Zustand. */
export function BeeSealIcon() {
return (
<svg {...ICON_PROPS}>
<ellipse cx="12" cy="13.5" rx="3.2" ry="4.6" />
<path d="M9 12.2h6M9.2 14.8h5.6" />
<path d="M11.4 8.9C10 6.6 7.4 6.2 6.4 7.6c-1 1.5.9 3.5 3.8 3.4M12.6 8.9c1.4-2.3 4-2.7 5-1.3 1 1.5-.9 3.5-3.8 3.4" />
<path d="M12 18.1v1.6" />
</svg>
);
}
export function DocumentIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M7 3h7l4 4v14H7z" />
<path d="M14 3v4h4M10 12h5M10 15.5h5" />
</svg>
);
}
export function MedalIcon() {
return (
<svg {...ICON_PROPS}>
<circle cx="12" cy="14" r="5.5" />
<circle cx="12" cy="14" r="2.6" />
<path d="M8.5 9.6 6.5 3h3l2.5 5M15.5 9.6l2-6.6h-3L12 8" />
</svg>
);
}
export function VectorIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M5 19c4-1 6-5 7-9s4-6 7-6" />
<rect x="3" y="17" width="4" height="4" />
<rect x="17" y="2" width="4" height="4" />
<path d="M12 10h-4M12 10h4" />
</svg>
);
}
export function ArchiveIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 7h16v13H4zM3 4h18v3H3z" />
<path d="M12 10v6M9.5 13.5 12 16l2.5-2.5" />
</svg>
);
}
export function DownloadIcon() {
return (
<svg {...ICON_PROPS} width={16} height={16}>
<path d="M12 4v11M7 10.5 12 15.5l5-5M5 19.5h14" />
</svg>
);
}
export function LinkIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1" />
<path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1" />
</svg>
);
}
@@ -0,0 +1,26 @@
import type { SponsorshipLifecycle } from "@/lib/sponsorship/access";
// Gleiche Formensprache wie OrderStatusBadge (components/account-ui.tsx):
// Rahmen, Punkt, Kapitaelchen. Aktiv in Honig, Vergangenes zurueckgenommen.
const STYLES: Record<SponsorshipLifecycle | "PENDING", string> = {
PENDING: "border-honey/40 text-honey-dark bg-honey/5",
SCHEDULED: "border-sage/50 text-heading bg-sage/10",
ACTIVE: "border-honey/50 text-honey-dark bg-honey/10",
CANCELLING: "border-ink/25 text-ink/75",
EXPIRED: "border-ink/15 text-ink/65",
ENDED: "border-ink/15 text-ink/65",
};
export function LifecycleBadge({ state, label }: { state: SponsorshipLifecycle | "PENDING"; label: string }) {
return (
<span className={`inline-flex items-center gap-2 whitespace-nowrap border px-2.5 py-1 text-xs tracking-wide-caps ${STYLES[state]}`}>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full bg-current ${
state === "PENDING" ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""
}`}
/>
{label}
</span>
);
}
@@ -0,0 +1,160 @@
import Link from "next/link";
import type { CustomerLoyalty } from "@/lib/loyalty";
import type { OwnedSponsorship } from "@/lib/sponsorship/access";
import type { AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import { sponsorshipFilePath } from "../_lib/present";
interface ShelfMedal {
key: string;
imageUrl: string;
title: string;
caption: string;
href?: string;
badge?: string;
/** Treue-Medaillen haben keine Detailseite; ihre Downloads stehen direkt unter dem Bild. */
downloads?: { png: string; svg: string };
}
/** Medaillen in fester Reihenfolge: Patenschaften (neueste zuerst), dann Stammkunde, dann Abo bzw. VIP. */
function collectMedals(
sponsorships: readonly OwnedSponsorship[],
loyalty: CustomerLoyalty,
t: AccountSponsorshipStrings
): ShelfMedal[] {
const medals: ShelfMedal[] = sponsorships
.filter((sponsorship) => sponsorship.revision !== null)
.map((sponsorship) => ({
key: sponsorship.id,
imageUrl: `${sponsorshipFilePath(sponsorship.id, "medaille.png")}?groesse=256`,
title: sponsorship.beeName,
caption: `${t.shelf.tier[sponsorship.offer.medalTier]} · ${t.shelf.sponsorshipCaption(sponsorship.startDate.getUTCFullYear())}`,
href: `/konto/patenschaft/${sponsorship.id}`,
}));
const regular = loyalty.regular.level;
if (regular) {
medals.push({
key: "stammkunde",
imageUrl: "/konto/patenschaft/medaille/stammkunde.png?groesse=256",
title: regular.name,
caption: t.shelf.regularCaption,
downloads: { png: "/konto/patenschaft/medaille/stammkunde.png", svg: "/konto/patenschaft/medaille/stammkunde.svg" },
});
}
const subscription = loyalty.subscription;
if (subscription?.level) {
medals.push({
key: "abo",
imageUrl: "/konto/patenschaft/medaille/abo.png?groesse=256",
title: subscription.isVip ? subscription.level.name : t.shelf.subscriptionCaption(subscription.months),
caption: subscription.isVip ? t.shelf.vipCaption : subscription.level.name,
badge: subscription.isVip ? t.shelf.vipBadge : undefined,
downloads: { png: "/konto/patenschaft/medaille/abo.png", svg: "/konto/patenschaft/medaille/abo.svg" },
});
}
return medals;
}
function progressLine(loyalty: CustomerLoyalty, t: AccountSponsorshipStrings): string | null {
const { level, next, paidOrders } = loyalty.regular;
if (!next || next.threshold === null) return null;
const missing = next.threshold - paidOrders;
if (missing <= 0) return null;
return level ? t.shelf.regularProgress(missing, next.name) : t.shelf.regularNone(missing, next.name);
}
function MedalFigure({ medal }: { medal: ShelfMedal }) {
return (
<>
<span className="relative block h-28 w-28 sm:h-32 sm:w-32">
{/* Lichtkegel hinter der Medaille, wie auf der Buehne der 3D-Ansicht. */}
<span
aria-hidden="true"
className="absolute inset-[-18%] bg-[radial-gradient(circle_at_50%_45%,color-mix(in_oklab,var(--color-honey)_22%,transparent),transparent_65%)]"
/>
{/* eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer */}
<img
src={medal.imageUrl}
alt=""
width={128}
height={128}
loading="lazy"
decoding="async"
className="relative h-full w-full object-contain drop-shadow-[0_14px_16px_rgba(36,53,43,0.28)] transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:rotate-[-4deg]"
/>
{medal.badge ? (
<span className="absolute -right-1 top-1 border border-honey-dark/40 bg-forest px-1.5 py-0.5 text-[10px] tracking-wide-caps text-cream">
{medal.badge}
</span>
) : null}
</span>
<span className="mt-3 block max-w-full truncate text-center font-serif text-xl leading-tight text-heading">{medal.title}</span>
<span className="mt-0.5 block max-w-full text-center text-xs leading-snug text-ink/65">{medal.caption}</span>
</>
);
}
/**
* Medaillenleiste: alle Medaillen des Kontos nebeneinander, auf schmalen
* Bildschirmen als waagerecht wischbare Reihe mit Einrasten.
*/
export function MedalShelf({
sponsorships,
loyalty,
t,
}: {
sponsorships: readonly OwnedSponsorship[];
loyalty: CustomerLoyalty;
t: AccountSponsorshipStrings;
}) {
const medals = collectMedals(sponsorships, loyalty, t);
const progress = progressLine(loyalty, t);
return (
<section aria-labelledby="medal-shelf-heading" className="relative mt-14 border-y border-ink/10 py-10">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-2">
<h2 id="medal-shelf-heading" className="text-3xl text-heading">
{t.shelf.title}
</h2>
{progress ? <p className="text-sm text-ink/70">{progress}</p> : null}
</div>
<p className="mt-2 max-w-[60ch] text-sm text-ink/70">{medals.length > 0 ? t.shelf.intro : t.shelf.empty}</p>
{medals.length > 0 ? (
<ul className="-mx-6 mt-8 flex snap-x snap-mandatory scroll-px-6 gap-6 overflow-x-auto overscroll-x-contain px-6 pb-2 [scrollbar-width:thin] sm:mx-0 sm:flex-wrap sm:gap-x-10 sm:gap-y-8 sm:overflow-visible sm:px-0">
{medals.map((medal, index) => (
<li
key={medal.key}
className="motion-rise w-36 shrink-0 snap-start animate-[wh-rise_0.5s_var(--ease-arrive)_both] sm:w-40"
style={{ animationDelay: `${Math.min(index, 8) * 60}ms` }}
>
{medal.href ? (
<Link
href={medal.href}
className="group flex flex-col items-center rounded-sm outline-offset-4 focus-visible:outline-2 focus-visible:outline-honey-dark"
>
<MedalFigure medal={medal} />
</Link>
) : (
<div className="group flex flex-col items-center">
<MedalFigure medal={medal} />
{medal.downloads ? (
<span className="mt-1.5 flex gap-3 text-xs">
<a href={medal.downloads.png} download aria-label={`${medal.title}, PNG`} className="text-heading underline underline-offset-4 decoration-honey/60 hover:decoration-heading">
PNG
</a>
<a href={medal.downloads.svg} download aria-label={`${medal.title}, SVG`} className="text-heading underline underline-offset-4 decoration-honey/60 hover:decoration-heading">
SVG
</a>
</span>
) : null}
</div>
)}
</li>
))}
</ul>
) : null}
</section>
);
}
@@ -0,0 +1,35 @@
import { AccountNavCard } from "@/components/account-ui";
import { prisma } from "@/lib/prisma";
import { hasActiveOffers } from "@/lib/sponsorship/offers";
import { getAccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { BeeSealIcon } from "./icons";
/**
* Karte "Patenschaft" auf der Kontoseite. Erscheint, sobald das Konto eine
* Patenschaft hat oder Patenschaften angeboten werden — ohne beides fuehrte
* sie nur auf eine leere Seite ohne Weg zum Angebot.
*/
export async function SponsorshipNavCard({
customerId,
locale,
className = "",
}: {
customerId: string;
locale: Locale;
className?: string;
}) {
const owned = await prisma.sponsorship.count({ where: { ownerId: customerId } });
if (owned === 0 && !(await hasActiveOffers())) return null;
const t = getAccountSponsorshipStrings(locale).navCard;
return (
<AccountNavCard
href="/konto/patenschaft"
icon={<BeeSealIcon />}
title={t.title}
description={t.description}
className={className}
/>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Die Urkunde entsteht meist binnen Sekunden nach der Zahlung, im Fehlerfall
// holt der Hintergrundlauf sie mit wachsendem Abstand nach. 10 Sekunden
// Abstand und hoechstens 30 Versuche (5 Minuten) decken den Normalfall ab,
// ohne eine vergessene Seite ewig gegen den Server laufen zu lassen.
const INTERVAL_MS = 10_000;
const MAX_REFRESHES = 30;
/**
* Laedt die Seite still neu, solange eine Urkunde noch erstellt wird. Pausiert
* in einem Hintergrund-Tab; sobald die Urkunde da ist, rendert der Server die
* Seite ohne diese Komponente und das Intervall endet von selbst.
*/
export function PendingRefresher() {
const router = useRouter();
useEffect(() => {
let count = 0;
const timer = window.setInterval(() => {
if (document.visibilityState !== "visible") return;
count += 1;
if (count > MAX_REFRESHES) {
window.clearInterval(timer);
return;
}
router.refresh();
}, INTERVAL_MS);
return () => window.clearInterval(timer);
}, [router]);
return null;
}
@@ -0,0 +1,108 @@
import Link from "next/link";
import type { OwnedSponsorship } from "@/lib/sponsorship/access";
import type { AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { LifecycleBadge } from "./lifecycle-badge";
import { kindLabel, sponsorshipFilePath, subscriptionManagePath, termText } from "../_lib/present";
/** Wabe als Platzhalter, solange die Medaille noch nicht gepraegt ist. */
function PendingSeal() {
return (
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-full w-full text-honey">
<polygon
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
fill="color-mix(in oklab, var(--color-honey) 8%, transparent)"
stroke="currentColor"
strokeWidth="0.7"
strokeDasharray="2 1.6"
className="motion-pulse origin-center animate-[wh-pulse_2.4s_ease-out_infinite]"
/>
</svg>
);
}
/**
* Eine Patenschaft in der Uebersicht. Die ganze Karte ist der Link zur
* Detailseite; "Abo verwalten" steht darunter ausserhalb des Links, damit
* keine Links ineinander liegen.
*/
export function SponsorshipRow({
sponsorship,
locale,
t,
index,
}: {
sponsorship: OwnedSponsorship;
locale: Locale;
t: AccountSponsorshipStrings;
index: number;
}) {
const isPending = sponsorship.revision === null;
const name = sponsorship.beeName || sponsorship.number;
return (
<li
className="motion-rise animate-[wh-rise_0.5s_var(--ease-arrive)_both]"
style={{ animationDelay: `${Math.min(index, 8) * 70}ms` }}
>
<Link
href={`/konto/patenschaft/${sponsorship.id}`}
aria-label={t.overview.openDetail(name)}
className="group relative grid grid-cols-[4.5rem_minmax(0,1fr)] items-center gap-x-5 gap-y-3 border border-ink/10 bg-background p-5 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_22px_40px_-26px_rgba(36,53,43,0.4)] active:scale-[0.995] sm:grid-cols-[5.5rem_minmax(0,1fr)_auto] sm:p-6"
>
<span className="relative block h-[4.5rem] w-[4.5rem] sm:h-[5.5rem] sm:w-[5.5rem]">
{isPending ? (
<PendingSeal />
) : (
// eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer
<img
src={`${sponsorshipFilePath(sponsorship.id, "medaille.png")}?groesse=256`}
alt=""
width={88}
height={88}
loading="lazy"
decoding="async"
className="h-full w-full object-contain drop-shadow-[0_10px_12px_rgba(36,53,43,0.25)] transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:rotate-[-6deg]"
/>
)}
</span>
<span className="min-w-0">
<span className="block truncate font-serif text-3xl leading-tight text-heading">{name}</span>
<span className="mt-1 block text-sm text-ink/70">
{kindLabel(sponsorship, t)} · {sponsorship.offer.name}
</span>
{isPending ? (
<span className="mt-1 block text-sm text-honey-dark">{t.overview.pendingTitle}</span>
) : (
<span className="mt-1 block text-sm tabular-nums text-ink/65">{termText(sponsorship, locale, t)}</span>
)}
</span>
<span className="col-span-2 flex items-center justify-between gap-3 sm:col-span-1 sm:flex-col sm:items-end">
<LifecycleBadge
state={isPending ? "PENDING" : sponsorship.lifecycle}
label={isPending ? t.overview.pendingBadge : t.lifecycle[sponsorship.lifecycle]}
/>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4 text-heading transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-1"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</Link>
{sponsorship.subscription ? (
<Link
href={subscriptionManagePath(sponsorship.subscription.id)}
className="mt-2 inline-flex items-center gap-1.5 py-1 text-sm text-heading underline decoration-honey/60 underline-offset-4 hover:decoration-heading"
>
{t.overview.manageSubscription}
</Link>
) : null}
</li>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { strToU8, zipSync, type Zippable } from "fflate";
import { readCertificatePdf } from "@/lib/sponsorship/storage";
import { renderMedalSvg, renderMedalPng } from "@/lib/medals";
import type { OwnedSponsorship } from "@/lib/sponsorship/access";
import { formatCertificateDate } from "@/lib/certificate/text";
import { displayTerm } from "@/lib/sponsorship/access";
import { getAccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
import { asciiFileStem } from "./http";
import { sponsorshipMedalInput } from "./medal";
/** Dateinamen im Archiv und in den Einzel-Downloads — an einer Stelle, damit sie gleich heissen. */
export function downloadNames(sponsorship: OwnedSponsorship): { pdf: string; png: string; svg: string; zip: string } {
const bee = sponsorship.beeName || sponsorship.number;
return {
pdf: `Bienenpatenschaft-${bee}.pdf`,
png: `Medaille-${bee}.png`,
svg: `Medaille-${bee}.svg`,
zip: `Bienenpatenschaft-${bee}.zip`,
};
}
function readme(sponsorship: OwnedSponsorship, locale: Locale, names: { pdf: string; png: string; svg: string }): string {
const t = getAccountSponsorshipStrings(locale).readme;
const term = displayTerm(sponsorship);
const termText =
term.kind === "OPEN"
? t.termOpen(formatCertificateDate(term.start, locale))
: t.termRange(formatCertificateDate(term.start, locale), formatCertificateDate(term.end, locale));
// CRLF: so oeffnet auch der Windows-Editor die Datei mit Zeilenumbruechen.
return [
t.title(sponsorship.beeName),
"",
`${t.numberLabel}: ${sponsorship.number}`,
termText,
"",
t.contents,
`- ${names.pdf}: ${t.pdf}`,
`- ${names.png}: ${t.png}`,
`- ${names.svg}: ${t.svg}`,
"",
t.symbolic,
"",
t.footer,
"",
].join("\r\n");
}
/**
* "Alles herunterladen": Urkunde (gespeicherte PDF der aktuellen Revision),
* Medaille als PNG und SVG und eine kurze Erklaerung. PDF und PNG sind schon
* komprimiert und werden nur gespeichert; SVG und Text werden gepackt.
*/
export async function buildSponsorshipZip(sponsorship: OwnedSponsorship, locale: Locale): Promise<Buffer | null> {
if (!sponsorship.revision) return null;
const pdf = await readCertificatePdf(sponsorship.revision.pdfPath);
if (!pdf) return null;
const medal = sponsorshipMedalInput(sponsorship);
const png = await renderMedalPng(medal, 2048);
// Im Archiv nur ASCII-Namen: aeltere Entpacker (u. a. Windows) zeigen
// UTF-8-Dateinamen sonst als Zeichensalat.
const bee = asciiFileStem(sponsorship.beeName || sponsorship.number);
const names = { pdf: `Bienenpatenschaft-${bee}.pdf`, png: `Medaille-${bee}.png`, svg: `Medaille-${bee}.svg` };
const folder = `Bienenpatenschaft-${bee}`;
const files: Zippable = {
[folder]: {
[names.pdf]: [new Uint8Array(pdf), { level: 0 }],
[names.png]: [new Uint8Array(png), { level: 0 }],
[names.svg]: [strToU8(renderMedalSvg(medal)), { level: 6 }],
"README.txt": [strToU8(readme(sponsorship, locale, names)), { level: 6 }],
},
};
// mtime fest auf die Ausstellung der Revision: gleiche Patenschaft, gleiches Archiv.
return Buffer.from(zipSync(files, { mtime: sponsorship.revision.createdAt }));
}
@@ -0,0 +1,41 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { asciiFileStem, attachmentDisposition, notFoundResponse, privateFileResponse } from "./http";
describe("Dateinamen der Downloads", () => {
it("schreibt Umlaute aus und entfernt Akzente", () => {
expect(asciiFileStem("Zoë Müller")).toBe("Zoe-Mueller");
expect(asciiFileStem("Ñúñez O’Brien")).toBe("Nunez-O-Brien");
expect(asciiFileStem("Weiß")).toBe("Weiss");
});
it("faellt bei reinen Sonderzeichen auf einen Standardnamen zurueck", () => {
expect(asciiFileStem("🐝🐝")).toBe("Patenschaft");
});
it("liefert ASCII-Fallback und RFC-5987-Fassung", () => {
const header = attachmentDisposition("Bienenpatenschaft-Zoë.pdf");
expect(header).toBe(`attachment; filename="Bienenpatenschaft-Zoe.pdf"; filename*=UTF-8''Bienenpatenschaft-Zo%C3%AB.pdf`);
});
it("laesst keine Anfuehrungszeichen oder Pfade in den Header", () => {
const header = attachmentDisposition('Bienenpatenschaft-a"b/../c.pdf');
expect(header).not.toMatch(/filename="[^"]*"[^;]*"/);
expect(header).not.toContain("/");
});
});
describe("Antworten der Besitzer-Routen", () => {
it("sind privat und ohne Typraten", () => {
const response = privateFileResponse(Buffer.from("%PDF"), { contentType: "application/pdf", downloadName: "Bienenpatenschaft-Li.pdf" });
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toContain('filename="Bienenpatenschaft-Li.pdf"');
});
it("verraten bei fremden IDs nichts", async () => {
const response = notFoundResponse();
expect(response.status).toBe(404);
expect(await response.text()).toBe("Nicht gefunden.");
});
});
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
/**
* Antworten der Besitzer-Routen (Urkunde, Textur, Medaille, ZIP). Alles hier
* ist persoenlich: kein gemeinsamer Cache darf es halten, und der Browser
* soll den Inhaltstyp nicht raten.
*/
const PRIVATE_HEADERS = {
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
} as const;
/** Bewusst ohne Begruendung: fremd und nicht vorhanden sehen gleich aus. */
export function notFoundResponse(): NextResponse {
return new NextResponse("Nicht gefunden.", { status: 404, headers: { ...PRIVATE_HEADERS, "Content-Type": "text/plain; charset=utf-8" } });
}
export function tooManyRequestsResponse(): NextResponse {
return new NextResponse("Zu viele Anfragen. Bitte versuche es gleich noch einmal.", {
status: 429,
headers: { ...PRIVATE_HEADERS, "Content-Type": "text/plain; charset=utf-8", "Retry-After": "60" },
});
}
export function privateFileResponse(
body: Buffer | string,
options: { contentType: string; downloadName?: string }
): NextResponse {
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
const headers: Record<string, string> = {
...PRIVATE_HEADERS,
"Content-Type": options.contentType,
"Content-Length": String(bytes.byteLength),
};
if (options.downloadName) headers["Content-Disposition"] = attachmentDisposition(options.downloadName);
return new NextResponse(bytes, { headers });
}
const UMLAUTS: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", Ä: "Ae", Ö: "Oe", Ü: "Ue", ß: "ss", ẞ: "SS" };
/**
* ASCII-Fassung eines Namens fuer Dateinamen: Umlaute ausgeschrieben, Akzente
* entfernt, alles andere zu Bindestrichen. "Zoë Müller" → "Zoe-Mueller".
*/
export function asciiFileStem(value: string, fallback = "Patenschaft"): string {
const ascii = value
.replace(/[äöüÄÖÜßẞ]/g, (char) => UMLAUTS[char] ?? char)
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^A-Za-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
return ascii || fallback;
}
/** Dateiname ohne Pfad- und Steuerzeichen, fuer filename* (RFC 5987) in UTF-8. */
function unicodeFileName(value: string): string {
return value.replace(/[\u0000-\u001f\u007f/\\"]/g, "").replace(/\s+/g, "-");
}
/**
* Content-Disposition mit ASCII-Fallback und RFC-5987-Fassung, damit
* "Bienenpatenschaft-Zoë.pdf" in jedem Browser richtig ankommt.
* `downloadName` hat die Form "<Praefix>-<Name>.<endung>".
*/
export function attachmentDisposition(downloadName: string): string {
const dot = downloadName.lastIndexOf(".");
const stem = dot > 0 ? downloadName.slice(0, dot) : downloadName;
const extension = dot > 0 ? downloadName.slice(dot + 1).replace(/[^A-Za-z0-9]/g, "") : "";
const asciiName = `${asciiFileStem(stem, "Bienenpatenschaft")}${extension ? `.${extension}` : ""}`;
const encoded = encodeURIComponent(unicodeFileName(downloadName)).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encoded}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { createHash } from "node:crypto";
import { renderMedalPng, type MedalInput } from "@/lib/medals";
import type { OwnedSponsorship } from "@/lib/sponsorship/access";
import type { CustomerLoyalty } from "@/lib/loyalty";
/** Patenschafts-Medaille: Stufe vom Angebot, Jahr des Patenschaftsbeginns. */
export function sponsorshipMedalInput(sponsorship: OwnedSponsorship): MedalInput {
return {
kind: "PATENSCHAFT",
tier: sponsorship.offer.medalTier,
beeName: sponsorship.beeName,
year: sponsorship.startDate.getUTCFullYear(),
number: sponsorship.number,
};
}
export type LoyaltyMedalKind = "stammkunde" | "abo";
/** Treue-Medaille des Kontos, oder null, solange die Stufe noch nicht erreicht ist. */
export function loyaltyMedalInput(loyalty: CustomerLoyalty, kind: LoyaltyMedalKind): MedalInput | null {
if (kind === "stammkunde") {
const level = loyalty.regular.level;
return level ? { kind: "STAMMKUNDE", level: level.rank, name: level.name } : null;
}
const subscription = loyalty.subscription;
if (!subscription?.level) return null;
return subscription.isVip ? { kind: "VIP" } : { kind: "ABO", months: subscription.months };
}
/**
* Erlaubte Bildgroessen: Vorschaubilder der Medaillenleiste (256/512), Textur
* der 3D-Ansicht (1024 — 2048 px waeren rund 2 MB) und der Download (2048).
*/
export const MEDAL_SIZES = [256, 512, 1024, 2048] as const;
export type MedalSize = (typeof MEDAL_SIZES)[number];
export function parseMedalSize(value: string | null): MedalSize {
const size = Number(value);
return (MEDAL_SIZES as readonly number[]).includes(size) ? (size as MedalSize) : 2048;
}
// Kleiner Prozess-Cache fuer gerasterte Vorschaubilder: die Medaillenleiste fragt
// bei jedem Seitenaufruf mehrere Vorschaubilder an, die sich nur aendern,
// wenn sich die Eingabe aendert. Schluessel ist ein Hash der Eingabe — der
// Cache haelt keine Namen im Klartext als Schluessel.
const MAX_CACHED = 120;
const pngCache = new Map<string, Buffer>();
function cacheKey(input: MedalInput, size: MedalSize): string {
return createHash("sha256").update(JSON.stringify(input)).update(`:${size}`).digest("hex");
}
export function cachedMedalPng(input: MedalInput, size: MedalSize): Buffer | null {
const key = cacheKey(input, size);
const hit = pngCache.get(key);
if (!hit) return null;
// Zuletzt benutzt nach hinten (einfaches LRU ueber die Einfuegereihenfolge der Map).
pngCache.delete(key);
pngCache.set(key, hit);
return hit;
}
export async function renderAndCacheMedalPng(input: MedalInput, size: MedalSize): Promise<Buffer> {
const png = await renderMedalPng(input, size);
// Nur Vorschaubilder; volle Downloads (2048 px, mehrere MB) werden selten
// geladen und wuerden den Speicher des Containers belasten.
if (size > 512) return png;
const key = cacheKey(input, size);
pngCache.set(key, png);
if (pngCache.size > MAX_CACHED) {
const oldest = pngCache.keys().next().value;
if (oldest !== undefined) pngCache.delete(oldest);
}
return png;
}
+35
View File
@@ -0,0 +1,35 @@
import { getCurrentCustomer } from "@/lib/auth";
import { getOwnedSponsorship, type OwnedSponsorship } from "@/lib/sponsorship/access";
import { rateLimit } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
import type { Locale } from "@/i18n/config";
import type { Customer } from "@/generated/prisma/client";
export interface OwnedContext {
customer: Customer;
sponsorship: OwnedSponsorship;
locale: Locale;
}
/**
* Anmeldung und Besitz in einem Schritt. null heisst fuer jede Route: 404 —
* auch ohne Anmeldung, damit eine Route nicht verraet, ob es die Patenschaft
* gibt.
*/
export async function loadOwnedSponsorship(id: string): Promise<OwnedContext | null> {
const customer = await getCurrentCustomer();
if (!customer) return null;
const locale = await getLocale();
const sponsorship = await getOwnedSponsorship(customer.id, id, locale);
return sponsorship ? { customer, sponsorship, locale } : null;
}
// Rendern (Textur, Medaille, ZIP) kostet spuerbar CPU. Zwischengespeicherte
// Ergebnisse zaehlen nicht; das Limit trifft nur echte Renderlaeufe. 60 in
// 10 Minuten reichen fuer jede normale Nutzung mehrerer Patenschaften.
const RENDERS_PER_WINDOW = 60;
const RENDER_WINDOW_MS = 10 * 60 * 1000;
export function allowRender(customerId: string): boolean {
return rateLimit(`sponsorship-render:${customerId}`, RENDERS_PER_WINDOW, RENDER_WINDOW_MS);
}
+35
View File
@@ -0,0 +1,35 @@
import { formatCertificateDate } from "@/lib/certificate/text";
import { toIsoDate } from "@/lib/certificate/schema";
import { displayTerm, type OwnedSponsorship } from "@/lib/sponsorship/access";
import type { AccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import type { Locale } from "@/i18n/config";
/**
* Anzeige-Hilfen fuer Uebersicht und Detail. Daten erscheinen im selben
* Format wie auf der Urkunde (formatCertificateDate), damit Konto und Urkunde
* nicht zwei Schreibweisen desselben Datums zeigen.
*/
export function termText(sponsorship: OwnedSponsorship, locale: Locale, t: AccountSponsorshipStrings): string {
const term = displayTerm(sponsorship);
const start = formatCertificateDate(term.start, locale);
if (term.kind === "OPEN") {
const since = t.term.since(start);
const cancelAt = sponsorship.subscription?.cancelAt;
return sponsorship.lifecycle === "CANCELLING" && cancelAt
? `${since} · ${t.term.endsOn(formatCertificateDate(toIsoDate(cancelAt), locale))}`
: since;
}
return t.term.range(start, formatCertificateDate(term.end, locale));
}
export function kindLabel(sponsorship: OwnedSponsorship, t: AccountSponsorshipStrings): string {
return sponsorship.subscription ? t.kind.subscription : t.kind.oneTime;
}
export function subscriptionManagePath(subscriptionId: string): string {
return `/konto/patenschaft/abo/${subscriptionId}`;
}
export function sponsorshipFilePath(id: string, file: string): string {
return `/konto/patenschaft/${id}/${file}`;
}
+165
View File
@@ -0,0 +1,165 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { resolveCertificatePath } from "@/lib/sponsorship/storage";
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
import { currentRevisionDocument, type OwnedSponsorship } from "@/lib/sponsorship/access";
import { CERT_COLORS, PAGE_HEIGHT, PAGE_WIDTH } from "@/lib/certificate/elements";
import { logomarkPaths } from "@/lib/certificate/art/logomark";
import { measureText, textToPath } from "@/lib/certificate/fonts";
import type { Locale } from "@/i18n/config";
/**
* Texturen der 3D-Ansicht. Beide werden aus unveraenderlichen Daten erzeugt
* (Vorderseite aus der gespeicherten Revision, Rueckseite aus der Nummer) und
* deshalb neben der PDF der Revision abgelegt: ein zweiter Aufruf liest nur
* noch die Datei. Faellt das Ablegen aus (Volume voll, nur lesbar), wird die
* Textur trotzdem ausgeliefert — der Zwischenspeicher ist kein Muss.
*/
export const TEXTURE_WIDTH_PX = 2048;
export interface TextureResult {
image: Buffer;
/** true = frisch gerendert (zaehlt fuers Rate-Limit). */
rendered: boolean;
}
async function readCached(relativePath: string): Promise<Buffer | null> {
try {
return await readFile(resolveCertificatePath(relativePath));
} catch {
return null;
}
}
async function storeCached(relativePath: string, data: Buffer): Promise<void> {
try {
const target = resolveCertificatePath(relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, data, { flag: "wx" });
} catch (error: unknown) {
// EEXIST: ein paralleler Aufruf war schneller, seine Datei ist gleichwertig.
if ((error as NodeJS.ErrnoException).code === "EEXIST") return;
const message = error instanceof Error ? error.message : String(error);
console.error(`[patenschaft] Textur ${relativePath} konnte nicht abgelegt werden: ${message}`);
}
}
async function cachedRender(relativePath: string, render: () => Promise<Buffer>): Promise<TextureResult> {
const cached = await readCached(relativePath);
if (cached) return { image: cached, rendered: false };
const image = await render();
await storeCached(relativePath, image);
return { image, rendered: true };
}
function revisionDir(sponsorship: OwnedSponsorship): string | null {
return sponsorship.revision ? path.posix.dirname(sponsorship.revision.pdfPath) : null;
}
/** Ist ein Ergebnis schon abgelegt? Damit kann die Route vor dem Rendern das Rate-Limit pruefen. */
export async function hasCachedTexture(sponsorship: OwnedSponsorship, side: "front" | "back"): Promise<boolean> {
const relative = texturePath(sponsorship, side);
return relative ? (await readCached(relative)) !== null : false;
}
function texturePath(sponsorship: OwnedSponsorship, side: "front" | "back"): string | null {
const dir = revisionDir(sponsorship);
if (!dir || !sponsorship.revision) return null;
return side === "front"
? path.posix.join(dir, `r${sponsorship.revision.revision}-textur-${TEXTURE_WIDTH_PX}.webp`)
: path.posix.join(dir, `rueckseite-${sponsorship.locale}-${TEXTURE_WIDTH_PX}.webp`);
}
/** Vorderseite: dieselbe Revision, dasselbe Layout wie die PDF, ohne Wasserzeichen. */
export async function frontTexture(sponsorship: OwnedSponsorship): Promise<TextureResult | null> {
const input = currentRevisionDocument(sponsorship);
const relative = texturePath(sponsorship, "front");
if (!input || !relative) return null;
return cachedRender(relative, () =>
renderCertificateDocumentImage(input, { widthPx: TEXTURE_WIDTH_PX, watermark: null, format: "webp" })
);
}
/** Rueckseite: Waben-Zeichen und Patenschaftsnummer. */
export async function backTexture(sponsorship: OwnedSponsorship): Promise<TextureResult | null> {
const relative = texturePath(sponsorship, "back");
if (!relative) return null;
return cachedRender(relative, () => renderBackImage(sponsorship.number, sponsorship.locale));
}
const NUMBER_LABEL: Record<Locale, string> = { de: "Nr.", en: "No." };
const MAKER_LINE = "IMKEREI WABENHAIN";
/**
* Feines Wabenraster, das zum Rand hin sichtbarer wird und die Mitte frei
* laesst — die Rueckseite soll wie bedrucktes Papier wirken, nicht leer.
* Vier Deckkraftstufen statt einer Maske, weil librsvg (sharp) Masken je nach
* Version unterschiedlich gut rendert.
*/
function honeycombField(): string {
const radius = 15;
const width = Math.sqrt(3) * radius;
const rowHeight = 1.5 * radius;
const cx = PAGE_WIDTH / 2;
const cy = PAGE_HEIGHT / 2;
const bands: string[][] = [[], [], [], []];
for (let row = -1; row * rowHeight < PAGE_HEIGHT + radius; row++) {
for (let col = -1; col * width < PAGE_WIDTH + width; col++) {
const x = col * width + (row % 2 === 0 ? 0 : width / 2);
const y = row * rowHeight;
const distance = Math.hypot((x - cx) / PAGE_WIDTH, (y - cy) / PAGE_HEIGHT);
const band = Math.min(3, Math.floor((distance - 0.24) / 0.07));
if (band < 0) continue;
const points = Array.from({ length: 6 }, (_, i) => {
const angle = (Math.PI / 3) * i + Math.PI / 6;
return `${(x + radius * Math.cos(angle)).toFixed(2)},${(y + radius * Math.sin(angle)).toFixed(2)}`;
});
bands[band].push(`M${points.join("L")}Z`);
}
}
const opacities = [0.05, 0.08, 0.11, 0.14];
return bands
.map((paths, i) =>
paths.length > 0
? `<path d="${paths.join("")}" fill="none" stroke="${CERT_COLORS.sage}" stroke-width="0.6" opacity="${opacities[i]}"/>`
: ""
)
.join("");
}
function centeredText(text: string, size: number, baseline: number, letterSpacing: number, font: "sans" | "sansMedium" | "serif"): string {
const width = measureText(font, text, size, { letterSpacing });
return textToPath(font, text, size, (PAGE_WIDTH - width) / 2, baseline, { letterSpacing });
}
export function renderBackSvg(number: string, locale: Locale): string {
const markSize = 132;
const markTop = PAGE_HEIGHT / 2 - markSize / 2 - 40;
const mark = logomarkPaths((PAGE_WIDTH - markSize) / 2, markTop, markSize)
.map(
(el) =>
`<path d="${el.d}" fill="none" stroke="${el.stroke ?? CERT_COLORS.forest}" stroke-width="${el.strokeWidth ?? 1}" stroke-linejoin="${el.lineJoin ?? "miter"}"/>`
)
.join("");
const makerBaseline = markTop + markSize + 46;
const ruleY = makerBaseline + 22;
const numberBaseline = ruleY + 30;
const frameInset = 34.02;
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}" width="${TEXTURE_WIDTH_PX}" height="${Math.round((TEXTURE_WIDTH_PX * PAGE_HEIGHT) / PAGE_WIDTH)}">`,
`<rect width="${PAGE_WIDTH}" height="${PAGE_HEIGHT}" fill="${CERT_COLORS.paper}"/>`,
honeycombField(),
`<rect x="${frameInset}" y="${frameInset}" width="${PAGE_WIDTH - 2 * frameInset}" height="${PAGE_HEIGHT - 2 * frameInset}" fill="none" stroke="${CERT_COLORS.honeyDark}" stroke-width="0.7" opacity="0.55"/>`,
mark,
`<path d="${centeredText(MAKER_LINE, 10.5, makerBaseline, 3.2, "sansMedium")}" fill="${CERT_COLORS.honeyDark}"/>`,
`<path d="M${PAGE_WIDTH / 2 - 28} ${ruleY}H${PAGE_WIDTH / 2 + 28}" stroke="${CERT_COLORS.honey}" stroke-width="0.6"/>`,
`<path d="${centeredText(`${NUMBER_LABEL[locale]} ${number}`, 11, numberBaseline, 0.6, "sans")}" fill="${CERT_COLORS.forest}"/>`,
"</svg>",
].join("");
}
async function renderBackImage(number: string, locale: Locale): Promise<Buffer> {
return sharp(Buffer.from(renderBackSvg(number, locale))).flatten({ background: CERT_COLORS.paper }).webp({ quality: 88 }).toBuffer();
}
@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
/** Abschnitt der Abo-Seite mit Ueberschrift — gleiches Muster wie die Detailseite der Patenschaft. */
export function AboSection({ id, title, children }: { id: string; title: string; children: ReactNode }) {
return (
<section aria-labelledby={`${id}-heading`} className="mt-12">
<h2 id={`${id}-heading`} className="text-3xl text-heading">
{title}
</h2>
{children}
</section>
);
}
/** Zeilen "Bezeichnung – Wert" als Beschreibungsliste. */
export function FactList({ rows }: { rows: { label: string; value: string }[] }) {
return (
<dl className="mt-6 divide-y divide-ink/10 border-y border-ink/10">
{rows.map((row) => (
<div key={row.label} className="grid gap-1 py-3.5 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-6">
<dt className="text-sm text-ink/65">{row.label}</dt>
<dd className="break-words text-heading tabular-nums">{row.value}</dd>
</div>
))}
</dl>
);
}
/** Fortschritt bis zur naechsten Lieferung als Balken mit Text (der Text traegt die Aussage, der Balken ist Zierde). */
export function DeliveryProgress({ paid, required, label }: { paid: number; required: number; label: string }) {
const percent = required > 0 ? Math.round((paid / required) * 100) : 0;
return (
<div className="mt-6">
<p className="text-heading tabular-nums">{label}</p>
<div aria-hidden="true" className="mt-3 h-2 w-full overflow-hidden bg-ink/10">
<div className="h-full bg-honey-dark" style={{ width: `${percent}%` }} />
</div>
</div>
);
}
/** Hinweisbox fuer Statusmeldungen (Kuendigung eingegangen, Fehler). */
export function Notice({ tone, children }: { tone: "info" | "error"; children: ReactNode }) {
return (
<p
role={tone === "error" ? "alert" : "status"}
className={`mt-8 border px-5 py-4 text-sm leading-relaxed ${
tone === "error" ? "border-red-700/40 text-red-700 dark:text-red-300" : "border-honey-dark/40 bg-surface/60 text-heading"
}`}
>
{children}
</p>
);
}
@@ -0,0 +1,69 @@
"use server";
import { redirect } from "next/navigation";
import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import { getBaseUrl } from "@/lib/url";
import { canCancel, getOwnedSubscription } from "@/lib/sponsorship-subscription/account";
import { cancelSubscription, createPaymentMethodPortalUrl } from "@/lib/sponsorship-subscription/cancellation";
/**
* Aktionen der Abo-Seite im Konto. Jede Aktion prueft Anmeldung und Besitz
* selbst — Server-Aktionen sind per direktem POST erreichbar, nicht nur ueber
* die Oberflaeche.
*/
const ACTIONS_PER_WINDOW = 10;
const WINDOW_MS = 10 * 60 * 1000;
function subscriptionIdFrom(formData: FormData): string {
const id = String(formData.get("subscriptionId") ?? "");
return /^[a-z0-9]{10,40}$/i.test(id) ? id : "";
}
async function loadOwned(formData: FormData) {
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
const id = subscriptionIdFrom(formData);
const subscription = id ? await getOwnedSubscription(customer.id, id) : null;
if (!subscription) redirect("/konto/patenschaft");
return { customer, subscription };
}
/** "Jetzt kuendigen" — zweite Stufe des Kuendigungsknopfs (§ 312k Abs. 2 BGB). */
export async function cancelSubscriptionAction(formData: FormData): Promise<void> {
const { customer, subscription } = await loadOwned(formData);
const base = `/konto/patenschaft/abo/${subscription.id}`;
if (!canCancel(subscription)) redirect(base);
if (!rateLimit(`abo-cancel:${customer.id}`, ACTIONS_PER_WINDOW, WINDOW_MS)) redirect(`${base}?fehler=kuendigung`);
let target = `${base}?gekuendigt=1`;
try {
const result = await cancelSubscription({ subscriptionId: subscription.id, receivedAt: new Date(), sendConfirmation: true });
if (!result.ok) target = `${base}?fehler=kuendigung`;
} catch (error: unknown) {
console.error(`[abo] Kuendigung von Abo ${subscription.id} fehlgeschlagen:`, error);
target = `${base}?fehler=kuendigung`;
}
redirect(target);
}
/** Stripe-Kundenportal, das nur das Aendern der Zahlungsart erlaubt. */
export async function openPaymentPortalAction(formData: FormData): Promise<void> {
const { customer, subscription } = await loadOwned(formData);
const base = `/konto/patenschaft/abo/${subscription.id}`;
if (!subscription.stripeCustomerId || !rateLimit(`abo-portal:${customer.id}`, ACTIONS_PER_WINDOW, WINDOW_MS)) {
redirect(`${base}?fehler=zahlungsart`);
}
let url: string | null = null;
try {
url = await createPaymentMethodPortalUrl({
stripeCustomerId: subscription.stripeCustomerId,
returnUrl: `${getBaseUrl()}${base}`,
});
} catch (error: unknown) {
console.error(`[abo] Kundenportal fuer Abo ${subscription.id} konnte nicht geoeffnet werden:`, error);
}
redirect(url ?? `${base}?fehler=zahlungsart`);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Button, Container, FormPanel, LinkButton, SectionHeading } from "@/components/ui";
import { getCurrentCustomer } from "@/lib/auth";
import { formatDate } from "@/lib/format";
import { canCancel, getOwnedSubscription } from "@/lib/sponsorship-subscription/account";
import { getLocale } from "@/i18n/locale";
import { getAboStrings } from "@/i18n/sponsorship/abo";
import { FactList } from "../_components/abo-blocks";
import { cancelSubscriptionAction } from "../actions";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata(): Promise<Metadata> {
return { title: getAboStrings(await getLocale()).confirm.metaTitle };
}
/**
* Bestaetigungsseite des Kuendigungsknopfs (§ 312k Abs. 2 BGB): fasst Art der
* Kuendigung, Vertrag, Wirksamkeitszeitpunkt und Bestaetigungsweg zusammen;
* erst "Jetzt kuendigen" erklaert die Kuendigung.
*/
export default async function AboCancelConfirmPage({ params }: Props) {
const { id } = await params;
const customer = await getCurrentCustomer();
if (!customer) redirect(`/konto?next=${encodeURIComponent(`/konto/patenschaft/abo/${id}/kuendigen`)}`);
const view = await getOwnedSubscription(customer.id, id);
if (!view) notFound();
if (!canCancel(view)) redirect(`/konto/patenschaft/abo/${view.id}`);
const locale = await getLocale();
const t = getAboStrings(locale);
const effective = view.currentPeriodEnd ? formatDate(view.currentPeriodEnd, locale) : null;
const rows = [
{ label: t.confirm.kindLabel, value: t.confirm.kindValue },
{ label: t.confirm.contractLabel, value: `${view.offerName} · ${view.contractReference}` },
...(effective ? [{ label: t.confirm.effectiveLabel, value: t.confirm.effectiveValue(effective) }] : []),
{ label: t.confirm.confirmationLabel, value: t.confirm.confirmationValue(view.customerEmail) },
];
return (
<div className="py-20">
<Container className="max-w-xl">
<FormPanel>
<SectionHeading title={t.confirm.title} level="h1" />
<p className="mt-6 text-sm leading-relaxed text-ink/75">{t.confirm.intro}</p>
<FactList rows={rows} />
<form action={cancelSubscriptionAction} className="mt-8 flex flex-wrap gap-3">
<input type="hidden" name="subscriptionId" value={view.id} />
<Button type="submit">{t.confirm.submit}</Button>
<LinkButton href={`/konto/patenschaft/abo/${view.id}`} variant="secondary">
{t.confirm.back}
</LinkButton>
</form>
</FormPanel>
</Container>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Button, Container, LinkButton, SectionHeading } from "@/components/ui";
import { BackLink } from "@/components/back-link";
import { getCurrentCustomer } from "@/lib/auth";
import { formatDate, formatPrice } from "@/lib/format";
import { canCancel, getOwnedSubscription, type OwnedSubscriptionView } from "@/lib/sponsorship-subscription/account";
import { getLocale } from "@/i18n/locale";
import { getAboStrings, type AboStrings } from "@/i18n/sponsorship/abo";
import type { Locale } from "@/i18n/config";
import { AboSection, DeliveryProgress, FactList, Notice } from "./_components/abo-blocks";
import { openPaymentPortalAction } from "./actions";
export const dynamic = "force-dynamic";
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata(): Promise<Metadata> {
return { title: getAboStrings(await getLocale()).detail.metaTitle };
}
function facts(view: OwnedSubscriptionView, locale: Locale, t: AboStrings): { label: string; value: string }[] {
const rows = [
{ label: t.detail.contractNumber, value: view.contractReference },
{ label: t.detail.statusLabel, value: t.status[view.status] },
{ label: t.detail.monthlyPrice, value: formatPrice(view.monthlyCents, "EUR", locale) },
{ label: t.detail.paidMonths, value: String(view.paidMonths) },
];
const end = view.cancelAt ?? view.endedAt;
if (end) rows.splice(2, 0, { label: t.detail.endsOn, value: formatDate(end, locale) });
else if (view.currentPeriodEnd && view.status !== "BEENDET") {
rows.splice(2, 0, { label: t.detail.nextCharge, value: formatDate(view.currentPeriodEnd, locale) });
}
return rows;
}
function Deliveries({ view, locale, t }: { view: OwnedSubscriptionView; locale: Locale; t: AboStrings }) {
if (view.deliveries.length === 0) return <p className="mt-6 text-sm text-ink/70">{t.detail.deliveriesEmpty}</p>;
return (
<ul className="mt-6 divide-y divide-ink/10 border-y border-ink/10">
{view.deliveries.map((delivery) => (
<li key={delivery.id} className="py-3.5 text-sm text-heading tabular-nums">
{delivery.status === "ANGELEGT"
? t.detail.deliveryPrepared(formatDate(delivery.dueAt, locale), delivery.jars)
: t.detail.deliveryDeferred(formatDate(delivery.dueAt, locale))}
</li>
))}
</ul>
);
}
function StatusNotice({ view, locale, t, query }: { view: OwnedSubscriptionView; locale: Locale; t: AboStrings; query: Record<string, string | string[] | undefined> }) {
if (query.fehler === "kuendigung") return <Notice tone="error">{t.detail.cancelFailed}</Notice>;
if (query.fehler === "zahlungsart") return <Notice tone="error">{t.detail.paymentUnavailable}</Notice>;
if (query.gekuendigt === "1" && view.cancelAt) return <Notice tone="info">{t.detail.cancelDone(formatDate(view.cancelAt, locale))}</Notice>;
if (view.status === "IM_VERZUG") return <Notice tone="error">{t.detail.delinquentNote}</Notice>;
return null;
}
export default async function AboDetailPage({ params, searchParams }: Props) {
const { id } = await params;
const customer = await getCurrentCustomer();
if (!customer) redirect(`/konto?next=${encodeURIComponent(`/konto/patenschaft/abo/${id}`)}`);
const view = await getOwnedSubscription(customer.id, id);
if (!view) notFound();
const locale = await getLocale();
const t = getAboStrings(locale);
const query = await searchParams;
const progressLabel =
view.progress && (view.cycle === "ERNTE" ? t.detail.progressHarvest : t.detail.progress)(view.progress.paid, view.progress.required);
const running = view.status !== "BEENDET" && view.status !== "AUSSTEHEND";
return (
<div className="py-20">
<Container className="max-w-3xl">
<BackLink href="/konto/patenschaft" label={t.detail.back} />
<SectionHeading title={t.detail.title(view.offerName)} level="h1" subtitle={view.beeName ? t.detail.beeLine(view.beeName) : undefined} />
<StatusNotice view={view} locale={locale} t={t} query={query} />
<FactList rows={facts(view, locale, t)} />
{view.sponsorshipId ? (
<Link href={`/konto/patenschaft/${view.sponsorshipId}`} className="link-draw mt-5 inline-block pb-0.5 text-sm text-heading">
{t.detail.certificateLink}
</Link>
) : null}
{view.progress && progressLabel && running ? (
<AboSection id="lieferung" title={t.detail.progressTitle}>
<DeliveryProgress paid={view.progress.paid} required={view.progress.required} label={progressLabel} />
{view.heldPayments > 0 ? <p className="mt-3 text-xs leading-relaxed text-ink/65">{t.detail.sepaHoldNote}</p> : null}
</AboSection>
) : null}
{view.progress ? (
<AboSection id="lieferungen" title={t.detail.deliveriesTitle}>
<Deliveries view={view} locale={locale} t={t} />
</AboSection>
) : null}
{running && view.stripeCustomerId ? (
<AboSection id="zahlungsart" title={t.detail.paymentTitle}>
<p className="mt-3 text-sm text-ink/70">{t.detail.paymentText}</p>
<form action={openPaymentPortalAction} className="mt-5">
<input type="hidden" name="subscriptionId" value={view.id} />
<Button type="submit" variant="secondary">
{t.detail.paymentButton}
</Button>
</form>
</AboSection>
) : null}
<AboSection id="kuendigen" title={t.detail.cancelTitle}>
{canCancel(view) ? (
<>
<p className="mt-3 text-sm text-ink/70">{t.detail.cancelText}</p>
{/* § 312k Abs. 2 BGB: eindeutig beschrifteter Knopf, der direkt zur Bestaetigungsseite fuehrt. */}
<LinkButton href={`/konto/patenschaft/abo/${view.id}/kuendigen`} className="mt-5">
{t.detail.cancelButton}
</LinkButton>
</>
) : view.status === "BEENDET" && view.endedAt ? (
<p className="mt-3 text-sm text-ink/70">{t.detail.endedNote(formatDate(view.endedAt, locale))}</p>
) : view.cancelAt ? (
<p className="mt-3 text-sm text-ink/70">{t.detail.cancelledNote(formatDate(view.cancelAt, locale))}</p>
) : null}
</AboSection>
</Container>
</div>
);
}
@@ -0,0 +1,52 @@
import { renderMedalSvg } from "@/lib/medals";
import { getCurrentCustomer } from "@/lib/auth";
import { getCustomerLoyalty } from "@/lib/loyalty";
import { getLocale } from "@/i18n/locale";
import { allowRender } from "../../_lib/owner";
import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http";
import {
cachedMedalPng,
loyaltyMedalInput,
parseMedalSize,
renderAndCacheMedalPng,
type LoyaltyMedalKind,
} from "../../_lib/medal";
// Feste Liste statt freier Dateinamen: /konto/patenschaft/medaille/stammkunde.png usw.
const FILES: Record<string, { kind: LoyaltyMedalKind; format: "png" | "svg" }> = {
"stammkunde.png": { kind: "stammkunde", format: "png" },
"stammkunde.svg": { kind: "stammkunde", format: "svg" },
"abo.png": { kind: "abo", format: "png" },
"abo.svg": { kind: "abo", format: "svg" },
};
/**
* Treue-Medaillen des angemeldeten Kontos (Stammkunden-Stufe, Abo-Stufe oder
* VIP). Die Stufe wird bei jedem Aufruf aus den Daten berechnet; ohne
* erreichte Stufe gibt es keine Medaille (404).
*/
export async function GET(request: Request, { params }: { params: Promise<{ datei: string }> }) {
const { datei } = await params;
const file = Object.hasOwn(FILES, datei) ? FILES[datei] : undefined;
const customer = await getCurrentCustomer();
if (!file || !customer) return notFoundResponse();
const loyalty = await getCustomerLoyalty(customer.id, await getLocale());
const input = loyaltyMedalInput(loyalty, file.kind);
if (!input) return notFoundResponse();
const stem = file.kind === "stammkunde" ? "Medaille-Stammkunde" : input.kind === "VIP" ? "Medaille-VIP" : "Medaille-Abo";
if (file.format === "svg") {
return privateFileResponse(renderMedalSvg(input), {
contentType: "image/svg+xml; charset=utf-8",
downloadName: `${stem}.svg`,
});
}
const size = parseMedalSize(new URL(request.url).searchParams.get("groesse"));
let png = cachedMedalPng(input, size);
if (!png) {
if (!allowRender(customer.id)) return tooManyRequestsResponse();
png = await renderAndCacheMedalPng(input, size);
}
return privateFileResponse(png, { contentType: "image/png", downloadName: size === 2048 ? `${stem}.png` : undefined });
}
+79
View File
@@ -0,0 +1,79 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { BackLink } from "@/components/back-link";
import { AccountEmptyState } from "@/components/account-ui";
import { getCurrentCustomer } from "@/lib/auth";
import { listOwnedSponsorships } from "@/lib/sponsorship/access";
import { hasActiveOffers } from "@/lib/sponsorship/offers";
import { getCustomerLoyalty } from "@/lib/loyalty";
import { getLocale } from "@/i18n/locale";
import { getAccountSponsorshipStrings } from "@/i18n/sponsorship/account";
import { MedalShelf } from "./_components/medal-shelf";
import { SponsorshipRow } from "./_components/sponsorship-row";
import { PendingRefresher } from "./_components/pending-refresher";
import { BeeSealIcon } from "./_components/icons";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: getAccountSponsorshipStrings(await getLocale()).overview.metaTitle };
}
export default async function KontoPatenschaftPage() {
const locale = await getLocale();
const t = getAccountSponsorshipStrings(locale);
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
const [sponsorships, loyalty] = await Promise.all([
listOwnedSponsorships(customer.id, locale),
getCustomerLoyalty(customer.id, locale),
]);
const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null);
return (
<div className="py-20">
<Container className="max-w-4xl">
<BackLink href="/konto" label={t.overview.back} />
<SectionHeading level="h1" title={t.overview.title} subtitle={t.overview.subtitle} />
{sponsorships.length === 0 ? (
<AccountEmptyState
icon={<BeeSealIcon />}
title={t.overview.emptyTitle}
description={t.overview.emptyDescription}
action={(await hasActiveOffers()) ? <LinkButton href="/patenschaft">{t.overview.emptyAction}</LinkButton> : undefined}
/>
) : null}
{sponsorships.length > 0 ? (
<section aria-labelledby="sponsorship-list-heading" className="mt-12">
<h2 id="sponsorship-list-heading" className="sr-only">
{t.overview.listTitle}
</h2>
{hasPending ? (
<p role="status" className="mb-5 flex items-start gap-3 border border-honey/30 bg-honey/5 px-4 py-3 text-sm text-ink/75">
<span aria-hidden="true" className="motion-pulse mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-honey animate-[wh-pulse_2s_ease-out_infinite]" />
<span>
<span className="text-heading">{t.overview.pendingTitle}.</span> {t.overview.pendingHint}
</span>
</p>
) : null}
<ul className="space-y-5">
{sponsorships.map((sponsorship, index) => (
<SponsorshipRow key={sponsorship.id} sponsorship={sponsorship} locale={locale} t={t} index={index} />
))}
</ul>
</section>
) : null}
{sponsorships.length > 0 || loyalty.regular.level || loyalty.subscription?.level ? (
<MedalShelf sponsorships={sponsorships} loyalty={loyalty} t={t} />
) : null}
{hasPending ? <PendingRefresher /> : null}
</Container>
</div>
);
}
+12 -2
View File
@@ -15,6 +15,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { I18nProvider } from "@/i18n/i18n-provider";
import { getBrandAssets } from "@/lib/brand";
import { hasActiveOffers } from "@/lib/sponsorship/offers";
import { cookies } from "next/headers";
import { THEME_COOKIE, parseThemeChoice } from "@/lib/theme";
@@ -53,11 +54,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
// Gewaehlte Darstellung schon serverseitig setzen — sonst blitzte die Seite
// beim Laden hell auf, bevor ein Skript auf dunkel umschalten koennte.
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
const [company, socialLinks, flash, brand] = await Promise.all([
const [company, socialLinks, flash, brand, showSponsorship] = await Promise.all([
getCompanyInfo(),
getSocialLinks(),
readFlash(),
getBrandAssets(),
// Navigationslink zur Bienenpatenschaft nur bei aktivem Angebot. Ein
// Datenbankfehler hier soll nicht jede Seite reissen — dann eben ohne Link.
hasActiveOffers().catch(() => false),
]);
const base = getSiteUrl();
@@ -148,13 +152,19 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
logoSrc={brand.logo}
logoInvertedSrc={brand.logoInverted}
themeChoice={themeChoice}
showSponsorship={showSponsorship}
/>
</HideOnAdmin>
<main id="main-content" className="flex-1">
{children}
</main>
<HideOnAdmin>
<Footer companyInfo={company} socialLinks={socialLinks} logoSrc={brand.logoInverted} />
<Footer
companyInfo={company}
socialLinks={socialLinks}
logoSrc={brand.logoInverted}
showSponsorship={showSponsorship}
/>
</HideOnAdmin>
<CartDrawer />
<CookieConsentBanner />
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { loadStripe } from "@stripe/stripe-js/pure";
import type { Stripe } from "@stripe/stripe-js";
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";
/**
* Eingebettete Stripe-Zahlung fuer ein Abo. Anders als die Kasse
* (checkout/embedded-payment.tsx) gibt es hier keine Bestellung mit
* reserviertem Bestand, die beim Zurueckgehen freigegeben werden muesste —
* "Zurueck" schliesst nur die Sitzung im Browser; Stripe laesst die offene
* Abo-Sitzung von selbst verfallen.
*
* Erst geladen, wenn wirklich ein Abo abgeschlossen wird (dynamischer
* Import im Konfigurator), damit Stripe.js nicht jede Seitenansicht belastet.
*/
const stripeByKey = new Map<string, Promise<Stripe | null>>();
function getStripe(publishableKey: string): Promise<Stripe | null> {
const cached = stripeByKey.get(publishableKey);
if (cached) return cached;
const pending = loadStripe(publishableKey);
stripeByKey.set(publishableKey, pending);
pending.catch(() => stripeByKey.delete(publishableKey));
return pending;
}
type LoadState = { status: "loading" } | { status: "ready"; stripe: Stripe } | { status: "failed" };
export interface AboPaymentProps {
publishableKey: string;
clientSecret: string;
heading: string;
backLabel: string;
loadingLabel: string;
failedLabel: string;
onBack: () => void;
}
export function AboPayment({ publishableKey, clientSecret, heading, backLabel, loadingLabel, failedLabel, onBack }: AboPaymentProps) {
const [load, setLoad] = useState<LoadState>({ status: "loading" });
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, []);
useEffect(() => {
let cancelled = false;
getStripe(publishableKey)
.then((stripe) => {
if (!cancelled) setLoad(stripe ? { status: "ready", stripe } : { status: "failed" });
})
.catch((error: unknown) => {
console.error("[patenschaft] Stripe.js konnte nicht geladen werden:", error);
if (!cancelled) setLoad({ status: "failed" });
});
return () => {
cancelled = true;
};
}, [publishableKey]);
return (
<section aria-labelledby="abo-payment-heading" className="space-y-6">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h3 id="abo-payment-heading" ref={headingRef} tabIndex={-1} style={{ outline: "none" }} className="font-serif text-3xl text-heading">
{heading}
</h3>
<button type="button" onClick={onBack} className="min-h-11 text-sm text-ink/70 underline underline-offset-4 hover:text-heading">
{backLabel}
</button>
</div>
<div className="min-h-[32rem]">
{load.status === "loading" ? (
<p className="text-sm text-ink/70" aria-live="polite">
{loadingLabel}
</p>
) : null}
{load.status === "failed" ? (
<p role="alert" className="text-sm text-red-700">
{failedLabel}
</p>
) : null}
{load.status === "ready" ? (
<EmbeddedCheckoutProvider stripe={load.stripe} options={{ clientSecret }}>
<EmbeddedCheckout id="stripe-abo-checkout" />
</EmbeddedCheckoutProvider>
) : null}
</div>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { Container, LinkButton, SectionHeading } from "@/components/ui";
import { noIndexPrivate } from "@/lib/seo";
import { getCurrentCustomer } from "@/lib/auth";
import { resolveSubscriptionReturn } from "@/lib/sponsorship-subscription/checkout-return";
import { getLocale } from "@/i18n/locale";
import { getAboStrings } from "@/i18n/sponsorship/abo";
// Fragt bei jedem Aufruf den aktuellen Stand bei Stripe ab — nie aus einem Cache.
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: getAboStrings(await getLocale()).thanks.metaTitle, robots: noIndexPrivate };
}
type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
/**
* Rueckkehrseite nach dem Abo-Checkout (return_url in checkout.ts). Ueber
* "abgeschlossen" entscheidet die serverseitige Nachfrage bei Stripe, nicht
* die URL; angelegt wird hier nichts — Abo, Zahlung und Urkunde entstehen
* ausschliesslich ueber den Webhook.
*/
export default async function AboThanksPage({ searchParams }: Props) {
const t = getAboStrings(await getLocale()).thanks;
const { session_id: sessionId } = await searchParams;
const customer = await getCurrentCustomer();
const result =
typeof sessionId === "string" && customer ? await resolveSubscriptionReturn(sessionId, customer.id) : { kind: "notCompleted" as const };
const view =
result.kind === "active"
? { title: t.activeTitle, text: t.activeText }
: result.kind === "processing"
? { title: t.processingTitle, text: t.processingText }
: { title: t.notCompletedTitle, text: t.notCompletedText };
return (
<div className="py-24">
<Container className="max-w-xl text-center">
<SectionHeading title={view.title} align="center" level="h1" className="mx-auto" />
<p role="status" className="mt-6 text-ink/70">
{view.text}
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
{result.kind === "notCompleted" ? (
<LinkButton href="/patenschaft">{t.backToOffer}</LinkButton>
) : (
<>
<LinkButton href={`/konto/patenschaft/abo/${result.subscriptionId}`}>{t.toSubscription}</LinkButton>
<LinkButton href="/konto/patenschaft" variant="secondary">
{t.toAccount}
</LinkButton>
</>
)}
</div>
</Container>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Sprungmarken der Verkaufsseite. Eigene Datei ohne "use client": Server-
* und Client-Komponenten brauchen dieselben Werte, und Exporte einer
* Client-Datei kommen in Server-Komponenten nur als Referenz an, nicht als Text.
*/
export const CONFIGURATOR_ANCHOR = "gestalten";
export const OFFERS_ANCHOR = "pakete";
export const NOTICE_ANCHOR = "hinweis";
+76
View File
@@ -0,0 +1,76 @@
import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { fixedTermFor, toIsoDate, type Personalization } from "@/lib/certificate/schema";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import { PREVIEW_NUMBER, renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
/**
* Beispielurkunde fuer den Kopf der Verkaufsseite und als erste Vorschau im
* Konfigurator — ein echtes Rendering aus derselben Engine statt eines
* Fotos, damit die Seite genau das zeigt, was spaeter gedruckt wird.
*
* Feste Beispieldaten, deshalb oeffentlich zwischenspeicherbar; im Speicher
* gehalten je Sprache und Tag (das Startdatum ist "heute"), damit nicht jeder
* Seitenaufruf sharp anwirft.
*/
const SAMPLE_WIDTH_PX = 1100;
const CACHE_SECONDS = 60 * 60 * 6;
const SAMPLE: Record<Locale, Omit<Personalization, "startDate">> = {
de: {
beeName: "Frieda",
recipientName: "Marie Hoffmann",
giverNames: "Anna und Ben",
dedication: "Für die Frühaufsteherin unter uns,\ndie jeden Morgen schon summt.",
occasion: "GEBURTSTAG",
},
en: {
beeName: "Frieda",
recipientName: "Marie Hoffmann",
giverNames: "Anna and Ben",
dedication: "For the early riser among us,\nwho is already humming every morning.",
occasion: "GEBURTSTAG",
},
};
const cache = new Map<string, Promise<Buffer>>();
function renderSample(locale: Locale, today: string): Promise<Buffer> {
const key = `${locale}:${today}`;
const cached = cache.get(key);
if (cached) return cached;
// Aeltere Tage verwerfen, der Speicher bleibt bei hoechstens zwei Eintraegen.
for (const existing of cache.keys()) {
if (!existing.endsWith(today)) cache.delete(existing);
}
const pending = renderCertificateDocumentImage(
{
personalization: { ...SAMPLE[locale], startDate: today },
locale,
term: fixedTermFor(today),
number: PREVIEW_NUMBER,
templateVersion: CURRENT_TEMPLATE_VERSION,
},
{ widthPx: SAMPLE_WIDTH_PX, watermark: null, format: "webp" }
);
cache.set(key, pending);
pending.catch(() => cache.delete(key));
return pending;
}
export async function GET(request: Request): Promise<Response> {
const requested = new URL(request.url).searchParams.get("lang");
const locale: Locale = isLocale(requested) ? requested : defaultLocale;
try {
const image = await renderSample(locale, toIsoDate(new Date()));
return new Response(new Uint8Array(image), {
headers: {
"Content-Type": "image/webp",
"Cache-Control": `public, max-age=${CACHE_SECONDS}`,
},
});
} catch (error: unknown) {
console.error("[patenschaft/beispiel] Beispielurkunde konnte nicht erzeugt werden:", error);
return new Response(null, { status: 500 });
}
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Die Urkundenvorschau als Papierblatt. Auf dem Telefon steht sie kompakt
* ueber dem jeweiligen Schritt; ein Tipp oeffnet sie in voller Breite (Spec
* "Verkaufsseite", Smartphone). Das Vergroessern laeuft ueber das native
* <dialog>: Fokusfalle, Escape und Hintergrundsperre bringt der Browser mit.
*
* Beim Wechsel auf ein neues Bild blendet das alte weich mit leichter
* Unschaerfe aus — zwei harte Bilder nacheinander wirken wie ein Flackern,
* die Unschaerfe verbindet sie zu einer Bewegung.
*/
interface CertificatePreviewProps {
src: string;
alt: string;
loading: boolean;
loadingLabel: string;
enlargeLabel: string;
closeLabel: string;
note?: string | null;
message?: string | null;
}
export function CertificatePreview({
src,
alt,
loading,
loadingLabel,
enlargeLabel,
closeLabel,
note,
message,
}: CertificatePreviewProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Schliessen per Klick auf den abgedunkelten Rand (das Dialog-Element selbst).
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function onClick(event: MouseEvent) {
if (event.target === dialog) dialog?.close();
}
dialog.addEventListener("click", onClick);
return () => dialog.removeEventListener("click", onClick);
}, []);
return (
<figure className="m-0">
<button
type="button"
onClick={() => dialogRef.current?.showModal()}
aria-label={enlargeLabel}
className="group relative mx-auto block w-auto cursor-zoom-in touch-manipulation select-none transition-transform duration-150 ease-out active:scale-[0.985]"
>
<span
aria-hidden="true"
className="absolute inset-0 translate-x-2.5 translate-y-2.5 border border-honey/50 transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-3.5 group-hover:translate-y-3.5"
/>
{/* Rohes <img>: Quelle ist eine Object-URL (blob:) oder die bereits
passend skalierte Beispielurkunde — der Next-Optimierer hilft hier nicht. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
width={900}
height={1273}
className={`relative block aspect-[595/842] h-auto max-h-[30svh] w-auto bg-[#faf8f3] shadow-[0_30px_60px_-30px_rgba(36,53,43,0.55),0_2px_6px_-2px_rgba(36,53,43,0.2)] transition-[filter,opacity] duration-300 ease-out lg:max-h-none lg:w-full motion-reduce:transition-none ${
loading ? "opacity-80 blur-[1.5px]" : "opacity-100 blur-0"
}`}
/>
<span
aria-hidden="true"
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-forest/85 text-cream shadow-lg backdrop-blur-sm lg:opacity-0 lg:transition-opacity lg:duration-200 lg:group-hover:opacity-100 lg:group-focus-visible:opacity-100"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
{loading ? (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-0.5 overflow-hidden">
<span className="block h-full w-1/3 animate-[wh-preview-load_1.1s_ease-in-out_infinite] bg-honey motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-60" />
</span>
) : null}
</button>
<figcaption className="mt-5 space-y-1 text-center text-xs leading-relaxed text-ink/60" aria-live="polite">
{loading ? <span className="sr-only">{loadingLabel}</span> : null}
{message ? <span className="block text-red-700">{message}</span> : note ? <span className="block">{note}</span> : null}
</figcaption>
<dialog
ref={dialogRef}
aria-label={alt}
className="m-auto max-h-[100dvh] max-w-[min(100vw,56rem)] overflow-auto bg-transparent p-0 backdrop:bg-forest/70 backdrop:backdrop-blur-sm open:animate-[wh-preview-open_220ms_var(--ease-arrive)] motion-reduce:open:animate-none"
>
<div className="relative px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-[max(1rem,env(safe-area-inset-top))]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt="" className="block h-auto w-full bg-[#faf8f3] shadow-2xl" width={900} height={1273} />
<form method="dialog" className="absolute right-6 top-6">
<button
type="submit"
aria-label={closeLabel}
autoFocus
className="flex h-11 w-11 items-center justify-center rounded-full bg-forest text-cream shadow-lg transition-transform duration-150 active:scale-95"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</form>
</div>
</dialog>
<style>{`
@keyframes wh-preview-load { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
@keyframes wh-preview-open { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
`}</style>
</figure>
);
}
+281
View File
@@ -0,0 +1,281 @@
"use client";
import { CERTIFICATE_LIMITS } from "@/lib/certificate/schema";
import type { SponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import { formatPrice } from "@/lib/format";
import type { Locale } from "@/i18n/config";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import type { DraftFields, GiftOccasion, OfferKind } from "./draft-store";
import type { DraftField } from "./draft-validation";
import { ChoiceOption, FieldError, TextField } from "./form-fields";
import { KindToggle } from "./kind-toggle";
/**
* Die Schritte 1–4 des Konfigurators (der Pruefschritt liegt in
* review-step.tsx). Reine Darstellung: Zustand und Pruefung kommen von
* configurator.tsx.
*/
export interface StepProps {
strings: SponsorshipShopStrings;
draft: DraftFields;
setField: <K extends keyof DraftFields>(key: K, value: DraftFields[K]) => void;
errorFor: (field: DraftField) => string | null;
touch: (field: DraftField) => void;
}
const GIFT_OCCASIONS: GiftOccasion[] = ["GEBURTSTAG", "WEIHNACHTEN", "DANKESCHOEN", "GESCHENK"];
export function OfferStep({
strings,
offers,
kind,
hasAbo,
selectedSlug,
onKind,
onSelect,
locale,
error,
}: {
strings: SponsorshipShopStrings;
offers: SponsorshipOfferView[];
kind: OfferKind;
hasAbo: boolean;
selectedSlug: string | null;
onKind: (kind: OfferKind) => void;
onSelect: (offer: SponsorshipOfferView) => void;
locale: Locale;
error: string | null;
}) {
return (
<div className="space-y-6">
{hasAbo ? (
<KindToggle
value={kind}
onChange={onKind}
labels={{ EINMALIG: strings.offers.once, ABO: strings.offers.abo }}
ariaLabel={strings.offers.toggleLabel}
/>
) : null}
<fieldset>
<legend className="sr-only">{strings.configurator.titles.offer}</legend>
<div className="space-y-3">
{offers.map((offer) => (
<ChoiceOption
key={offer.slug}
name="sponsorship-offer"
value={offer.slug}
checked={selectedSlug === offer.slug}
disabled={!offer.isAvailable}
onSelect={() => onSelect(offer)}
className="py-4"
>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 pr-2">
<span className="min-w-0">
<span className="block font-serif text-2xl leading-tight text-heading">{offer.name}</span>
<span className="block text-xs text-ink/60">
{!offer.isAvailable
? strings.offers.soldOut
: offer.kind === "ABO"
? strings.offers.shippingIncluded
: offer.honeyJars > 0 || offer.isPhysical
? strings.offers.shippingExtra
: strings.offers.noShipping}
</span>
</span>
<span className="whitespace-nowrap text-sm tabular-nums text-heading">
{formatPrice(offer.priceCents, "EUR", locale)}
<span className="ml-1 text-xs text-ink/55">
{offer.kind === "ABO" ? strings.offers.perMonth : strings.offers.priceOnce}
</span>
</span>
</span>
</ChoiceOption>
))}
</div>
</fieldset>
<FieldError message={error} />
</div>
);
}
export function WhomStep({ strings, draft, setField, errorFor }: StepProps) {
const c = strings.configurator;
return (
<div className="space-y-8">
<fieldset>
<legend className="sr-only">{c.titles.whom}</legend>
<div className="grid gap-3 sm:grid-cols-2">
<ChoiceOption name="audience" value="GIFT" checked={draft.audience === "GIFT"} onSelect={() => setField("audience", "GIFT")} className="py-4">
<GiftIcon />
<span className="font-serif text-xl text-heading">{c.whomGift}</span>
</ChoiceOption>
<ChoiceOption name="audience" value="SELF" checked={draft.audience === "SELF"} onSelect={() => setField("audience", "SELF")} className="py-4">
<SelfIcon />
<span className="font-serif text-xl text-heading">{c.whomSelf}</span>
</ChoiceOption>
</div>
</fieldset>
{draft.audience === "GIFT" ? (
<fieldset>
<legend className="text-sm text-heading">{c.occasionLabel}</legend>
<div className="mt-3 flex flex-wrap gap-2.5">
{GIFT_OCCASIONS.map((occasion) => {
const checked = draft.occasion === occasion;
return (
<label
key={occasion}
className={`inline-flex min-h-11 cursor-pointer select-none items-center rounded-full border px-5 text-sm transition-[border-color,background-color,color,transform] duration-200 active:scale-[0.97] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark ${
checked ? "border-forest bg-forest text-cream" : "border-ink/20 bg-background text-ink/80 hover:border-heading"
}`}
>
<input
type="radio"
name="occasion"
value={occasion}
checked={checked}
onChange={() => setField("occasion", occasion)}
className="sr-only"
/>
{c.occasions[occasion]}
</label>
);
})}
</div>
<FieldError message={errorFor("occasion")} />
</fieldset>
) : null}
</div>
);
}
export function NamesStep({ strings, draft, setField, errorFor, touch }: StepProps) {
const c = strings.configurator;
const isGift = draft.audience === "GIFT";
return (
<div className="space-y-7">
<TextField
label={isGift ? c.recipientGift : c.recipientSelf}
value={draft.recipientName}
onChange={(value) => setField("recipientName", value)}
onBlur={() => touch("recipientName")}
hint={c.recipientHint}
error={errorFor("recipientName")}
max={CERTIFICATE_LIMITS.recipientName.max}
counterLabel={c.counter}
autoComplete={isGift ? "off" : "name"}
enterKeyHint="next"
/>
<TextField
label={c.beeName}
value={draft.beeName}
onChange={(value) => setField("beeName", value)}
onBlur={() => touch("beeName")}
hint={c.beeNameHint}
error={errorFor("beeName")}
max={CERTIFICATE_LIMITS.beeName.max}
counterLabel={c.counter}
enterKeyHint={isGift ? "next" : "done"}
/>
{isGift ? (
<TextField
label={c.givers}
optionalLabel={c.optional}
value={draft.giverNames}
onChange={(value) => setField("giverNames", value)}
onBlur={() => touch("giverNames")}
hint={c.giversHint}
error={errorFor("giverNames")}
max={CERTIFICATE_LIMITS.giverNames.max}
counterLabel={c.counter}
enterKeyHint="done"
/>
) : null}
</div>
);
}
export function DedicationStep({
strings,
draft,
setField,
errorFor,
touch,
isSubscription,
dateWindow,
startDate,
}: StepProps & { isSubscription: boolean; dateWindow: { min: string; max: string }; startDate: string }) {
const c = strings.configurator;
return (
<div className="space-y-7">
<TextField
label={c.dedication}
optionalLabel={c.optional}
value={draft.dedication}
onChange={(value) => setField("dedication", value)}
onBlur={() => touch("dedication")}
hint={c.dedicationHint}
placeholder={c.dedicationPlaceholder}
error={errorFor("dedication")}
max={CERTIFICATE_LIMITS.dedication.max}
counterLabel={c.counter}
multiline
rows={5}
/>
{isSubscription ? (
<div className="border-t border-ink/10 pt-6">
<p className="text-sm text-heading">{c.startDate}</p>
<p className="mt-2 text-sm leading-relaxed text-ink/70">{c.startDateAbo}</p>
</div>
) : (
<div className="border-t border-ink/10 pt-6">
<label htmlFor="sponsorship-start" className="text-sm text-heading">
{c.startDate}
</label>
<input
id="sponsorship-start"
type="date"
value={startDate}
min={dateWindow.min}
max={dateWindow.max}
required
onChange={(event) => setField("startDate", event.target.value)}
onBlur={() => touch("startDate")}
aria-invalid={errorFor("startDate") ? true : undefined}
aria-describedby="sponsorship-start-hint"
className={`mt-2 block min-h-12 w-full max-w-xs border bg-background px-4 py-3 text-base text-ink focus:border-heading focus:outline-none ${
errorFor("startDate") ? "border-red-700" : "border-ink/20"
}`}
/>
<p id="sponsorship-start-hint" className="mt-2 text-xs leading-relaxed text-ink/60">
{c.startDateHint}
</p>
<FieldError message={errorFor("startDate")} />
</div>
)}
</div>
);
}
function GiftIcon() {
return (
<span aria-hidden="true" className="flex h-10 w-10 flex-shrink-0 items-center justify-center border border-ink/15 text-heading">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3.5" y="9" width="17" height="11" strokeLinejoin="round" />
<path d="M2.5 9h19M12 9v11M12 9c-1.5-3.5-6-4.5-6-1.5S10 9 12 9zm0 0c1.5-3.5 6-4.5 6-1.5S14 9 12 9z" strokeLinejoin="round" />
</svg>
</span>
);
}
function SelfIcon() {
return (
<span aria-hidden="true" className="flex h-10 w-10 flex-shrink-0 items-center justify-center border border-ink/15 text-heading">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" strokeLinecap="round" />
</svg>
</span>
);
}
+480
View File
@@ -0,0 +1,480 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useI18n } from "@/i18n/i18n-provider";
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { useCartStore } from "@/store/cart-store";
import { LinkButton } from "@/components/ui";
import { loginPathWithNext } from "@/lib/safe-next-path";
import { useDraftStore, REVIEW_STEP, STEP_COUNT, type DraftFields, type OfferKind } from "./draft-store";
import { describeFieldError, firstInvalidStep, validateDraft, STEP_FIELDS, type DraftField, type FieldErrors } from "./draft-validation";
import { useCertificatePreview } from "./use-certificate-preview";
import { CertificatePreview } from "./certificate-preview";
import { StepIndicator } from "./step-indicator";
import { DedicationStep, NamesStep, OfferStep, WhomStep } from "./configurator-steps";
import { ReviewStep, type AccountState } from "./review-step";
import { CONFIGURATOR_ANCHOR } from "./anchors";
const AboPayment = dynamic(() => import("./abo-payment").then((module) => module.AboPayment), { ssr: false });
/**
* Konfigurator der Patenschaft in fuenf Schritten (Spec "Verkaufsseite",
* Punkt 3). Entwurf im draft-store, Pruefung mit dem Schema der Urkunde,
* Vorschau ueber /api/patenschaft/vorschau.
*
* Kauf: Einmal-Patenschaften gehen als eigene Position in den Warenkorb
* (Schluessel sp_<uuid>, Menge 1). Abos laufen ueber eine eigene
* Stripe-Sitzung (/api/patenschaft/abo), getrennt vom Warenkorb. Ohne Konto
* fuehren beide Wege zur Anmeldung; der Entwurf bleibt im sessionStorage.
*/
const PURCHASE_RETURN_PATH = "/patenschaft";
interface ConfiguratorProps {
offersOnce: SponsorshipOfferView[];
offersAbo: SponsorshipOfferView[];
dateWindow: { min: string; max: string };
account: AccountState;
stripePublishableKey?: string;
sampleSrc: string;
}
interface AboSession {
clientSecret: string;
}
function isAboSession(value: unknown): value is AboSession {
return Boolean(value) && typeof (value as { clientSecret?: unknown }).clientSecret === "string";
}
export function SponsorshipConfigurator({
offersOnce,
offersAbo,
dateWindow,
account,
stripePublishableKey,
sampleSrc,
}: ConfiguratorProps) {
const { locale } = useI18n();
const strings = getSponsorshipShopStrings(locale);
const c = strings.configurator;
const reduceMotion = useReducedMotion();
const draft = useDraftStore();
const hasHydrated = useDraftStore((s) => s.hasHydrated);
const addSponsorship = useCartStore((s) => s.addSponsorship);
const [touched, setTouched] = useState<Set<DraftField>>(() => new Set());
const [attempted, setAttempted] = useState<Set<number>>(() => new Set());
const [reached, setReached] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [added, setAdded] = useState(false);
const [aboSession, setAboSession] = useState<AboSession | null>(null);
// Neue Schrittueberschrift fokussieren, sobald sie eingeblendet ist (der
// Schrittwechsel wartet auf die Ausblendung des alten Schritts).
const focusStepHeading = useRef(false);
const stepHeadingRef = useCallback((element: HTMLHeadingElement | null) => {
if (element && focusStepHeading.current) {
focusStepHeading.current = false;
element.focus({ preventScroll: true });
}
}, []);
// Ziel beim Schrittwechsel: Vorschau und Schritt, nicht die Abschnittsueberschrift —
// auf dem Telefon soll der neue Schritt ohne Suchen im Blick sein.
const workAreaRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
// Entwurf aus dem sessionStorage erst nach dem ersten Rendern lesen.
useEffect(() => {
void useDraftStore.persist.rehydrate();
}, []);
const hasAbo = offersAbo.length > 0;
const kind: OfferKind = hasAbo ? draft.kind : "EINMALIG";
const isSubscription = kind === "ABO";
const offers = isSubscription ? offersAbo : offersOnce;
const offer = offers.find((candidate) => candidate.slug === draft.offerSlug) ?? null;
// Abo: Beginn ist der Tag der ersten Zahlung, nicht waehlbar. Einmal: ohne
// Eingabe gilt der fruehestmoegliche Tag, das Feld ist damit vorbelegt.
const effectiveDraft: DraftFields = useMemo(
() => ({ ...draft, startDate: isSubscription || !draft.startDate ? dateWindow.min : draft.startDate }),
[draft, isSubscription, dateWindow.min]
);
const validation = validateDraft(effectiveDraft, {
offerAvailable: offer ? offer.isAvailable : null,
window: dateWindow,
isSubscription,
});
const previewRequest = validation.personalization
? { personalization: validation.personalization, locale, term: isSubscription ? ("OPEN" as const) : ("FIXED" as const) }
: null;
const preview = useCertificatePreview(previewRequest);
const previewKey = previewRequest ? JSON.stringify(previewRequest) : null;
const serverError = preview.error && preview.errorKey === previewKey ? preview.error : null;
// Feldfehler der Vorschau (Glyphen, passt nicht aufs Blatt) zu den
// Schema-Fehlern legen — sie blockieren "Weiter" genauso.
const errors: FieldErrors = { ...validation.errors };
const serverField = serverError ? mapServerField(serverError.field) : null;
if (serverError && serverField && !errors[serverField]) {
errors[serverField] = { code: serverError.code, characters: serverError.characters };
}
const step = Math.min(draft.step, REVIEW_STEP);
// Nach der Rueckkehr von der Anmeldung steht der Entwurf auf dem
// Pruefschritt: dorthin scrollen, statt oben auf der Seite zu landen.
useEffect(() => {
if (!hasHydrated || restoredRef.current) return;
restoredRef.current = true;
const restoredStep = useDraftStore.getState().step;
setReached((current) => Math.max(current, restoredStep));
if (restoredStep === REVIEW_STEP) {
workAreaRef.current?.scrollIntoView({ block: "start" });
}
}, [hasHydrated]);
function setField<K extends keyof DraftFields>(key: K, value: DraftFields[K]) {
setActionError(null);
setAdded(false);
draft.setField(key, value);
}
function touch(field: DraftField) {
setTouched((current) => (current.has(field) ? current : new Set(current).add(field)));
}
function errorFor(field: DraftField): string | null {
const error = errors[field];
if (!error) return null;
const fieldStep = STEP_FIELDS.findIndex((fields) => fields.includes(field));
const isServer = serverField === field;
if (!isServer && !touched.has(field) && !attempted.has(fieldStep)) return null;
return describeFieldError(strings, field, error);
}
function goTo(target: number) {
draft.setStep(target);
setReached((current) => Math.max(current, target));
setActionError(null);
// Fokus auf die neue Schrittueberschrift: Screenreader hoeren, wo sie
// sind, und auf dem Telefon rueckt der Schritt in den Blick.
focusStepHeading.current = true;
requestAnimationFrame(() => {
const top = workAreaRef.current?.getBoundingClientRect().top ?? 0;
if (top < 0 || top > window.innerHeight * 0.5) {
workAreaRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
}
});
}
function next() {
setAttempted((current) => new Set(current).add(step));
const invalid = firstInvalidStep(errors, step);
if (invalid !== null) {
if (invalid !== step) goTo(invalid);
return;
}
goTo(step + 1);
}
function selectOffer(selected: SponsorshipOfferView) {
setActionError(null);
draft.selectOffer(selected.slug, selected.kind);
}
function changeKind(nextKind: OfferKind) {
setActionError(null);
draft.setKind(nextKind);
}
function goToLogin() {
// Der Entwurf liegt bereits im sessionStorage (persist schreibt bei jeder
// Aenderung) und wird nach der Rueckkehr wiederhergestellt. Bewusst eine
// volle Navigation: Anmeldung und Rueckkehr laufen ueber Server-Redirects
// mit neuem Session-Cookie, die Seite soll danach frisch gerendert werden.
window.location.assign(loginPathWithNext(PURCHASE_RETURN_PATH));
}
async function submit() {
setAttempted((current) => new Set(current).add(REVIEW_STEP));
const invalid = firstInvalidStep(errors, REVIEW_STEP);
if (invalid !== null) {
if (invalid !== REVIEW_STEP) goTo(invalid);
return;
}
if (!offer || !validation.personalization) return;
if (account === "GUEST") {
goToLogin();
return;
}
if (isSubscription) {
await startSubscription(offer);
return;
}
if (!offer.productId) {
setActionError(strings.errors.generic);
return;
}
addSponsorship(
{ productId: offer.productId, slug: offer.slug, name: offer.name, weightGrams: 0, priceCents: offer.priceCents, image: null },
{
offerSlug: offer.slug,
offerName: offer.name,
isPhysical: offer.isPhysical,
honeyJars: offer.honeyJars,
personalization: validation.personalization,
}
);
setAdded(true);
setTouched(new Set());
setAttempted(new Set());
setReached(0);
// Paket behalten, alles Persoenliche leeren: die naechste Patenschaft
// ist meist fuer jemand anderen.
draft.reset({ kind: offer.kind, offerSlug: offer.slug, step: 0 });
}
async function startSubscription(selected: SponsorshipOfferView) {
if (!stripePublishableKey) {
setActionError(strings.abo.unavailable);
return;
}
setSubmitting(true);
setActionError(null);
try {
const response = await fetch("/api/patenschaft/abo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ offerSlug: selected.slug, personalization: validation.personalization, locale }),
});
const data: unknown = await response.json().catch(() => null);
if (response.status === 401) {
goToLogin();
return;
}
if (!response.ok || !isAboSession(data)) {
const message = data && typeof (data as { error?: unknown }).error === "string" ? (data as { error: string }).error : null;
setActionError(response.status === 404 || !message ? strings.abo.unavailable : message);
return;
}
setAboSession({ clientSecret: data.clientSecret });
} catch {
setActionError(strings.abo.unavailable);
} finally {
setSubmitting(false);
}
}
const previewMessage =
serverError && !serverField
? serverError.code === "rate_limited"
? strings.errors.rateLimited
: serverError.code === "fit"
? strings.errors.fit
: strings.preview.failed
: null;
// Eigene Vorschau erst, wenn der Entwurf Namen traegt — nach dem Hinzufuegen
// (Entwurf geleert) steht wieder die Beispielurkunde da, nicht die letzte.
const showOwnPreview = Boolean(preview.url) && Boolean(draft.beeName.trim() || draft.recipientName.trim());
const stepTitles = [c.titles.offer, c.titles.whom, c.titles.names, c.titles.dedication, c.titles.review];
const stepProps = { strings, draft: effectiveDraft, setField, errorFor, touch };
return (
<div id={CONFIGURATOR_ANCHOR} className="scroll-mt-24">
<div className="max-w-2xl">
<h2 className="text-4xl leading-[1.05] tracking-[-0.01em] text-heading md:text-5xl">{c.heading}</h2>
<p className="mt-4 leading-relaxed text-ink/70">{c.lead}</p>
</div>
<div ref={workAreaRef} className="mt-12 grid scroll-mt-24 gap-8 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<div className="lg:sticky lg:top-28">
<CertificatePreview
src={showOwnPreview ? (preview.url as string) : sampleSrc}
alt={showOwnPreview ? strings.preview.alt : strings.hero.previewAlt}
loading={preview.loading}
loadingLabel={strings.preview.loading}
enlargeLabel={strings.preview.enlarge}
closeLabel={strings.preview.close}
note={showOwnPreview ? null : strings.preview.sampleNote}
message={previewMessage}
/>
</div>
</div>
<div className="min-w-0 lg:col-span-7">
<StepIndicator
labels={c.steps}
current={step}
reached={reached}
onSelect={goTo}
ariaLabel={c.stepsLabel}
stepOf={c.stepOf}
/>
<div className="mt-6 border border-ink/10 bg-background px-5 py-7 shadow-[0_30px_60px_-46px_rgba(36,53,43,0.55)] sm:px-9 sm:py-10">
{aboSession && stripePublishableKey ? (
<AboPayment
publishableKey={stripePublishableKey}
clientSecret={aboSession.clientSecret}
heading={strings.abo.heading}
backLabel={strings.abo.back}
loadingLabel={strings.abo.loading}
failedLabel={strings.abo.loadFailed}
onBack={() => setAboSession(null)}
/>
) : added ? (
<AddedPanel strings={strings} onAnother={() => setAdded(false)} />
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, transform: "translateY(10px)" }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, transform: "translateY(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, transform: "translateY(-6px)" }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
>
<p className="text-xs tracking-wide-caps text-honey-dark" aria-hidden="true">
{c.stepOf(step + 1, STEP_COUNT)}
</p>
<h3
ref={stepHeadingRef}
tabIndex={-1}
// Fokusziel nur fuer Screenreader und Tastatur-Kontext; der
// globale :focus-visible-Ring (globals.css) wuerde die
// Ueberschrift sonst wie ein Eingabefeld umrahmen.
style={{ outline: "none" }}
className="mt-2 font-serif text-3xl leading-tight text-heading sm:text-4xl"
>
{stepTitles[step]}
</h3>
<div className="mt-7">
{step === 0 ? (
<OfferStep
strings={strings}
offers={offers}
kind={kind}
hasAbo={hasAbo}
selectedSlug={draft.offerSlug}
onKind={changeKind}
onSelect={selectOffer}
locale={locale}
error={attempted.has(0) && errors.offer ? describeFieldError(strings, "offer", errors.offer) : null}
/>
) : null}
{step === 1 ? <WhomStep {...stepProps} /> : null}
{step === 2 ? <NamesStep {...stepProps} /> : null}
{step === 3 ? (
<DedicationStep
{...stepProps}
isSubscription={isSubscription}
dateWindow={dateWindow}
startDate={effectiveDraft.startDate}
/>
) : null}
{step === REVIEW_STEP ? (
<ReviewStep
strings={strings}
locale={locale}
offer={offer}
personalization={validation.personalization}
isSubscription={isSubscription}
confirmed={draft.confirmed}
onConfirm={(value) => setField("confirmed", value)}
confirmError={errorFor("confirmed")}
onSubmit={submit}
submitting={submitting}
account={account}
actionError={actionError}
/>
) : null}
</div>
</motion.div>
</AnimatePresence>
)}
</div>
{!aboSession && !added ? (
// Klebt auf dem Telefon am unteren Rand, solange der Konfigurator
// im Blick ist — Daumenreichweite statt Suchen nach dem Knopf.
<div className="sticky bottom-0 z-20 -mx-6 mt-0 flex items-center gap-3 border-t border-ink/10 bg-surface/95 px-6 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur md:static md:mx-0 md:mt-6 md:border-0 md:bg-transparent md:p-0 md:backdrop-blur-none">
{step > 0 ? (
<button
type="button"
onClick={() => goTo(step - 1)}
className="inline-flex min-h-12 select-none items-center justify-center gap-2 border border-ink/20 bg-background px-5 text-sm tracking-wide-caps text-ink transition-[border-color,transform] duration-150 touch-manipulation hover:border-heading active:scale-[0.97]"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M20 12H5M11 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{c.back}
</button>
) : null}
{step < REVIEW_STEP ? (
<button
type="button"
onClick={next}
className="ml-auto inline-flex min-h-12 flex-1 select-none items-center justify-center gap-2 border border-heading bg-forest px-7 text-sm tracking-wide-caps text-cream transition-[background-color,transform,box-shadow] duration-150 touch-manipulation hover:bg-honey-deep hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] md:flex-none"
>
{c.next}
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
);
}
const DRAFT_FIELDS = new Set<string>(["beeName", "recipientName", "giverNames", "dedication", "startDate", "occasion"]);
/** Feld der Vorschau-API auf ein Formularfeld abbilden; "layout" hat keins. */
function mapServerField(field: string | null): DraftField | null {
return field && DRAFT_FIELDS.has(field) ? (field as DraftField) : null;
}
function AddedPanel({
strings,
onAnother,
}: {
strings: ReturnType<typeof getSponsorshipShopStrings>;
onAnother: () => void;
}) {
return (
<div className="flex flex-col items-start gap-6 py-4" role="status">
<svg viewBox="0 0 24 26" className="h-12 w-12 text-honey" aria-hidden="true">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="currentColor" />
<path d="M8 13.2l2.8 2.8 5.2-5.6" stroke="var(--color-cream)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<p className="font-serif text-3xl leading-tight text-heading">
{strings.configurator.added}
</p>
<div className="flex w-full flex-col gap-3 sm:flex-row">
<LinkButton href="/warenkorb" className="min-h-12 whitespace-nowrap">
{strings.configurator.toCart}
</LinkButton>
<button
type="button"
onClick={onAnother}
className="inline-flex min-h-12 items-center justify-center whitespace-nowrap border border-ink/20 px-6 text-sm tracking-wide-caps text-ink transition-[border-color,transform] duration-150 hover:border-heading active:scale-[0.97]"
>
{strings.configurator.addAnother}
</button>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { Occasion, PersonalizationInput } from "@/lib/certificate/schema";
/**
* Entwurf der Patenschaft auf der Verkaufsseite. Paketkarten und
* Konfigurator teilen ihn, deshalb ein kleiner Store statt Props quer durch
* die Seite.
*
* Gespeichert im sessionStorage unter "wabenhain-sponsorship-draft" (Spec
* "Kundenkonto ist Pflicht"): wer ohne Konto auf "In den Warenkorb" tippt,
* landet bei der Anmeldung und findet danach alles so vor, wie er es
* verlassen hat. sessionStorage statt localStorage, weil Namen Dritter und
* eine Widmung nicht laenger im Browser liegen sollen als der Besuch dauert.
*/
export const DRAFT_STORAGE_KEY = "wabenhain-sponsorship-draft";
export const STEP_COUNT = 5;
export const REVIEW_STEP = STEP_COUNT - 1;
export type OfferKind = "EINMALIG" | "ABO";
export type Audience = "SELF" | "GIFT";
export type GiftOccasion = Exclude<Occasion, "SELBST">;
export interface DraftFields {
kind: OfferKind;
offerSlug: string | null;
audience: Audience;
occasion: GiftOccasion | null;
beeName: string;
recipientName: string;
giverNames: string;
dedication: string;
startDate: string;
confirmed: boolean;
step: number;
}
interface DraftState extends DraftFields {
/** true, sobald der Entwurf aus dem sessionStorage gelesen wurde. */
hasHydrated: boolean;
setField: <K extends keyof DraftFields>(key: K, value: DraftFields[K]) => void;
selectOffer: (slug: string, kind: OfferKind) => void;
setKind: (kind: OfferKind) => void;
setStep: (step: number) => void;
reset: (keep?: Partial<DraftFields>) => void;
}
const EMPTY: DraftFields = {
kind: "EINMALIG",
offerSlug: null,
audience: "GIFT",
occasion: null,
beeName: "",
recipientName: "",
giverNames: "",
dedication: "",
startDate: "",
confirmed: false,
step: 0,
};
export const useDraftStore = create<DraftState>()(
persist(
(set) => ({
...EMPTY,
hasHydrated: false,
// Jede inhaltliche Aenderung hebt die Bestaetigung "Vorschau geprueft"
// auf: geprueft wurde ein anderer Stand.
setField: (key, value) =>
set({ [key]: value, confirmed: key === "confirmed" ? Boolean(value) : false } as Partial<DraftFields>),
selectOffer: (slug, kind) => set({ offerSlug: slug, kind, confirmed: false }),
setKind: (kind) => set((state) => (state.kind === kind ? state : { kind, offerSlug: null, confirmed: false })),
setStep: (step) => set({ step: Math.max(0, Math.min(REVIEW_STEP, step)) }),
reset: (keep) => set({ ...EMPTY, ...keep }),
}),
{
name: DRAFT_STORAGE_KEY,
version: 1,
storage: createJSONStorage(() => sessionStorage),
// Erst nach dem ersten Rendern lesen (DraftHydrator), sonst wiche das
// erste Client-Rendern vom Server-HTML ab.
skipHydration: true,
partialize: (state): DraftFields => ({
kind: state.kind,
offerSlug: state.offerSlug,
audience: state.audience,
occasion: state.occasion,
beeName: state.beeName,
recipientName: state.recipientName,
giverNames: state.giverNames,
dedication: state.dedication,
startDate: state.startDate,
confirmed: state.confirmed,
step: state.step,
}),
onRehydrateStorage: () => () => {
useDraftStore.setState({ hasHydrated: true });
},
}
)
);
/** Rohe Personalisierung aus dem Entwurf; das Schema prueft und normalisiert sie. */
export type DraftPersonalization = Omit<PersonalizationInput, "occasion"> & { occasion?: Occasion };
export function draftToPersonalization(draft: DraftFields): DraftPersonalization {
const isSelf = draft.audience === "SELF";
return {
beeName: draft.beeName,
recipientName: draft.recipientName,
giverNames: isSelf ? undefined : draft.giverNames,
dedication: draft.dedication,
// Ohne gewaehlten Anlass bleibt das Feld leer — das Schema meldet dann
// "required", statt still einen Anlass zu unterstellen.
occasion: isSelf ? "SELBST" : (draft.occasion ?? undefined),
startDate: draft.startDate,
};
}
+119
View File
@@ -0,0 +1,119 @@
import {
CERTIFICATE_LIMITS,
personalizationSchema,
type CertificateErrorCode,
type Personalization,
} from "@/lib/certificate/schema";
import type { PreviewErrorCode, SponsorshipShopStrings } from "@/i18n/sponsorship/shop";
import { describeCertificateError } from "@/i18n/sponsorship/shop";
import { draftToPersonalization, type DraftFields } from "./draft-store";
/**
* Pruefung des Entwurfs im Browser — mit demselben zod-Schema wie Server und
* Checkout, damit ein Feld, das hier als gueltig gilt, dort nicht scheitert.
* Was nur der Server kann (Glyphen in den Schriften, passt es aufs Blatt),
* meldet die Vorschau-API; ihre Fehler werden im Konfigurator dazugelegt.
*/
export type DraftField =
| "offer"
| "occasion"
| "recipientName"
| "beeName"
| "giverNames"
| "dedication"
| "startDate"
| "confirmed";
export interface FieldError {
code: PreviewErrorCode | "offer_missing" | "offer_sold_out" | "confirm_missing";
characters?: string[];
}
export type FieldErrors = Partial<Record<DraftField, FieldError>>;
/** Welche Felder zu welchem Schritt gehoeren (Paket, Fuer wen, Namen, Widmung, Pruefen). */
export const STEP_FIELDS: readonly (readonly DraftField[])[] = [
["offer"],
["occasion"],
["recipientName", "beeName", "giverNames"],
["dedication", "startDate"],
["confirmed"],
];
export interface DraftValidationContext {
/** Ist das gewaehlte Paket bestellbar? null = kein Paket gewaehlt oder unbekannt. */
offerAvailable: boolean | null;
window: { min: string; max: string };
isSubscription: boolean;
}
export interface DraftValidation {
errors: FieldErrors;
/** Normalisierte Personalisierung, sobald das Schema sie annimmt. */
personalization: Personalization | null;
}
const SCHEMA_FIELDS = new Set<DraftField>(["occasion", "recipientName", "beeName", "giverNames", "dedication", "startDate"]);
export function validateDraft(draft: DraftFields, context: DraftValidationContext): DraftValidation {
const errors: FieldErrors = {};
if (context.offerAvailable === null) errors.offer = { code: "offer_missing" };
else if (!context.offerAvailable) errors.offer = { code: "offer_sold_out" };
const parsed = personalizationSchema.safeParse(draftToPersonalization(draft));
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const field = issue.path[0];
if (typeof field !== "string" || !SCHEMA_FIELDS.has(field as DraftField)) continue;
const key = field as DraftField;
if (!errors[key]) errors[key] = { code: issue.message as CertificateErrorCode };
}
}
const personalization = parsed.success ? parsed.data : null;
if (personalization && !context.isSubscription) {
const { startDate } = personalization;
if (startDate < context.window.min || startDate > context.window.max) {
errors.startDate = { code: "date_range" };
}
}
if (!draft.confirmed) errors.confirmed = { code: "confirm_missing" };
const hasPersonalizationError = [...SCHEMA_FIELDS].some((field) => errors[field]);
return { errors, personalization: hasPersonalizationError ? null : personalization };
}
/** Erster Schritt mit einem Fehler bis einschliesslich `upTo`, sonst null. */
export function firstInvalidStep(errors: FieldErrors, upTo: number): number | null {
for (let step = 0; step <= upTo; step++) {
if (STEP_FIELDS[step]?.some((field) => errors[field])) return step;
}
return null;
}
const FIELD_LIMITS: Partial<Record<DraftField, { min?: number; max?: number }>> = {
beeName: CERTIFICATE_LIMITS.beeName,
recipientName: CERTIFICATE_LIMITS.recipientName,
giverNames: CERTIFICATE_LIMITS.giverNames,
dedication: { max: CERTIFICATE_LIMITS.dedication.max },
};
/** Fehler als Satz in der Seitensprache, mit den Grenzen des jeweiligen Felds. */
export function describeFieldError(strings: SponsorshipShopStrings, field: DraftField, error: FieldError): string {
switch (error.code) {
case "offer_missing":
return strings.configurator.titles.offer;
case "offer_sold_out":
return strings.configurator.soldOut;
case "confirm_missing":
return strings.configurator.confirmMissing;
case "required":
return field === "occasion" ? strings.errors.chooseOccasion : strings.errors.required;
case "too_many_line_breaks":
return describeCertificateError(strings, error.code, { max: CERTIFICATE_LIMITS.dedication.maxLineBreaks });
default:
return describeCertificateError(strings, error.code, { ...FIELD_LIMITS[field], characters: error.characters });
}
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useId, type HTMLAttributes, type ReactNode } from "react";
import { countCharacters } from "@/lib/certificate/schema";
/**
* Eingabefelder des Konfigurators. Mindestens 16 px Schrift (sonst zoomt iOS
* beim Fokus in die Seite) und 48 px Hoehe als Tippziel. Der Zeichenzaehler
* zaehlt wie das Schema Codepunkte, nicht UTF-16-Einheiten — "Zoë" ist drei
* Zeichen, hier wie auf dem Server.
*/
const NEAR_LIMIT_RATIO = 0.9;
interface TextFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
hint?: string;
error?: string | null;
/** Hoechstzahl an Zeichen fuer den Zaehler; ohne Angabe kein Zaehler. */
max?: number;
counterLabel?: (count: number, max: number) => string;
optionalLabel?: string;
multiline?: boolean;
rows?: number;
placeholder?: string;
autoComplete?: string;
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
enterKeyHint?: HTMLAttributes<HTMLInputElement>["enterKeyHint"];
}
export function TextField({
label,
value,
onChange,
onBlur,
hint,
error,
max,
counterLabel,
optionalLabel,
multiline = false,
rows = 4,
placeholder,
autoComplete = "off",
inputMode,
enterKeyHint,
}: TextFieldProps) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const counterId = `${id}-counter`;
const count = countCharacters(value);
const describedBy = [hint ? hintId : null, max ? counterId : null, error ? errorId : null].filter(Boolean).join(" ");
const counterTone =
max && count > max ? "text-red-700" : max && count >= max * NEAR_LIMIT_RATIO ? "text-honey-dark" : "text-ink/50";
const fieldClass = `block w-full border bg-background px-4 py-3 text-base leading-snug text-ink transition-[border-color,box-shadow] duration-200 placeholder:text-ink/40 focus:outline-none focus-visible:outline-none focus:border-heading focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-honey)_28%,transparent)] ${
error ? "border-red-700" : "border-ink/20"
}`;
return (
<div>
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={id} className="text-sm text-heading">
{label}
{optionalLabel ? <span className="ml-1.5 text-xs text-ink/55">({optionalLabel})</span> : null}
</label>
{max ? (
<span id={counterId} className={`text-xs tabular-nums transition-colors ${counterTone}`} aria-live="off">
{counterLabel ? counterLabel(count, max) : `${count}/${max}`}
</span>
) : null}
</div>
{multiline ? (
<textarea
id={id}
value={value}
rows={rows}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy || undefined}
className={`${fieldClass} mt-2 min-h-32 resize-y`}
/>
) : (
<input
id={id}
type="text"
value={value}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
enterKeyHint={enterKeyHint}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy || undefined}
className={`${fieldClass} mt-2 min-h-12`}
/>
)}
{hint ? (
<p id={hintId} className="mt-2 text-xs leading-relaxed text-ink/60">
{hint}
</p>
) : null}
<FieldError id={errorId} message={error} />
</div>
);
}
/**
* Fehlerzeile unter einem Feld. Der Wrapper bleibt immer im DOM (aria-live),
* damit Screenreader eine neu erscheinende Meldung tatsaechlich ansagen.
*/
export function FieldError({ id, message }: { id?: string; message?: string | null }) {
return (
<div aria-live="polite">
{message ? (
<p id={id} className="mt-2 flex items-start gap-2 text-sm text-red-700">
<svg viewBox="0 0 24 24" className="mt-0.5 h-4 w-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<polygon points="12,2.5 20.2,7.25 20.2,16.75 12,21.5 3.8,16.75 3.8,7.25" strokeLinejoin="round" />
<path d="M12 8v5M12 16.2v.3" strokeLinecap="round" />
</svg>
{message}
</p>
) : null}
</div>
);
}
/**
* Auswahl als echte Radio-Buttons (Pfeiltasten, Screenreader, Formular-
* semantik gratis), gestaltet als Tippflaechen.
*/
export function ChoiceOption({
name,
value,
checked,
onSelect,
disabled = false,
children,
className = "",
}: {
name: string;
value: string;
checked: boolean;
onSelect: () => void;
disabled?: boolean;
children: ReactNode;
className?: string;
}) {
return (
<label
className={`group relative flex min-h-12 cursor-pointer select-none items-center gap-3 border px-4 py-3 transition-[border-color,background-color,box-shadow,transform] duration-200 ease-[var(--ease-arrive)] active:scale-[0.985] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark ${
checked
? "border-heading bg-forest/[0.05] text-heading shadow-[0_12px_28px_-22px_rgba(36,53,43,0.7)]"
: "border-ink/15 bg-background text-ink/80 hover:border-heading/50"
} ${disabled ? "cursor-not-allowed opacity-50 active:scale-100" : ""} ${className}`}
>
<input
type="radio"
name={name}
value={value}
checked={checked}
disabled={disabled}
onChange={onSelect}
className="sr-only"
/>
{children}
<span
aria-hidden="true"
className={`ml-auto flex h-5 w-5 flex-shrink-0 items-center justify-center transition-opacity duration-200 ${
checked ? "opacity-100" : "opacity-0"
}`}
>
<svg viewBox="0 0 24 26" className="h-5 w-5 text-honey">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="currentColor" />
<path d="M8 13.2l2.8 2.8 5.2-5.6" stroke="var(--color-cream)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</label>
);
}
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff. Show more