feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -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.");
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user