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
+28036
-123
No files matched your search
@@ -36,6 +36,11 @@ STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Optional: Kundenportal-Konfiguration (bpc_...) fuer "Zahlungsart aendern" im
|
||||
# Abo, angelegt von `pnpm tsx scripts/stripe-setup.ts --apply`. Leer = die App
|
||||
# findet sie ueber ihre Metadaten; fehlt sie ganz, bietet das Konto "Zahlungsart
|
||||
# aendern" nicht an (die Stripe-Standardkonfiguration wird nie benutzt).
|
||||
STRIPE_PORTAL_CONFIGURATION_ID=
|
||||
|
||||
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
||||
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
||||
@@ -243,6 +248,18 @@ UPLOAD_DIR=./uploads
|
||||
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
||||
INVOICE_DIR=./invoices
|
||||
|
||||
# Ablage der Urkunden-PDFs der Bienenpatenschaft (lib/sponsorship/storage.ts).
|
||||
# Aus demselben Grund wie INVOICE_DIR NIE unterhalb von UPLOAD_DIR — eine
|
||||
# Urkunde traegt Namen und eine persoenliche Widmung. Die Anwendung startet
|
||||
# nicht, wenn das Verzeichnis im Upload-Verzeichnis liegt. In Kubernetes ein
|
||||
# Unterordner des gesicherten Rechnungs-Volumes (siehe k8s/01-configmap.yaml).
|
||||
CERTIFICATE_DIR=./certificates
|
||||
|
||||
# Hintergrundaufgaben im App-Prozess (src/instrumentation.ts): Urkunden
|
||||
# nachholen, Abo-Lieferungen anlegen, Verzug pruefen. "off" schaltet sie ab,
|
||||
# z.B. fuer eine zweite Instanz, die nur Seiten ausliefern soll.
|
||||
BACKGROUND_JOBS=on
|
||||
|
||||
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
|
||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||
# 19 = voller Satz
|
||||
@@ -270,6 +287,8 @@ VAT_RATE_PERCENT_KERZEN=19
|
||||
VAT_RATE_PERCENT_PROPOLIS=19
|
||||
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher)
|
||||
VAT_RATE_PERCENT_SONSTIGES=19
|
||||
# Bienenpatenschaft (Leistung, Regelsatz; Honiganteil eigene Position).
|
||||
VAT_RATE_PERCENT_PATENSCHAFT=19
|
||||
|
||||
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
||||
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
||||
|
||||
@@ -56,3 +56,6 @@ next-env.d.ts
|
||||
|
||||
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
||||
/invoices
|
||||
|
||||
# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR).
|
||||
/certificates
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -260,6 +260,37 @@ Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOO
|
||||
|
||||
**Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren.
|
||||
|
||||
### 5.4 Abo der Bienenpatenschaft
|
||||
|
||||
Das Abo läuft über Stripe Billing (Checkout im Modus „subscription“). Produkte und Monatspreise der Abo-Angebote legt Wabenhain selbst an, sobald ein Abo-Angebot im Admin gespeichert oder zum ersten Mal abgeschlossen wird — **nicht** von Hand im Dashboard anlegen.
|
||||
|
||||
**Einrichtungsskript** (wiederholbar, schreibt ohne `--apply` nichts):
|
||||
|
||||
```bash
|
||||
# Probelauf: zeigt, was fehlt
|
||||
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
|
||||
# Schreiben (Testmodus)
|
||||
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply
|
||||
# Livekonto: nur nach Freigabe des Inhabers, zusätzlich --live-ok
|
||||
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply --live-ok
|
||||
```
|
||||
|
||||
Das Skript liest `STRIPE_SECRET_KEY` aus der Umgebung (`.env`) und
|
||||
|
||||
1. ergänzt den Webhook-Endpunkt `<BASE_URL>/api/checkout/webhook` um die Abo-Ereignisse (bestehende bleiben): `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.dispute.closed`. Legt es den Endpunkt neu an, steht das Signaturgeheimnis nur im Dashboard → `STRIPE_WEBHOOK_SECRET`.
|
||||
2. legt eine Kundenportal-Konfiguration an, die **nur das Ändern der Zahlungsart** erlaubt (keine Kündigung, kein Tarifwechsel, keine Kundendaten, kein Rechnungsverlauf). Die App findet sie über die Metadaten `wabenhain=abo-zahlungsart`; alternativ `STRIPE_PORTAL_CONFIGURATION_ID` setzen. Ohne diese Konfiguration bietet das Konto „Zahlungsart ändern“ nicht an — die Standardkonfiguration wird bewusst nie benutzt.
|
||||
|
||||
**Nur im Dashboard** (Checkliste, das Skript gibt sie ebenfalls aus):
|
||||
|
||||
- [ ] **Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen:** Smart Retries an; nach dem letzten erfolglosen Versuch **Abo kündigen** (nicht „unbezahlt markieren“). Wabenhain beendet das Abo dann über `customer.subscription.deleted`.
|
||||
- [ ] **… Kunden-E-Mails:** Stripe-Mails zu erfolgreichen Zahlungen/Rechnungen, bevorstehenden Verlängerungen und fehlgeschlagenen Zahlungen **aus** — Wabenhain verschickt eigene Rechnungen (Nummernkreis RE-…) und Mails.
|
||||
- [ ] **Einstellungen → Zahlungsmethoden:** Karte, Apple Pay, Google Pay und SEPA-Lastschrift aktiv. Im Abo zeigt Checkout nur Zahlarten, die wiederkehrende Zahlungen können. Google Pay prüfen; für Apple Pay/Google Pay im eingebetteten Checkout die Domain registrieren.
|
||||
- [ ] **Stripe Tax** nur mit aktiver Registrierung einschalten (Preise sind Bruttopreise; ohne Registrierung berechnet Stripe nichts).
|
||||
|
||||
**Im Admin:** SEPA-Neukunden-Frist (Vorgabe 56 Tage, 0 schaltet ab), Kontingente und Erntefenster der Abo-Angebote.
|
||||
|
||||
**Was automatisch passiert:** Jede bezahlte Monatsrechnung wird eine Wabenhain-Bestellung mit Rechnung und Mail; die erste erzeugt die Urkunde. Nach drei bezahlten Monaten (Ernte-Variante: im Erntefenster nach sechs) legt der Hintergrundlauf alle zehn Minuten eine 0-€-Lieferbestellung an, die in der normalen Pack- und DHL-Liste erscheint. Eine Rückbuchung setzt das Abo in Verzug, sperrt SEPA für das Konto und beendet das Abo nach 14 Tagen ohne Ausgleich. Kündigungen kommen aus dem Konto oder von `/vertraege-kuendigen` (Footer „Verträge hier kündigen“); öffentliche Kündigungen stehen im Audit-Log (`subscription.cancellation_request`) und gehen zusätzlich an `ADMIN_EMAIL`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Optionale Dienste
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Bienenpatenschaft Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bienenpatenschaften als Einmalkauf und Abo verkaufen, mit deterministisch erzeugter Urkunde, Medaillen, 3D-Ansicht im Konto, Übertragung, Treuestufen und Stripe-Abo.
|
||||
|
||||
**Architecture:** Eine reine Layout-Engine (`src/lib/certificate`) erzeugt aus Personalisierung, Textbausteinen und Vorlagenversion positionierte Elemente; daraus entstehen PDF (pdfkit) und Bilder (SVG mit Glyphenpfaden → sharp). Einmal-Angebote hängen an je einem `Product` der neuen Kategorie `PATENSCHAFT`, damit Warenkorb, Checkout, Rechnung, ERP-Export und ApiCore-Buchung weiterlaufen; der Honig eines Pakets wird beim Checkout als eigene Position des verknüpften Honigprodukts angelegt (Steueraufteilung, Bestandsreservierung und ApiCore-Abbuchung ohne Sonderweg). Abos laufen über Stripe Billing; jede bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung mit Rechnung, jede fällige Lieferung zu einer 0-€-Bestellung mit Honigpositionen.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, Server Actions), Prisma 7 (Postgres + MySQL), pdfkit, fontkit, sharp, three + @react-three/fiber + drei, fflate, Stripe (API 2026-08-26.dahlia), vitest, Playwright.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md`
|
||||
|
||||
Abweichung vom Standard „ein Plan je Teilprojekt“: Der Inhaber hat alle fünf Teilprojekte auf einmal freigegeben. Der Plan ist deshalb in Teile A–E gegliedert, jeder Teil ist für sich lauffähig und testbar und wird einzeln committet.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Kein Versprechen von Besuchen, Events, Videos, Fotos, Berichten, Umweltwirkungen – in keinem Text, keiner Mail, keiner Urkunde.
|
||||
- Urkunde: A4 hochkant (595.28 × 841.89 pt), genau eine Seite, Inhalt ≥ 15 mm (42.52 pt) vom Rand, Rahmen ≥ 12 mm (34.02 pt), kleinste Schrift 8 pt, Fließtext ≥ 11 pt.
|
||||
- Schriften Cormorant Garamond und Inter, statische TTF unter SIL OFL, mit Lizenzdatei in `assets/fonts/`.
|
||||
- Farben: Forest `#24352b`, Honey Dark `#8a6531`, Honey `#b88a44`, Sage `#788774`; auf Papier kein Verlauf, keine Folienimitation.
|
||||
- Eingabelimits (Start): Bienenname 2–24, Name 2–60, Schenkende ≤ 80, Widmung ≤ 280 Zeichen und ≤ 5 Zeilenumbrüche; NFC, Steuerzeichen raus, Glyphenprüfung.
|
||||
- Keine Pronomen, keine abgeleiteten Anreden, keine Behauptung individueller Betreuung einer Biene.
|
||||
- Patenschaftsnummer `WH-P-JJJJ-NNNNN`. Vorlagenkennung `<name>@<version>`.
|
||||
- Patenschaften nur mit angemeldetem, verifiziertem Konto. Gastbestellungen anderer Produkte bleiben.
|
||||
- PDFs in `CERTIFICATE_DIR` (nie unter `UPLOAD_DIR`), Downloads nur nach Besitzprüfung, fremde IDs → 404.
|
||||
- Wasserzeichen der Vorschau: „Unverbindliche Vorschau“ / „Preview – not binding“.
|
||||
- Abo: Lieferanspruch nach 3 (vierteljährlich) bzw. 6 (Ernte) bezahlten Monaten; SEPA-Neukunden-Frist 56 Tage (Admin, 0 = aus); Rückbuchung → `IM_VERZUG`, nach 14 Tagen ohne Ausgleich Ende; Konto mit Rückbuchung → kein SEPA mehr.
|
||||
- Abo-Stufen 1–24 je bezahltem Monat, danach VIP; Stammkunden-Stufen ab 2/5/10/25 bezahlten Bestellungen; beides im Admin pflegbar.
|
||||
- Übertragungslinks: 24 h / 7 Tage / 30 Tage, einmalig, widerrufbar, Token ≥ 128 Bit, nur Hash gespeichert.
|
||||
- Zahlarten nie per `payment_method_types`; Stripe-Livekonto nur über Einrichtungsskript bzw. Stripe-Anbindung ändern.
|
||||
- Codekommentare und Texte auf Deutsch, Stil wie im Repo (Warum-Kommentare, keine console.log zum Debuggen).
|
||||
- Agents committen nicht; der Hauptlauf committet zentral.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Zwei Patenschaften desselben Pakets im Warenkorb dürfen nicht verschmelzen → Tests in A7 (Warenkorbschlüssel) und A8 (Checkout mit doppelter productId).
|
||||
2. Webhook mehrfach oder parallel: genau eine Patenschaft, genau eine Mail → Test in A9.
|
||||
3. Manipulierte Personalisierung (überlange Widmung, fremde Zeichen, `<script>`): Checkout lehnt ab, Urkunde zeigt nur Text → Tests in A2 und A8.
|
||||
4. Abo gekündigt, während eine Lieferung fällig wird: keine Lieferung über bezahlte Monate hinaus → Test in D4.
|
||||
5. Honig knapp, während Abos reserviert sind: Einzelverkauf und Pakete können reservierte Gläser nicht kaufen → Test in D5.
|
||||
|
||||
---
|
||||
|
||||
## Teil A – Urkunde und Einmal-Patenschaft (Teilprojekt 1)
|
||||
|
||||
### Task A1: Schriften, Personalisierungsschema, Textbausteine
|
||||
|
||||
**Files:** Create `assets/fonts/CormorantGaramond-{Regular,Italic,SemiBold,SemiBoldItalic}.ttf`, `assets/fonts/Inter-{Regular,Medium}.ttf`, Lizenzdateien; `src/lib/certificate/{schema,text,fonts}.ts`, `src/types/fontkit.d.ts`; Tests `src/lib/certificate/{schema,text}.test.ts`
|
||||
|
||||
**Produces:**
|
||||
- `OCCASIONS = ["SELBST","GEBURTSTAG","WEIHNACHTEN","DANKESCHOEN","GESCHENK"] as const`, `type Occasion`
|
||||
- `CERTIFICATE_LIMITS = { beeName:{min:2,max:24}, recipientName:{min:2,max:60}, giverNames:{max:80}, dedication:{max:280,maxLineBreaks:5} }`
|
||||
- `normalizeCertificateText(value: string, opts?: { multiline?: boolean }): string`
|
||||
- `personalizationSchema` (zod) → `Personalization = { beeName; recipientName; giverNames?; dedication?; occasion: Occasion; startDate: "YYYY-MM-DD" }`; `giverNames` wird bei `SELBST` verworfen
|
||||
- `CertificateTerm = {kind:"FIXED";start;end} | {kind:"OPEN";start} | {kind:"ENDED";start;end}`, `fixedTermFor(start)` (Ende = Start + 12 Monate − 1 Tag)
|
||||
- `composeCertificateText({ personalization, locale, term, number }): CertificateText` = `{ title; subtitle; occasionLine|null; leadLine|null; beforeBee; beeName; afterBee; giverLine|null; dedication|null; termLine; numberLine; notice }`
|
||||
- `loadCertificateFonts(): CertificateFonts` (gecacht; Schlüssel `serif`, `serifItalic`, `serifBold`, `serifBoldItalic`, `sans`, `sansMedium`), `findUnsupportedCharacters(text, fontKeys): string[]`
|
||||
|
||||
- [ ] Tests (Schema-Grenzen, „Zoë“, Steuerzeichen, giverNames bei SELBST, 6 Umbrüche, Emoji; Text: eigene/verschenkte Variante, Anlasszeilen, leere Felder ohne Zeile, drei Laufzeitformen de/en, Hinweis wörtlich)
|
||||
- [ ] Implementieren, `pnpm vitest run src/lib/certificate` grün
|
||||
|
||||
### Task A2: Layout-Engine
|
||||
|
||||
**Files:** `src/lib/certificate/layout.ts`, `src/lib/certificate/templates/{index,<vorlage>}.ts`; Test `layout.test.ts`
|
||||
|
||||
**Produces:**
|
||||
- `LayoutElement = {kind:"text"; font; size; color; x; y; width; text; align; role} | {kind:"path"; d; stroke?; fill?; strokeWidth?; opacity?} | {kind:"image"; src:"logo"; x; y; width; height}`
|
||||
- `CertificateLayout = { templateVersion; width: 595.28; height: 841.89; elements }`
|
||||
- `CertificateTemplate { version; layout(text, fonts): CertificateLayout }`, `CURRENT_TEMPLATE_VERSION`, `getTemplate(version)`
|
||||
- `layoutCertificate(text, templateVersion?)`, `CertificateLayoutError { field }`, `checkCertificateFits(p, locale)`
|
||||
|
||||
- [ ] Testmatrix der Spec: keine Überlappung, Sicherheitszone, Mindestgrößen, Namen ≤ 2 Zeilen, Umbruch nur an Leerzeichen/Bindestrich
|
||||
- [ ] Implementieren, grün
|
||||
|
||||
### Task A3: Renderer und Designvarianten
|
||||
|
||||
**Files:** `src/lib/certificate/{render-pdf,render-image}.ts`, `scripts/render-certificate-samples.ts`; Test `render.test.ts`
|
||||
|
||||
**Produces:** `renderCertificatePdf(layout, { title, logo? }): Promise<Buffer>`, `renderCertificateSvg(layout, { watermark? }): string` (Glyphenpfade), `renderCertificateImage(layout, { widthPx, watermark?, format }): Promise<Buffer>`, `layoutLines(layout): string[]`
|
||||
|
||||
- [ ] Tests: `%PDF`, genau eine Seite, eingebettete Fonts; SVG ohne `<text>`; Wasserzeichen; Bildbreite
|
||||
- [ ] Drei Vorlagen rendern, ansehen, eine wählen, andere entfernen
|
||||
|
||||
### Task A4: Datenmodell
|
||||
Enums `SponsorshipOfferKind`, `SponsorshipCycle`, `MedalTier`, `SponsorshipStatus`; `ProductCategory.PATENSCHAFT`; `FulfillmentMethod.DIGITAL`; Modelle `SponsorshipOffer`, `Sponsorship`, `SponsorshipRevision`, `SponsorshipCounter`; `OrderItem.personalization`, `OrderItem.sponsorshipOfferId`, `OrderItem.sponsorshipRole`. Migration `prisma/migrations/20261016010000_sponsorship`. `DIGITAL` überall behandeln, `pnpm typecheck` grün.
|
||||
|
||||
### Task A5: Angebote im Admin + Startdaten
|
||||
`src/lib/sponsorship/offers.ts` (`getActiveOffers`, `getOfferBySlug`), Admin `/admin/patenschaften/angebote`, Upsert legt das `Product` (Kategorie `PATENSCHAFT`, Preis = Paketpreis − Honiganteil) mit an; Seed der drei Pakete; `PATENSCHAFT` aus Shop-Listen und Produktseite filtern.
|
||||
|
||||
### Task A6: Vorschau-API und Verkaufsseite
|
||||
`/api/patenschaft/vorschau` (Origin, Rate-Limit 60/10 min, Schema + Fit, WebP 900 px mit Wasserzeichen, `no-store`); `/patenschaft` mit Konfigurator (Einmalig/Abo, Schritte, Zähler, Debounce 400 ms, Bestätigung, Anmeldezwang mit Entwurf in `sessionStorage` `wabenhain-sponsorship-draft`).
|
||||
|
||||
### Task A7: Warenkorb
|
||||
`CartItem.key`, `CartItem.sponsorship?: { offerSlug; personalization }`, Operationen über `key`, `persist` Version 2 mit `migrate`, Patenschaft Menge fest 1. Tests: zwei gleiche Pakete bleiben zwei Positionen; alter Speicherstand wird migriert.
|
||||
|
||||
### Task A8: Checkout
|
||||
`expandSponsorshipItems(...)` in `src/lib/sponsorship/checkout-expand.ts`: Hauptposition ohne Reservierung, Honigposition aus verknüpftem Produkt (`unitPriceCents = honeyShareCentsPerJar`, Menge = Gläser, Steuer des Honigs), Kontopflicht, erneute Prüfung, reine Digitalbestellung → `DIGITAL` ohne Versand, doppelte productIds erlaubt. Tests inkl. Manipulation.
|
||||
|
||||
### Task A9: Ausstellung nach Zahlung
|
||||
`createSponsorshipsForOrder(tx, orderId)` im Claim-Lauf; `issuePendingSponsorships({orderId?, limit?})`; Mail-Claim über `mailSentAt`; Backoff `min(2^n × 5 min, 12 h)`, 8 Versuche → `FEHLER`; `scripts/sponsorship-jobs.ts` + CronJob `wabenhain-sponsorship`; `CERTIFICATE_DIR` mit Startprüfung. Tests: doppelter Aufruf, Fehler beim Rendern, Wiederholung.
|
||||
|
||||
### Task A10: Kontobereich und Download
|
||||
`/konto/patenschaft`, `/konto/patenschaft/[id]`, PDF-Route mit Besitzprüfung, `getOwnedSponsorship(customerId, id)`.
|
||||
|
||||
### Task A11: Admin Patenschaften, Druck, Korrektur
|
||||
Liste/Filter, Detail, Druck-PDF, „gedruckt“, „erneut erzeugen“, „Mail erneut“, `reviseSponsorship(...)` mit Revision und Audit-Log; Aufmerksamkeitsliste.
|
||||
|
||||
## Teil B – Medaillen, 3D, Gesamtdownload (Teilprojekt 2)
|
||||
- B1 `src/lib/medals/`: `renderMedalSvg(input: MedalInput)`, `renderMedalPng(input, sizePx = 2048)`; `MedalInput` = Patenschaft (tier, beeName, year, number) | Stammkunde (level, name) | Abo (months) | VIP.
|
||||
- B2 Routen `textur.webp`, `medaille.png`, `medaille.svg`, `alles.zip` (fflate) mit Besitzprüfung.
|
||||
- B3 `src/components/patenschaft-3d/`: `<SponsorshipStage textureUrl backTextureUrl medalSvgUrl tier fallbackImageUrl />`, dynamisch geladen, Wölbung, Reflex, Ziehen mit Nachschwung, Doppelklick wendet, Zoom begrenzt, Neigung nach Tipp, reduced motion, WebGL-Fallback, Tastatur, Pause außerhalb der Sicht.
|
||||
|
||||
## Teil C – Übertragung (Teilprojekt 3)
|
||||
`SponsorshipTransfer`, `createTransferAction(id, validity)`, `revokeTransferAction`, `/patenschaft/uebernehmen/[token]`, Audit-Log, neutrale Fehlerseite. Tests: Ablauf, Widerruf, Doppel-Einlösung, Abo nicht übertragbar.
|
||||
|
||||
## Teil D – Abo (Teilprojekt 4)
|
||||
- D1 Modelle `SponsorshipSubscription`, `SubscriptionPayment`, `SubscriptionDelivery`, `HarvestWindow`; `Customer.stripeCustomerId`, `Customer.sepaBlockedAt`; Einstellung SEPA-Frist (56).
|
||||
- D2 Stripe Product/Price aus Admin; `POST /api/patenschaft/abo` (Checkout `mode: "subscription"`, eingebettet; `excluded_payment_method_types: ["sepa_debit"]` nur bei Sperre).
|
||||
- D3 Webhooks Abo/Rechnung/Rückbuchung idempotent; `invoice.paid` → Zahlung + Bestellung `DIGITAL` + Rechnung + Mail; erste Zahlung → Patenschaft (OPEN).
|
||||
- D4 `computeDeliveryEntitlement(...)`, täglicher Lauf erzeugt 0-€-Lieferbestellungen.
|
||||
- D5 `availableForSale(productId)` = Bestand − Abo-Reservierung; Kontingent; Deckungsanzeige; Stopp neuer Abos.
|
||||
- D6 Kündigung im Konto und unter `/vertraege-kuendigen`, `cancel_at_period_end`, Mail, ENDED-Revision bei Wirksamkeit.
|
||||
- D7 `scripts/stripe-setup.ts` und Livekonto über die Stripe-Anbindung (Webhook-Ereignisse, Kundenportal nur Zahlungsart), Checkliste in `docs/EINRICHTUNG.md`.
|
||||
|
||||
## Teil E – Treuestufen (Teilprojekt 5)
|
||||
`LoyaltyLevel`, Seed (2/5/10/25; Abo 1–24 + VIP), `getCustomerLoyalty(customerId)`, Medaillenleiste, Admin-Pflege. Tests: Erstattung senkt Stufe, Kündigung entfernt Abo-Stufe.
|
||||
|
||||
## Abschluss
|
||||
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` grün
|
||||
- [ ] Browserprüfung Desktop + 390 px, reduced motion, Tastatur
|
||||
- [ ] Code-Review und `code-simplifier` auf den Diff
|
||||
- [ ] Commits, Push, CI grün; Rollout nur nach Freigabe
|
||||
@@ -69,6 +69,28 @@ data:
|
||||
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
||||
INVOICE_DIR: "/app/invoices"
|
||||
|
||||
# Urkunden-PDFs der Bienenpatenschaft (src/lib/sponsorship/storage.ts):
|
||||
# /app/invoices/urkunden/2026/WH-P-2026-00042/r1.pdf
|
||||
# Unterordner des Rechnungs-Volumes: ebenso privat (keine oeffentliche Route
|
||||
# zeigt dorthin), ebenso taeglich gesichert (07-backup-cronjob.yaml sichert
|
||||
# das ganze Volume), und kein zweites ReadWriteOnce-Volume am App-Pod. Die
|
||||
# Pfade der Rechnungen beginnen mit dem Jahr, "urkunden" kollidiert nicht.
|
||||
CERTIFICATE_DIR: "/app/invoices/urkunden"
|
||||
|
||||
# Hintergrundaufgaben im App-Prozess (src/lib/background-jobs.ts): Urkunden
|
||||
# nachholen, Abo-Lieferungen anlegen, Verzug nach Rueckbuchung beenden.
|
||||
# Laufen im App-Pod, weil nur er das Volume mit CERTIFICATE_DIR einhaengt.
|
||||
# "off" nur fuer zusaetzliche Instanzen, die keine Aufgaben uebernehmen sollen.
|
||||
BACKGROUND_JOBS: "on"
|
||||
|
||||
# Stripe-Kundenportal fuer "Zahlungsart aendern" im Abo (bpc_..., kein
|
||||
# Geheimnis). Legt scripts/stripe-setup.ts --apply an und gibt die ID aus.
|
||||
# Leer lassen ist der Normalfall: dann sucht die App die Konfiguration ueber
|
||||
# ihre Metadaten (wabenhain=abo-zahlungsart). Gibt es keine, bietet das Konto
|
||||
# "Zahlungsart aendern" nicht an — die Standardkonfiguration von Stripe wird
|
||||
# bewusst nie benutzt, weil sie Kuendigung oder Tarifwechsel erlauben koennte.
|
||||
STRIPE_PORTAL_CONFIGURATION_ID: ""
|
||||
|
||||
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||
# 19 = voller Satz
|
||||
@@ -95,6 +117,10 @@ data:
|
||||
VAT_RATE_PERCENT_PROPOLIS: "19"
|
||||
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher).
|
||||
VAT_RATE_PERCENT_SONSTIGES: "19"
|
||||
# Bienenpatenschaft (Urkunde, Namensgebung, Abo-Monatsbeitrag ohne
|
||||
# Honiganteil): Leistung zum Regelsatz. Vor der Veroeffentlichung mit der
|
||||
# Steuerberatung bestaetigen (Spec, "Geschaeftliche Konfiguration").
|
||||
VAT_RATE_PERCENT_PATENSCHAFT: "19"
|
||||
|
||||
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
||||
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
-- Bienenpatenschaft: Angebote, Patenschaften mit Urkunden-Revisionen,
|
||||
-- Uebertragungen, Abos (Stripe Billing) mit Zahlungen und Lieferungen,
|
||||
-- Erntefenster und Treuestufen. Rein additiv; die beiden neuen Enum-Werte
|
||||
-- (PATENSCHAFT, DIGITAL) werden in dieser Migration nicht benutzt, ALTER TYPE
|
||||
-- ADD VALUE ist deshalb auch innerhalb der Migrationstransaktion zulaessig.
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SponsorshipOfferKind" AS ENUM ('EINMALIG', 'ABO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SponsorshipCycle" AS ENUM ('VIERTELJAEHRLICH', 'ERNTE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MedalTier" AS ENUM ('BRONZE', 'SILBER', 'GOLD', 'ABO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SponsorshipStatus" AS ENUM ('AUSSTEHEND', 'FEHLER', 'BEREIT', 'GEDRUCKT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('AUSSTEHEND', 'AKTIV', 'IM_VERZUG', 'GEKUENDIGT', 'BEENDET');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DeliveryStatus" AS ENUM ('ANGELEGT', 'ZURUECKGESTELLT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LoyaltyKind" AS ENUM ('STAMMKUNDE', 'ABO');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ProductCategory" ADD VALUE 'PATENSCHAFT';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FulfillmentMethod" ADD VALUE 'DIGITAL';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "customers" ADD COLUMN "sepaBlockedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "stripeCustomerId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "order_items" ADD COLUMN "personalization" JSONB,
|
||||
ADD COLUMN "sponsorshipOfferId" TEXT,
|
||||
ADD COLUMN "sponsorshipRole" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorship_offers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"kind" "SponsorshipOfferKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT,
|
||||
"description" TEXT NOT NULL,
|
||||
"descriptionEn" TEXT,
|
||||
"features" JSONB NOT NULL DEFAULT '[]',
|
||||
"featuresEn" JSONB NOT NULL DEFAULT '[]',
|
||||
"priceCents" INTEGER NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPhysical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"productId" TEXT,
|
||||
"honeyProductId" TEXT,
|
||||
"honeyJars" INTEGER NOT NULL DEFAULT 0,
|
||||
"honeyShareCentsPerJar" INTEGER NOT NULL DEFAULT 0,
|
||||
"honeyShareCentsPerMonth" INTEGER NOT NULL DEFAULT 0,
|
||||
"cycle" "SponsorshipCycle",
|
||||
"maxActiveSubscriptions" INTEGER,
|
||||
"stripeProductId" TEXT,
|
||||
"stripePriceId" TEXT,
|
||||
"stripePriceAmountCents" INTEGER,
|
||||
"medalTier" "MedalTier" NOT NULL DEFAULT 'BRONZE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sponsorship_offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorships" (
|
||||
"id" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"offerId" TEXT NOT NULL,
|
||||
"orderItemId" TEXT,
|
||||
"subscriptionId" TEXT,
|
||||
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE,
|
||||
"status" "SponsorshipStatus" NOT NULL DEFAULT 'AUSSTEHEND',
|
||||
"currentRevision" INTEGER NOT NULL DEFAULT 0,
|
||||
"pdfAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
"mailSentAt" TIMESTAMP(3),
|
||||
"printedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sponsorships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorship_revisions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sponsorshipId" TEXT NOT NULL,
|
||||
"revision" INTEGER NOT NULL,
|
||||
"personalization" JSONB NOT NULL,
|
||||
"term" JSONB NOT NULL,
|
||||
"text" JSONB NOT NULL,
|
||||
"templateVersion" TEXT NOT NULL,
|
||||
"pdfPath" TEXT NOT NULL,
|
||||
"pdfSha256" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdByLabel" TEXT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sponsorship_revisions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorship_counters" (
|
||||
"year" INTEGER NOT NULL,
|
||||
"lastNumber" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "sponsorship_counters_pkey" PRIMARY KEY ("year")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorship_transfers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sponsorshipId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"claimedById" TEXT,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sponsorship_transfers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sponsorship_subscriptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"offerId" TEXT NOT NULL,
|
||||
"status" "SubscriptionStatus" NOT NULL DEFAULT 'AUSSTEHEND',
|
||||
"personalization" JSONB NOT NULL,
|
||||
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||
"stripeCheckoutSessionId" TEXT,
|
||||
"stripeSubscriptionId" TEXT,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"currentPeriodEnd" TIMESTAMP(3),
|
||||
"cancelAt" TIMESTAMP(3),
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"delinquentSince" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sponsorship_subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscription_payments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"subscriptionId" TEXT NOT NULL,
|
||||
"stripeInvoiceId" TEXT NOT NULL,
|
||||
"stripePaymentIntentId" TEXT,
|
||||
"paymentMethodType" TEXT,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"paidAt" TIMESTAMP(3) NOT NULL,
|
||||
"periodStart" TIMESTAMP(3) NOT NULL,
|
||||
"periodEnd" TIMESTAMP(3) NOT NULL,
|
||||
"countsFrom" TIMESTAMP(3) NOT NULL,
|
||||
"reversedAt" TIMESTAMP(3),
|
||||
"deliveryId" TEXT,
|
||||
"orderId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "subscription_payments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscription_deliveries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"subscriptionId" TEXT NOT NULL,
|
||||
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||
"jars" INTEGER NOT NULL,
|
||||
"status" "DeliveryStatus" NOT NULL,
|
||||
"orderId" TEXT,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "subscription_deliveries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "harvest_windows" (
|
||||
"id" TEXT NOT NULL,
|
||||
"offerId" TEXT NOT NULL,
|
||||
"startsOn" DATE NOT NULL,
|
||||
"endsOn" DATE NOT NULL,
|
||||
"plannedJars" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "harvest_windows_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "loyalty_levels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kind" "LoyaltyKind" NOT NULL,
|
||||
"threshold" INTEGER,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "loyalty_levels_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_offers_slug_key" ON "sponsorship_offers"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_offers_productId_key" ON "sponsorship_offers"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorship_offers_kind_isActive_sortOrder_idx" ON "sponsorship_offers"("kind", "isActive", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorship_offers_honeyProductId_idx" ON "sponsorship_offers"("honeyProductId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorships_number_key" ON "sponsorships"("number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorships_orderItemId_key" ON "sponsorships"("orderItemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorships_subscriptionId_key" ON "sponsorships"("subscriptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorships_ownerId_idx" ON "sponsorships"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorships_status_nextAttemptAt_idx" ON "sponsorships"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorships_offerId_idx" ON "sponsorships"("offerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_revisions_sponsorshipId_revision_key" ON "sponsorship_revisions"("sponsorshipId", "revision");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_transfers_tokenHash_key" ON "sponsorship_transfers"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorship_transfers_sponsorshipId_idx" ON "sponsorship_transfers"("sponsorshipId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeCheckoutSessionId_key" ON "sponsorship_subscriptions"("stripeCheckoutSessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeSubscriptionId_key" ON "sponsorship_subscriptions"("stripeSubscriptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorship_subscriptions_customerId_idx" ON "sponsorship_subscriptions"("customerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sponsorship_subscriptions_offerId_status_idx" ON "sponsorship_subscriptions"("offerId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscription_payments_stripeInvoiceId_key" ON "subscription_payments"("stripeInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscription_payments_orderId_key" ON "subscription_payments"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_payments_subscriptionId_paidAt_idx" ON "subscription_payments"("subscriptionId", "paidAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscription_deliveries_orderId_key" ON "subscription_deliveries"("orderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_deliveries_subscriptionId_dueAt_idx" ON "subscription_deliveries"("subscriptionId", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "harvest_windows_offerId_startsOn_idx" ON "harvest_windows"("offerId", "startsOn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "loyalty_levels_kind_threshold_key" ON "loyalty_levels"("kind", "threshold");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customers_stripeCustomerId_key" ON "customers"("stripeCustomerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_honeyProductId_fkey" FOREIGN KEY ("honeyProductId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_revisions" ADD CONSTRAINT "sponsorship_revisions_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_transfers" ADD CONSTRAINT "sponsorship_transfers_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "subscription_deliveries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "harvest_windows" ADD CONSTRAINT "harvest_windows_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -76,6 +76,17 @@ model Customer {
|
||||
wishlistItems WishlistItem[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
/// Stripe-Kunde fuer Abos (Stripe Billing). Erst beim ersten Abo-Abschluss
|
||||
/// angelegt; Einmalkaeufe laufen weiter ohne Stripe-Kunde.
|
||||
stripeCustomerId String? @unique
|
||||
/// Gesetzt nach einer SEPA-Rueckbuchung: neue Abos dieses Kontos nur noch
|
||||
/// mit Karte oder Wallet (siehe lib/sponsorship-subscription). Der Admin
|
||||
/// kann die Sperre aufheben.
|
||||
sepaBlockedAt DateTime?
|
||||
|
||||
sponsorships Sponsorship[]
|
||||
subscriptions SponsorshipSubscription[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
@@ -213,6 +224,10 @@ enum ProductCategory {
|
||||
KERZEN
|
||||
PROPOLIS
|
||||
SONSTIGES
|
||||
/// Traegerprodukt einer Einmal-Patenschaft (siehe SponsorshipOffer). Nicht
|
||||
/// im Shop gelistet; es haelt Preis, Steuersatz und Rechnungsposition, damit
|
||||
/// Warenkorb, Checkout und Rechnung keinen Sonderweg brauchen.
|
||||
PATENSCHAFT
|
||||
}
|
||||
|
||||
model Product {
|
||||
@@ -291,6 +306,11 @@ model Product {
|
||||
wishlistItems WishlistItem[]
|
||||
stockNotifications StockNotification[]
|
||||
|
||||
/// Einmal-Patenschaft, deren Traegerprodukt dieses Produkt ist.
|
||||
sponsorshipOffer SponsorshipOffer? @relation("SponsorshipOfferProduct")
|
||||
/// Patenschafts-Angebote, deren Honig aus diesem Produkt kommt.
|
||||
sponsorshipHoneyFor SponsorshipOffer[] @relation("SponsorshipOfferHoney")
|
||||
|
||||
@@index([archivedAt])
|
||||
@@map("products")
|
||||
}
|
||||
@@ -384,6 +404,9 @@ enum OrderStatus {
|
||||
enum FulfillmentMethod {
|
||||
VERSAND
|
||||
ABHOLUNG
|
||||
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
|
||||
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
|
||||
DIGITAL
|
||||
}
|
||||
|
||||
model Order {
|
||||
@@ -462,6 +485,10 @@ model Order {
|
||||
invoice Invoice?
|
||||
returnRequest ReturnRequest?
|
||||
withdrawalDeclarations WithdrawalDeclaration[]
|
||||
/// Gesetzt, wenn diese Bestellung eine bezahlte Abo-Monatsrechnung abbildet.
|
||||
subscriptionPayment SubscriptionPayment?
|
||||
/// Gesetzt, wenn diese Bestellung eine Abo-Honiglieferung ist (0 €).
|
||||
subscriptionDelivery SubscriptionDelivery?
|
||||
|
||||
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
|
||||
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
|
||||
@@ -539,7 +566,20 @@ model OrderItem {
|
||||
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
|
||||
apicoreAllocations Json?
|
||||
|
||||
/// Personalisierung einer Patenschaft, wie sie beim Kauf bestaetigt wurde
|
||||
/// (lib/certificate/schema.ts), samt Vorlagenversion. Nur an der
|
||||
/// Hauptposition (sponsorshipRole "MAIN"); daraus entsteht nach der Zahlung
|
||||
/// die erste Revision der Urkunde.
|
||||
personalization Json?
|
||||
/// Angebot, zu dem diese Position gehoert — an Haupt- UND Honigposition,
|
||||
/// damit Warenkorb, Rechnung und Admin sie als ein Paket zeigen koennen.
|
||||
sponsorshipOfferId String?
|
||||
/// "MAIN" (Patenschaft, Regelsteuersatz) oder "HONEY" (Honiganteil des
|
||||
/// Pakets zum Honig-Satz). Null bei gewoehnlichen Positionen.
|
||||
sponsorshipRole String?
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
sponsorship Sponsorship?
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||
apicoreStockEvents ApiCoreStockEvent[]
|
||||
@@ -951,3 +991,328 @@ model WithdrawalDeclaration {
|
||||
@@index([processedAt])
|
||||
@@map("withdrawal_declarations")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bienenpatenschaft (docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum SponsorshipOfferKind {
|
||||
EINMALIG
|
||||
ABO
|
||||
}
|
||||
|
||||
enum SponsorshipCycle {
|
||||
/// Lieferanspruch nach jeweils drei bezahlten Monaten.
|
||||
VIERTELJAEHRLICH
|
||||
/// Lieferung in den Erntefenstern, frueheste nach sechs bezahlten Monaten
|
||||
/// seit der letzten Lieferung.
|
||||
ERNTE
|
||||
}
|
||||
|
||||
/// Praegung der Patenschafts-Medaille (lib/medals).
|
||||
enum MedalTier {
|
||||
BRONZE
|
||||
SILBER
|
||||
GOLD
|
||||
ABO
|
||||
}
|
||||
|
||||
enum SponsorshipStatus {
|
||||
/// Bezahlt, PDF noch nicht erzeugt oder Mail noch nicht versendet.
|
||||
AUSSTEHEND
|
||||
/// Alle automatischen Versuche aufgebraucht — braucht den Admin.
|
||||
FEHLER
|
||||
/// PDF liegt vor, Mail ist raus.
|
||||
BEREIT
|
||||
/// Nur physische Pakete: Urkunde gedruckt.
|
||||
GEDRUCKT
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
/// Checkout gestartet, bei Stripe noch nicht abgeschlossen.
|
||||
AUSSTEHEND
|
||||
AKTIV
|
||||
/// Zahlung offen oder zurueckgebucht — keine Lieferungen, Stufe ruht.
|
||||
IM_VERZUG
|
||||
/// Gekuendigt, laeuft bis zum Ende des bezahlten Monats.
|
||||
GEKUENDIGT
|
||||
BEENDET
|
||||
}
|
||||
|
||||
enum DeliveryStatus {
|
||||
/// Bestellung fuer den Versand angelegt.
|
||||
ANGELEGT
|
||||
/// Faellig, aber ohne Bestand oder Anspruch zurueckgestellt.
|
||||
ZURUECKGESTELLT
|
||||
}
|
||||
|
||||
enum LoyaltyKind {
|
||||
STAMMKUNDE
|
||||
ABO
|
||||
}
|
||||
|
||||
/// Ein im Admin pflegbares Patenschafts-Angebot (Einmalpaket oder Abo).
|
||||
model SponsorshipOffer {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
kind SponsorshipOfferKind
|
||||
name String
|
||||
nameEn String?
|
||||
description String @db.Text
|
||||
descriptionEn String? @db.Text
|
||||
/// Leistungsliste als JSON-Array von Strings (lib/json-array.ts).
|
||||
features Json @default("[]")
|
||||
featuresEn Json @default("[]")
|
||||
/// EINMALIG: Bruttopreis des ganzen Pakets. ABO: Monatspreis brutto.
|
||||
priceCents Int
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
/// Mit gedruckter Urkunde, Honig, Grusskarte und Versand.
|
||||
isPhysical Boolean @default(false)
|
||||
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
|
||||
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
|
||||
productId String? @unique
|
||||
/// Honigprodukt, dessen Bestand das Paket bzw. Abo verbraucht.
|
||||
honeyProductId String?
|
||||
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
|
||||
honeyJars Int @default(0)
|
||||
/// EINMALIG: Honiganteil des Paketpreises je Glas (zum Honig-Steuersatz).
|
||||
honeyShareCentsPerJar Int @default(0)
|
||||
/// ABO: Honiganteil des Monatspreises (zum Honig-Steuersatz).
|
||||
honeyShareCentsPerMonth Int @default(0)
|
||||
/// Nur ABO.
|
||||
cycle SponsorshipCycle?
|
||||
/// Nur ABO: Hoechstzahl aktiver Abos (Kontingent); null = unbegrenzt.
|
||||
maxActiveSubscriptions Int?
|
||||
/// Nur ABO: Stripe-Produkt und der Preis, der priceCents abbildet. Bei einer
|
||||
/// Preisaenderung entsteht ein neuer Stripe-Preis; laufende Abos behalten
|
||||
/// ihren alten.
|
||||
stripeProductId String?
|
||||
stripePriceId String?
|
||||
stripePriceAmountCents Int?
|
||||
medalTier MedalTier @default(BRONZE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
product Product? @relation("SponsorshipOfferProduct", fields: [productId], references: [id], onDelete: Restrict)
|
||||
honeyProduct Product? @relation("SponsorshipOfferHoney", fields: [honeyProductId], references: [id], onDelete: SetNull)
|
||||
sponsorships Sponsorship[]
|
||||
subscriptions SponsorshipSubscription[]
|
||||
harvestWindows HarvestWindow[]
|
||||
|
||||
@@index([kind, isActive, sortOrder])
|
||||
@@index([honeyProductId])
|
||||
@@map("sponsorship_offers")
|
||||
}
|
||||
|
||||
/// Eine gekaufte Patenschaft (eine Biene, ein Name, eine Urkunde).
|
||||
model Sponsorship {
|
||||
id String @id @default(cuid())
|
||||
/// WH-P-JJJJ-NNNNN, fortlaufend ueber SponsorshipCounter.
|
||||
number String @unique
|
||||
ownerId String
|
||||
offerId String
|
||||
/// Einmalkauf: die Hauptposition der Bestellung. Eindeutig — das ist der
|
||||
/// Idempotenzschluessel gegen doppelt zugestellte Webhooks.
|
||||
orderItemId String? @unique
|
||||
/// Abo: das Abo, aus dem die Patenschaft entstand.
|
||||
subscriptionId String? @unique
|
||||
locale String @default("de")
|
||||
startDate DateTime @db.Date
|
||||
/// Leer bei laufendem Abo.
|
||||
endDate DateTime? @db.Date
|
||||
status SponsorshipStatus @default(AUSSTEHEND)
|
||||
/// Nummer der aktuellen Revision; 0 = noch keine Urkunde erzeugt.
|
||||
currentRevision Int @default(0)
|
||||
pdfAttempts Int @default(0)
|
||||
nextAttemptAt DateTime?
|
||||
lastError String? @db.Text
|
||||
mailSentAt DateTime?
|
||||
printedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner Customer @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
|
||||
orderItem OrderItem? @relation(fields: [orderItemId], references: [id], onDelete: SetNull)
|
||||
subscription SponsorshipSubscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||
revisions SponsorshipRevision[]
|
||||
transfers SponsorshipTransfer[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([offerId])
|
||||
@@map("sponsorships")
|
||||
}
|
||||
|
||||
/// Unveraenderlicher Stand einer Urkunde. Korrekturen und das Abo-Ende
|
||||
/// erzeugen neue Revisionen; alte bleiben samt PDF erhalten.
|
||||
model SponsorshipRevision {
|
||||
id String @id @default(cuid())
|
||||
sponsorshipId String
|
||||
revision Int
|
||||
/// Personalisierung wie bestaetigt (lib/certificate/schema.ts).
|
||||
personalization Json
|
||||
/// Laufzeitform (FIXED/OPEN/ENDED) mit Daten.
|
||||
term Json
|
||||
/// Aufgeloeste Textbausteine — Beleg dessen, was auf der Urkunde steht.
|
||||
text Json
|
||||
templateVersion String
|
||||
/// Relativ zu CERTIFICATE_DIR.
|
||||
pdfPath String
|
||||
pdfSha256 String
|
||||
/// Admin-Konto bei Korrekturen, null bei System-Revisionen.
|
||||
createdById String?
|
||||
/// Lesbarer Ersteller ("System" oder die Admin-Adresse) — bleibt lesbar,
|
||||
/// auch wenn das Admin-Konto spaeter geloescht wird.
|
||||
createdByLabel String
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sponsorshipId, revision])
|
||||
@@map("sponsorship_revisions")
|
||||
}
|
||||
|
||||
/// Jahreszaehler der Patenschaftsnummern, Muster wie InvoiceCounter.
|
||||
model SponsorshipCounter {
|
||||
year Int @id
|
||||
lastNumber Int @default(0)
|
||||
|
||||
@@map("sponsorship_counters")
|
||||
}
|
||||
|
||||
/// Befristeter, einmal nutzbarer Link zur Uebertragung einer
|
||||
/// Einmal-Patenschaft an ein anderes Konto. Gespeichert wird nur der Hash.
|
||||
model SponsorshipTransfer {
|
||||
id String @id @default(cuid())
|
||||
sponsorshipId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdById String
|
||||
claimedById String?
|
||||
claimedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sponsorshipId])
|
||||
@@map("sponsorship_transfers")
|
||||
}
|
||||
|
||||
/// Abo-Patenschaft ueber Stripe Billing.
|
||||
model SponsorshipSubscription {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
offerId String
|
||||
status SubscriptionStatus @default(AUSSTEHEND)
|
||||
/// Personalisierung beim Abschluss bestaetigt; daraus entsteht mit der
|
||||
/// ersten bezahlten Rechnung die Patenschaft.
|
||||
personalization Json
|
||||
locale String @default("de")
|
||||
stripeCheckoutSessionId String? @unique
|
||||
stripeSubscriptionId String? @unique
|
||||
startedAt DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
/// Gesetzt nach Kuendigung: Ende des bezahlten Zeitraums.
|
||||
cancelAt DateTime?
|
||||
endedAt DateTime?
|
||||
/// Beginn des Verzugs (fehlgeschlagene Zahlung oder Rueckbuchung).
|
||||
delinquentSince DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
|
||||
payments SubscriptionPayment[]
|
||||
deliveries SubscriptionDelivery[]
|
||||
sponsorship Sponsorship?
|
||||
|
||||
@@index([customerId])
|
||||
@@index([offerId, status])
|
||||
@@map("sponsorship_subscriptions")
|
||||
}
|
||||
|
||||
/// Eine bezahlte Abo-Monatsrechnung von Stripe.
|
||||
model SubscriptionPayment {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
/// Idempotenzschluessel gegen doppelt zugestellte invoice.paid-Ereignisse.
|
||||
stripeInvoiceId String @unique
|
||||
stripePaymentIntentId String?
|
||||
paymentMethodType String?
|
||||
amountCents Int
|
||||
paidAt DateTime
|
||||
periodStart DateTime
|
||||
periodEnd DateTime
|
||||
/// Ab wann diese Zahlung fuer den Lieferanspruch zaehlt (SEPA-Neukunden-
|
||||
/// Frist, siehe lib/sponsorship-subscription/entitlement.ts).
|
||||
countsFrom DateTime
|
||||
/// Zurueckgebucht — zaehlt nie mehr fuer eine Lieferung.
|
||||
reversedAt DateTime?
|
||||
/// Lieferung, die diese Zahlung verbraucht hat.
|
||||
deliveryId String?
|
||||
/// Wabenhain-Bestellung samt Rechnung zu dieser Zahlung.
|
||||
orderId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
delivery SubscriptionDelivery? @relation(fields: [deliveryId], references: [id], onDelete: SetNull)
|
||||
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([subscriptionId, paidAt])
|
||||
@@map("subscription_payments")
|
||||
}
|
||||
|
||||
/// Eine Abo-Honiglieferung (0-€-Bestellung mit Honigpositionen).
|
||||
model SubscriptionDelivery {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
dueAt DateTime
|
||||
jars Int
|
||||
status DeliveryStatus
|
||||
orderId String? @unique
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||
payments SubscriptionPayment[]
|
||||
|
||||
@@index([subscriptionId, dueAt])
|
||||
@@map("subscription_deliveries")
|
||||
}
|
||||
|
||||
/// Erntefenster eines Abo-Angebots mit Zyklus ERNTE.
|
||||
model HarvestWindow {
|
||||
id String @id @default(cuid())
|
||||
offerId String
|
||||
startsOn DateTime @db.Date
|
||||
endsOn DateTime @db.Date
|
||||
/// Fuer Abos geplante Glaeser — werden reserviert, bevor sie in den
|
||||
/// Freiverkauf gehen.
|
||||
plannedJars Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([offerId, startsOn])
|
||||
@@map("harvest_windows")
|
||||
}
|
||||
|
||||
/// Treuestufe (Stammkunde nach bezahlten Bestellungen, Abo nach bezahlten
|
||||
/// Monaten). threshold null = VIP (nur ABO, nach der hoechsten Zahl).
|
||||
model LoyaltyLevel {
|
||||
id String @id @default(cuid())
|
||||
kind LoyaltyKind
|
||||
threshold Int?
|
||||
name String
|
||||
nameEn String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([kind, threshold])
|
||||
@@map("loyalty_levels")
|
||||
}
|
||||
+115
@@ -83,6 +83,121 @@ async function main() {
|
||||
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
|
||||
);
|
||||
}
|
||||
|
||||
await seedSponsorshipDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startangebote der Bienenpatenschaft und Treuestufen — nur wenn es noch
|
||||
* keine gibt, und die Angebote INAKTIV: dieser Seed laeuft bei jedem
|
||||
* Containerstart auch in Produktion, und Preise, Honig und Rechtstexte muss
|
||||
* der Inhaber vor der Veroeffentlichung pruefen (Spec, "Geschaeftliche
|
||||
* Konfiguration"). Erst das Aktivieren im Admin macht /patenschaft sichtbar.
|
||||
*/
|
||||
async function seedSponsorshipDefaults(): Promise<void> {
|
||||
if ((await prisma.sponsorshipOffer.count()) === 0) {
|
||||
const packages = [
|
||||
{
|
||||
slug: "bienenfreund",
|
||||
name: "Bienenfreund",
|
||||
nameEn: "Bee Friend",
|
||||
priceCents: 1900,
|
||||
isPhysical: false,
|
||||
honeyJars: 0,
|
||||
medalTier: "BRONZE" as const,
|
||||
description: "Symbolische Namensgebung mit individuell gestalteter Urkunde als PDF.",
|
||||
descriptionEn: "Symbolic naming with an individually designed certificate as a PDF.",
|
||||
features: ["Symbolische Namensgebung", "Individuell gestaltete PDF-Urkunde", "Medaille und 3D-Ansicht im Kundenkonto", "Sicherer Download"],
|
||||
featuresEn: ["Symbolic naming", "Individually designed PDF certificate", "Medal and 3D view in your account", "Secure download"],
|
||||
},
|
||||
{
|
||||
slug: "honigfreund",
|
||||
name: "Honigfreund",
|
||||
nameEn: "Honey Friend",
|
||||
priceCents: 3900,
|
||||
isPhysical: true,
|
||||
honeyJars: 1,
|
||||
medalTier: "SILBER" as const,
|
||||
description: "Digitale Leistungen plus gedruckte Urkunde, ein Glas Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||||
descriptionEn: "Digital items plus a printed certificate, a jar of Wabenhain honey, a greeting card and gift wrapping.",
|
||||
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "1 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||||
featuresEn: ["All digital items", "High-quality printed certificate", "1 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||||
},
|
||||
{
|
||||
slug: "wabenfreund",
|
||||
name: "Wabenfreund",
|
||||
nameEn: "Comb Friend",
|
||||
priceCents: 5900,
|
||||
isPhysical: true,
|
||||
honeyJars: 3,
|
||||
medalTier: "GOLD" as const,
|
||||
description: "Digitale Leistungen plus gedruckte Urkunde, drei Gläser Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||||
descriptionEn: "Digital items plus a printed certificate, three jars of Wabenhain honey, a greeting card and gift wrapping.",
|
||||
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "3 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||||
featuresEn: ["All digital items", "High-quality printed certificate", "3 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||||
},
|
||||
];
|
||||
for (const [index, pkg] of packages.entries()) {
|
||||
// Honiganteil als Vorschlag (7 € je Glas) — der Inhaber prueft ihn mit
|
||||
// der Steuerberatung, bevor er das Angebot aktiviert.
|
||||
const honeyShareCentsPerJar = pkg.honeyJars > 0 ? 700 : 0;
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
slug: `patenschaft-${pkg.slug}`,
|
||||
name: `Bienenpatenschaft ${pkg.name}`,
|
||||
category: "PATENSCHAFT",
|
||||
description: pkg.description,
|
||||
weightGrams: 0,
|
||||
priceCents: pkg.priceCents - pkg.honeyJars * honeyShareCentsPerJar,
|
||||
pricePerKgCents: 0,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
await prisma.sponsorshipOffer.create({
|
||||
data: {
|
||||
slug: pkg.slug,
|
||||
kind: "EINMALIG",
|
||||
name: pkg.name,
|
||||
nameEn: pkg.nameEn,
|
||||
description: pkg.description,
|
||||
descriptionEn: pkg.descriptionEn,
|
||||
features: pkg.features,
|
||||
featuresEn: pkg.featuresEn,
|
||||
priceCents: pkg.priceCents,
|
||||
isActive: false,
|
||||
sortOrder: index,
|
||||
isPhysical: pkg.isPhysical,
|
||||
productId: product.id,
|
||||
honeyJars: pkg.honeyJars,
|
||||
honeyShareCentsPerJar,
|
||||
medalTier: pkg.medalTier,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log("Bienenpatenschaft: drei Startangebote angelegt (inaktiv, bitte im Admin pruefen und aktivieren).");
|
||||
}
|
||||
|
||||
if ((await prisma.loyaltyLevel.count()) === 0) {
|
||||
const regular = [
|
||||
{ threshold: 2, name: "Blütenbesuch", nameEn: "Blossom Visitor" },
|
||||
{ threshold: 5, name: "Wabenbau", nameEn: "Comb Builder" },
|
||||
{ threshold: 10, name: "Honigschatz", nameEn: "Honey Treasure" },
|
||||
{ threshold: 25, name: "Goldwabe", nameEn: "Golden Comb" },
|
||||
];
|
||||
const subscription = Array.from({ length: 24 }, (_, i) => ({
|
||||
threshold: i + 1,
|
||||
name: `Stufe ${i + 1}`,
|
||||
nameEn: `Level ${i + 1}`,
|
||||
}));
|
||||
await prisma.loyaltyLevel.createMany({
|
||||
data: [
|
||||
...regular.map((level) => ({ kind: "STAMMKUNDE" as const, ...level })),
|
||||
...subscription.map((level) => ({ kind: "ABO" as const, ...level })),
|
||||
{ kind: "ABO" as const, threshold: null, name: "VIP", nameEn: "VIP" },
|
||||
],
|
||||
});
|
||||
console.log("Treuestufen angelegt (Stammkunde 2/5/10/25, Abo 1–24 und VIP).");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Rendert Beispiel-Urkunden fuer die Sichtpruefung (Spec "Visuelle
|
||||
* Kontrolle"): je Vorlage und Datensatz ein PNG mit 300 dpi (2480 px breit)
|
||||
* und die Druck-PDF, dazu eine Vorschau mit Wasserzeichen.
|
||||
*
|
||||
* Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=a@1,b@1] [--samples=standard,lang]
|
||||
*
|
||||
* Schreibt nur in den angegebenen Ordner; keine Datenbank, keine Uploads.
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { layoutCertificate } from "../src/lib/certificate/layout";
|
||||
import { renderCertificateImage } from "../src/lib/certificate/render-image";
|
||||
import { certificatePdfTitle, renderCertificatePdf } from "../src/lib/certificate/render-pdf";
|
||||
import { fixedTermFor, type CertificateTerm, type Personalization } from "../src/lib/certificate/schema";
|
||||
import { listTemplateVersions } from "../src/lib/certificate/templates";
|
||||
import { composeCertificateText } from "../src/lib/certificate/text";
|
||||
import type { Locale } from "../src/i18n/config";
|
||||
|
||||
interface Sample {
|
||||
personalization: Personalization;
|
||||
locale: Locale;
|
||||
term: CertificateTerm;
|
||||
number: string;
|
||||
}
|
||||
|
||||
const START = "2026-12-24";
|
||||
const NUMBER = "WH-P-2026-00042";
|
||||
|
||||
function sample(personalization: Personalization, extra: Partial<Omit<Sample, "personalization">> = {}): Sample {
|
||||
return { personalization, locale: "de", term: fixedTermFor(personalization.startDate), number: NUMBER, ...extra };
|
||||
}
|
||||
|
||||
const MAX_DEDICATION =
|
||||
"Für die Frau, die jeden Garten zum Summen bringt.\nMöge diese kleine Biene dich an all die sonnigen Tage erinnern, die wir gemeinsam zwischen Lavendel und Linden verbracht haben.\n\nIn Liebe und Dankbarkeit,\nAnna, Ben und Carla";
|
||||
|
||||
const SAMPLES: Record<string, Sample> = {
|
||||
// Identische Beispieldaten fuer den Vergleich der drei Varianten.
|
||||
standard: sample({
|
||||
beeName: "Summsi",
|
||||
recipientName: "Marie-Luise Schmidt",
|
||||
giverNames: "Anna und Ben",
|
||||
dedication: "Für die beste Gärtnerin der Welt –\nmögen deine Blüten immer summen.",
|
||||
occasion: "WEIHNACHTEN",
|
||||
startDate: START,
|
||||
}),
|
||||
kurz: sample({ beeName: "Bo", recipientName: "Li", occasion: "SELBST", startDate: START }),
|
||||
lang: sample({
|
||||
beeName: "Hildegard-Wilhelmine",
|
||||
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden",
|
||||
giverNames: "Anna, Ben, Carla und Familie Özdemir aus dem Nachbarhaus",
|
||||
dedication: MAX_DEDICATION,
|
||||
occasion: "GEBURTSTAG",
|
||||
startDate: START,
|
||||
}),
|
||||
// An den Eingabelimits (CERTIFICATE_LIMITS): 23/24, 57/60, 80/80, 277/280 Zeichen, 5 Umbrueche.
|
||||
maximal: sample(
|
||||
{
|
||||
beeName: "Wilhelmine-Mümmelwunder",
|
||||
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden-Württemberg",
|
||||
giverNames: "Anna, Ben, Carla, Dominik, Emilia, Friedrich und die ganze Familie Özdemir-Lange",
|
||||
dedication: [
|
||||
"Möge diese kleine Biene dich an sonnige Tage im Garten erinnern,",
|
||||
"an Lindenblüten, Lavendel und an den Duft von frischem Honigbrot,",
|
||||
"an lange Abende auf der Bank vor dem Haus und an alle Gespräche.",
|
||||
"Danke für alles, was du für uns getan hast, jeden einzelnen Tag!",
|
||||
"Alles Liebe",
|
||||
"Anna",
|
||||
].join("\n"),
|
||||
occasion: "DANKESCHOEN",
|
||||
startDate: START,
|
||||
},
|
||||
{ locale: "de" }
|
||||
),
|
||||
sonderzeichen: sample(
|
||||
{
|
||||
beeName: "Zoë",
|
||||
recipientName: "Jürgen Weiß & Ñúñez O’Brien",
|
||||
giverNames: "Anna, Ben, Carla und Familie Özdemir",
|
||||
occasion: "DANKESCHOEN",
|
||||
startDate: START,
|
||||
},
|
||||
{ term: { kind: "OPEN", start: START } }
|
||||
),
|
||||
englisch: sample(
|
||||
{
|
||||
beeName: "Honeybell",
|
||||
recipientName: "Charlotte Ashworth-Blake",
|
||||
giverNames: "Oliver and Grace",
|
||||
dedication: "For the kindest gardener we know —\nmay your days always hum with sunshine.",
|
||||
occasion: "GEBURTSTAG",
|
||||
startDate: START,
|
||||
},
|
||||
{ locale: "en" }
|
||||
),
|
||||
beendet: sample(
|
||||
{ beeName: "Lindenblüte", recipientName: "Familie Wagner", occasion: "GESCHENK", startDate: START },
|
||||
{ term: { kind: "ENDED", start: START, end: "2027-05-31" } }
|
||||
),
|
||||
};
|
||||
|
||||
function parseList(flag: string): string[] | null {
|
||||
const arg = process.argv.find((value) => value.startsWith(`--${flag}=`));
|
||||
return arg ? arg.slice(flag.length + 3).split(",").filter(Boolean) : null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outDir = process.argv[2];
|
||||
if (!outDir || outDir.startsWith("--")) {
|
||||
throw new Error("Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=…] [--samples=…]");
|
||||
}
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const templates = parseList("templates") ?? listTemplateVersions();
|
||||
const samples = parseList("samples") ?? Object.keys(SAMPLES);
|
||||
|
||||
for (const version of templates) {
|
||||
for (const name of samples) {
|
||||
const data = SAMPLES[name];
|
||||
if (!data) throw new Error(`Unbekannter Datensatz "${name}".`);
|
||||
const text = composeCertificateText(data);
|
||||
const layout = layoutCertificate(text, version);
|
||||
const base = path.join(outDir, `${version.replace("@", "-v")}_${name}`);
|
||||
const [png, pdf] = await Promise.all([
|
||||
renderCertificateImage(layout, { widthPx: 2480, format: "png" }),
|
||||
renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: new Date(`${START}T00:00:00Z`) }),
|
||||
]);
|
||||
await writeFile(`${base}.png`, png);
|
||||
await writeFile(`${base}.pdf`, pdf);
|
||||
if (name === "standard") {
|
||||
const preview = await renderCertificateImage(layout, { widthPx: 1240, format: "png", watermark: data.locale });
|
||||
await writeFile(`${base}_vorschau.png`, preview);
|
||||
}
|
||||
process.stdout.write(`${base}.png / .pdf\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Rendert Beispiel-Medaillen aller Arten und Stufen als PNG (und SVG) zur
|
||||
* Sichtpruefung. Aufruf: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { medalReliefSvg, renderMedalPng, renderMedalSvg, type MedalInput } from "../src/lib/medals";
|
||||
|
||||
const SAMPLES: Array<[string, MedalInput]> = [
|
||||
["patenschaft-bronze", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Summsi", year: 2026, number: "WH-P-2026-00042" }],
|
||||
["patenschaft-silber", { kind: "PATENSCHAFT", tier: "SILBER", beeName: "Zoë", year: 2026, number: "WH-P-2026-00043" }],
|
||||
["patenschaft-gold", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Ñúñez", year: 2026, number: "WH-P-2026-00044" }],
|
||||
["patenschaft-abo", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Hildegard", year: 2026, number: "WH-P-2026-00045" }],
|
||||
["patenschaft-lang-24", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Wilhelmine Augusta Maxi", year: 2026, number: "WH-P-2026-00046" }],
|
||||
["patenschaft-lang-ohne-umbruch", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Bienenköniginwunderbarer", year: 2026, number: "WH-P-2026-00047" }],
|
||||
["patenschaft-abo-lang", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Wilhelmine-Augusta Maxi", year: 2026, number: "WH-P-2026-00048" }],
|
||||
["stammkunde-1", { kind: "STAMMKUNDE", level: 1, name: "Blütenbesuch" }],
|
||||
["stammkunde-2", { kind: "STAMMKUNDE", level: 2, name: "Wabenbau" }],
|
||||
["stammkunde-3", { kind: "STAMMKUNDE", level: 3, name: "Honigschatz" }],
|
||||
["stammkunde-4", { kind: "STAMMKUNDE", level: 4, name: "Goldwabe" }],
|
||||
["abo-1", { kind: "ABO", months: 1 }],
|
||||
["abo-12", { kind: "ABO", months: 12 }],
|
||||
["abo-24", { kind: "ABO", months: 24 }],
|
||||
["vip", { kind: "VIP" }],
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const target = process.argv[2];
|
||||
if (!target) throw new Error("Zielordner fehlt: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]");
|
||||
const size = Number(process.argv[3] ?? 1024);
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const [name, input] of SAMPLES) {
|
||||
await writeFile(path.join(target, `${name}.png`), await renderMedalPng(input, size));
|
||||
await writeFile(path.join(target, `${name}.svg`), renderMedalSvg(input));
|
||||
if (name === "patenschaft-gold") await writeFile(path.join(target, `${name}-relief.svg`), medalReliefSvg(input));
|
||||
}
|
||||
process.stdout.write(`${SAMPLES.length} Medaillen in ${target}\n`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Stripe-Einrichtung fuer das Abo der Bienenpatenschaft (Spec
|
||||
* "Stripe-Einrichtung", docs/EINRICHTUNG.md).
|
||||
*
|
||||
* Wiederholbar und ohne Geheimnisse im Code: liest STRIPE_SECRET_KEY aus der
|
||||
* Umgebung (.env), vergleicht den Ist-Stand mit dem Soll und zeigt, was es
|
||||
* aendern wuerde. Ohne --apply wird NICHTS geschrieben.
|
||||
*
|
||||
* pnpm tsx scripts/stripe-setup.ts # Probelauf (Standard)
|
||||
* pnpm tsx scripts/stripe-setup.ts --apply # Aenderungen schreiben
|
||||
* pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
|
||||
*
|
||||
* Gegen ein Livekonto (sk_live_/rk_live_) schreibt es nur mit dem
|
||||
* zusaetzlichen Schalter --live-ok — erst nach ausdruecklicher Freigabe des
|
||||
* Inhabers.
|
||||
*
|
||||
* Was es einrichtet:
|
||||
* 1. Webhook-Endpunkt <BASE_URL>/api/checkout/webhook mit allen Ereignissen
|
||||
* (die bestehenden sechs plus die Abo-Ereignisse). Bestehende Ereignisse
|
||||
* bleiben, es wird nur ergaenzt.
|
||||
* 2. Kundenportal-Konfiguration, die NUR das Aendern der Zahlungsart erlaubt
|
||||
* (keine Kuendigung, kein Tarifwechsel, keine Kundendaten, keine
|
||||
* Stripe-Rechnungen). Die App findet sie ueber ihre Metadaten.
|
||||
* Was nur im Dashboard geht, gibt es am Ende als Checkliste aus.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const REQUIRED_EVENTS = [
|
||||
// bestehend (Einmalbestellungen)
|
||||
"checkout.session.completed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"charge.dispute.created",
|
||||
// Abo
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"charge.dispute.closed",
|
||||
] as const satisfies readonly Stripe.WebhookEndpointCreateParams.EnabledEvent[];
|
||||
|
||||
/** Muss zu PORTAL_METADATA_VALUE in src/lib/sponsorship-subscription/cancellation.ts passen. */
|
||||
const PORTAL_METADATA = { wabenhain: "abo-zahlungsart" } as const;
|
||||
|
||||
const PORTAL_FEATURES = {
|
||||
payment_method_update: { enabled: true },
|
||||
subscription_cancel: { enabled: false },
|
||||
subscription_update: { enabled: false },
|
||||
customer_update: { enabled: false },
|
||||
invoice_history: { enabled: false },
|
||||
} as const;
|
||||
|
||||
interface Options {
|
||||
apply: boolean;
|
||||
liveOk: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[]): Options {
|
||||
const baseArg = argv.find((arg) => arg.startsWith("--base-url="))?.slice("--base-url=".length);
|
||||
const baseUrl = (baseArg || process.env.NEXT_PUBLIC_SITE_URL || "https://wabenhain.com").replace(/\/+$/, "");
|
||||
return { apply: argv.includes("--apply"), liveOk: argv.includes("--live-ok"), baseUrl };
|
||||
}
|
||||
|
||||
function log(line: string): void {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
async function ensureWebhook(stripe: Stripe, options: Options): Promise<void> {
|
||||
const url = `${options.baseUrl}/api/checkout/webhook`;
|
||||
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
|
||||
const existing = endpoints.data.find((endpoint) => endpoint.url === url);
|
||||
|
||||
if (!existing) {
|
||||
log(`Webhook: kein Endpunkt fuer ${url} — ${options.apply ? "wird angelegt" : "wuerde angelegt"} mit ${REQUIRED_EVENTS.length} Ereignissen.`);
|
||||
if (!options.apply) return;
|
||||
const created = await stripe.webhookEndpoints.create({
|
||||
url,
|
||||
enabled_events: [...REQUIRED_EVENTS],
|
||||
description: "Wabenhain Shop (Bestellungen und Abos)",
|
||||
});
|
||||
log(` angelegt: ${created.id}. Das Signatur-Geheimnis steht NUR in dieser Antwort und im Dashboard:`);
|
||||
log(" im Dashboard unter Entwickler → Webhooks → Endpunkt → Signaturgeheimnis anzeigen, als STRIPE_WEBHOOK_SECRET setzen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = new Set<string>(existing.enabled_events);
|
||||
if (enabled.has("*")) {
|
||||
log(`Webhook ${existing.id}: empfaengt alle Ereignisse ("*") — nichts zu tun.`);
|
||||
return;
|
||||
}
|
||||
const missing = REQUIRED_EVENTS.filter((event) => !enabled.has(event));
|
||||
if (missing.length === 0) {
|
||||
log(`Webhook ${existing.id}: alle ${REQUIRED_EVENTS.length} Ereignisse abonniert — nichts zu tun.`);
|
||||
return;
|
||||
}
|
||||
log(`Webhook ${existing.id}: ${missing.length} Ereignisse fehlen — ${options.apply ? "werden ergaenzt" : "wuerden ergaenzt"}:`);
|
||||
for (const event of missing) log(` + ${event}`);
|
||||
if (existing.status !== "enabled") log(` Hinweis: Endpunkt ist "${existing.status}".`);
|
||||
if (!options.apply) return;
|
||||
await stripe.webhookEndpoints.update(existing.id, {
|
||||
enabled_events: [...existing.enabled_events, ...missing] as Stripe.WebhookEndpointUpdateParams.EnabledEvent[],
|
||||
});
|
||||
log(" ergaenzt.");
|
||||
}
|
||||
|
||||
function portalDeviations(config: Stripe.BillingPortal.Configuration): string[] {
|
||||
const features = config.features;
|
||||
const actual: Record<keyof typeof PORTAL_FEATURES, boolean> = {
|
||||
payment_method_update: features.payment_method_update.enabled,
|
||||
subscription_cancel: features.subscription_cancel.enabled,
|
||||
subscription_update: features.subscription_update.enabled,
|
||||
customer_update: features.customer_update.enabled,
|
||||
invoice_history: features.invoice_history.enabled,
|
||||
};
|
||||
return (Object.keys(PORTAL_FEATURES) as (keyof typeof PORTAL_FEATURES)[])
|
||||
.filter((key) => actual[key] !== PORTAL_FEATURES[key].enabled)
|
||||
.map((key) => `${key}: ${actual[key]} → ${PORTAL_FEATURES[key].enabled}`);
|
||||
}
|
||||
|
||||
async function ensurePortal(stripe: Stripe, options: Options): Promise<void> {
|
||||
const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 100 });
|
||||
const existing = configs.data.find((config) => config.metadata?.wabenhain === PORTAL_METADATA.wabenhain);
|
||||
const defaultReturnUrl = `${options.baseUrl}/konto/patenschaft`;
|
||||
|
||||
if (!existing) {
|
||||
log(`Kundenportal: keine Wabenhain-Konfiguration — ${options.apply ? "wird angelegt" : "wuerde angelegt"} (nur Zahlungsart aendern).`);
|
||||
if (!options.apply) return;
|
||||
const created = await stripe.billingPortal.configurations.create({
|
||||
name: "Wabenhain Abo – nur Zahlungsart",
|
||||
features: PORTAL_FEATURES,
|
||||
default_return_url: defaultReturnUrl,
|
||||
metadata: PORTAL_METADATA,
|
||||
});
|
||||
log(` angelegt: ${created.id} (die App findet sie ueber die Metadaten; optional STRIPE_PORTAL_CONFIGURATION_ID=${created.id}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviations = portalDeviations(existing);
|
||||
if (deviations.length === 0) {
|
||||
log(`Kundenportal ${existing.id}: erlaubt nur das Aendern der Zahlungsart — nichts zu tun.`);
|
||||
return;
|
||||
}
|
||||
log(`Kundenportal ${existing.id}: weicht ab — ${options.apply ? "wird korrigiert" : "wuerde korrigiert"}:`);
|
||||
for (const deviation of deviations) log(` ~ ${deviation}`);
|
||||
if (!options.apply) return;
|
||||
await stripe.billingPortal.configurations.update(existing.id, { features: PORTAL_FEATURES, default_return_url: defaultReturnUrl });
|
||||
log(" korrigiert.");
|
||||
}
|
||||
|
||||
function printDashboardChecklist(): void {
|
||||
log("");
|
||||
log("Nur im Stripe-Dashboard einstellbar (siehe docs/EINRICHTUNG.md):");
|
||||
log(" [ ] Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen: Smart Retries an.");
|
||||
log(" [ ] … nach dem letzten erfolglosen Versuch: Abo kuendigen (nicht 'als unbezahlt markieren').");
|
||||
log(" [ ] … Kunden-E-Mails: Rechnungen/Belege fuer erfolgreiche Zahlungen, bevorstehende Verlaengerungen und");
|
||||
log(" fehlgeschlagene Zahlungen AUS — Wabenhain verschickt eigene Rechnungen und Mails.");
|
||||
log(" [ ] Einstellungen → Zahlungsmethoden: Karte, Apple Pay, Google Pay, SEPA-Lastschrift aktiv (Google Pay pruefen).");
|
||||
log(" [ ] Apple Pay / Google Pay im eingebetteten Checkout: Domain wabenhain.com unter Zahlungsmethoden-Domains registrieren.");
|
||||
log(" [ ] Stripe Tax nur mit aktiver Registrierung einschalten — sonst bleibt es aus (Preise sind Bruttopreise).");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
const key = process.env.STRIPE_SECRET_KEY?.trim();
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
|
||||
const live = /^(sk|rk)_live_/.test(key);
|
||||
if (live && options.apply && !options.liveOk) {
|
||||
throw new Error("Livekonto: --apply schreibt hier nur zusammen mit --live-ok (nach Freigabe des Inhabers).");
|
||||
}
|
||||
|
||||
log(`Stripe-Einrichtung (${live ? "LIVEMODUS" : "Testmodus"}, ${options.apply ? "SCHREIBT" : "Probelauf, schreibt nichts"})`);
|
||||
log(`Basis-URL: ${options.baseUrl}`);
|
||||
log("");
|
||||
const stripe = new Stripe(key);
|
||||
await ensureWebhook(stripe, options);
|
||||
await ensurePortal(stripe, options);
|
||||
printDashboardChecklist();
|
||||
if (!options.apply) log("\nProbelauf beendet. Mit --apply schreiben.");
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`Fehler: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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
|
||||
// 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.
|
||||
vatRatePercent: vatRateForProduct(product, process.env),
|
||||
};
|
||||
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,7 +711,9 @@ export function CheckoutForm({
|
||||
</div>
|
||||
<div className="flex justify-between text-ink/70">
|
||||
<span>
|
||||
{fulfillment === "VERSAND"
|
||||
{digitalOnly
|
||||
? sponsorshipStrings.checkout.digitalShipping
|
||||
: fulfillment === "VERSAND"
|
||||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||
: t.checkoutPage.shippingPickup}
|
||||
</span>
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
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