Compare commits
17
Commits
85d1a46f64
...
main
No files matched your search
@@ -9,6 +9,11 @@ DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=p
|
|||||||
# MySQL/MariaDB stattdessen:
|
# MySQL/MariaDB stattdessen:
|
||||||
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
||||||
|
|
||||||
|
# Optional: Groesse des Datenbank-Verbindungspools PRO Instanz (Standard 20,
|
||||||
|
# siehe src/lib/db-adapter.ts). Beim Hochskalieren mitziehen, damit
|
||||||
|
# Instanzen x Poolgroesse plus CronJobs unter max_connections bleiben.
|
||||||
|
# DATABASE_POOL_SIZE=20
|
||||||
|
|
||||||
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
||||||
POSTGRES_USER=wabenhain
|
POSTGRES_USER=wabenhain
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
@@ -31,6 +36,11 @@ STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
|||||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
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
|
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
||||||
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
||||||
@@ -238,6 +248,18 @@ UPLOAD_DIR=./uploads
|
|||||||
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
||||||
INVOICE_DIR=./invoices
|
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.
|
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
|
||||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||||
# 19 = voller Satz
|
# 19 = voller Satz
|
||||||
@@ -265,6 +287,8 @@ VAT_RATE_PERCENT_KERZEN=19
|
|||||||
VAT_RATE_PERCENT_PROPOLIS=19
|
VAT_RATE_PERCENT_PROPOLIS=19
|
||||||
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher)
|
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher)
|
||||||
VAT_RATE_PERCENT_SONSTIGES=19
|
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
|
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
||||||
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
# (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.
|
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
||||||
/invoices
|
/invoices
|
||||||
|
|
||||||
|
# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR).
|
||||||
|
/certificates
|
||||||
@@ -4,6 +4,17 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
|
|
||||||
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
|
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
Was gebaut, geprüft, gemergt und ausgerollt ist, was offen ist und was nur der Inhaber erledigen kann, steht in den Gitea-Issues. Einstieg ist das Übersichts-Issue **[Projektstand und Übersicht](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/34)**, die Aufgaben des Inhabers sammelt **[Inhaber-Aufgaben](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/33)**.
|
||||||
|
|
||||||
|
Kurzfassung (Stand 2026-10-16):
|
||||||
|
|
||||||
|
- Produktiv läuft der Build `sha-0d254db` (Client-IP-Härtung vom 2026-10-12). Die drei neueren Commits auf `main` (Abhängigkeits-Updates, Qualitäts-Gate in der CI) sind gebaut, aber noch nicht ausgerollt.
|
||||||
|
- Im Cluster laufen App, Datenbank und die tägliche Sicherung `wabenhain-db-backup`. Die CronJobs `wabenhain-release-stale-orders` und `wabenhain-apicore-stock` sind angelegt, aber ausgesetzt (`suspend: true`).
|
||||||
|
- Für den Bestandsabgleich mit ApiCore fehlt im Cluster-Secret `APICORE_WEBHOOK_SECRET`, für die ERPNext-Schnittstelle `ERP_EXPORT_TOKEN`.
|
||||||
|
- Offene Spezifikation: [Versandstaffel je Kategorie](docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md) (freigegeben, nicht umgesetzt).
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
|
|
||||||
| Bereich | Technologie |
|
| Bereich | Technologie |
|
||||||
@@ -14,7 +25,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
|
| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
|
||||||
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
|
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
|
||||||
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
|
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
|
||||||
| Payment | Stripe Checkout |
|
| Payment | Stripe Checkout, eingebettet (`ui_mode: "embedded_page"`) |
|
||||||
| Hosting | Docker / Node.js |
|
| Hosting | Docker / Node.js |
|
||||||
|
|
||||||
## Was ist enthalten
|
## Was ist enthalten
|
||||||
@@ -38,7 +49,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
||||||
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
||||||
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
||||||
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**)
|
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Cookie-Richtlinie, Versand und Zahlung sowie die elektronische Widerrufsfunktion „Vertrag widerrufen“ (`/vertrag-widerrufen`, Eingänge unter Admin → Widerrufe). Die Texte sind ausformuliert und im Admin unter „Inhalte“ pflegbar; `pnpm preflight` meldet verbliebene Platzhalter. Eine anwaltliche Prüfung steht noch aus.
|
||||||
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
|
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
|
||||||
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
||||||
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
|
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
|
||||||
@@ -59,8 +70,8 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
|
|
||||||
### Bewusst vereinfacht
|
### Bewusst vereinfacht
|
||||||
|
|
||||||
- **Bilder**: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt.
|
- **Bilder**: Fotos für Produkte, Ernten, Chargen, Inhalte, Logo und das Hero-Video lädt der Admin hoch (siehe `k8s/README.md` zu Speicher und Video-Upload). Solange zu einer Fläche kein Foto hinterlegt ist, erscheint ein Platzhalter im Stil des Glas-Etiketts.
|
||||||
- Rechtstexte sind Platzhalter und **müssen** vor Live-Gang anwaltlich geprüft werden.
|
- Rechtstexte sind ausformuliert, **müssen** aber anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
## Admin-Zugang
|
## Admin-Zugang
|
||||||
|
|
||||||
@@ -400,7 +411,7 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc
|
|||||||
|
|
||||||
Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert".
|
Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert".
|
||||||
|
|
||||||
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
|
Unabhängig davon **müssen** die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
## Rechnungen
|
## Rechnungen
|
||||||
|
|
||||||
@@ -418,16 +429,16 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge
|
|||||||
|
|
||||||
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
|
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
|
||||||
|
|
||||||
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump. Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume.
|
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump auf die Storage Box (30 Tage Aufbewahrung). Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume. Die zehnjährige Aufbewahrung nach § 147 AO deckt das nicht ab. Vorgesehen ist dafür, dass ERPNext jede Rechnung samt PDF über `/api/erp/rechnungen` übernimmt; im Cluster fehlt dafür Stand 2026-10-16 noch `ERP_EXPORT_TOKEN` (siehe „Projektstand“).
|
||||||
|
|
||||||
> Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS).
|
> Bei **mehr als einem Knoten**: im Repo ist das PVC `wabenhain-invoices` als `ReadWriteOnce` angelegt und ließe sich nur von Pods auf demselben Knoten einhängen; landet der Sicherungs-Pod woanders als die App, bleibt er ohne Fehlermeldung in „Pending“. Im Cluster sind `wabenhain-invoices` und `wabenhain-uploads` deshalb bereits `ReadWriteMany` auf SMB (Stand 2026-10-16); das Repo-Manifest weicht davon ab.
|
||||||
|
|
||||||
### Umsatzsteuer
|
### Umsatzsteuer
|
||||||
|
|
||||||
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
|
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
|
||||||
|
|
||||||
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
|
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
|
||||||
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`.
|
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`, `VAT_RATE_PERCENT_SONSTIGES` (weitere Bienenprodukte wie Salben oder Wachstücher, Vorgabe in `k8s/01-configmap.yaml`: 19).
|
||||||
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
|
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
|
||||||
|
|
||||||
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
|
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
|
||||||
@@ -501,7 +512,9 @@ Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der D
|
|||||||
|
|
||||||
## Kubernetes / Gitea CI
|
## Kubernetes / Gitea CI
|
||||||
|
|
||||||
`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
|
`.gitea/workflows/docker-build.yml` prüft bei jedem Push auf `main` zuerst `pnpm lint`, `pnpm audit --prod --audit-level high`, `pnpm typecheck` und `pnpm test` (Runner `ci-isolated`). Nur wenn alle grün sind, baut der Job `build-and-push` (Runner `docker-builder`) zwei Images und veröffentlicht sie in der Gitea-eigenen Container-Registry: `wabenhain` (App) und `wabenhain-tools` (Skripte für die CronJobs), jeweils mit den Tags `latest` und `sha-<kurzer Commit>`. Der App-Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig.
|
||||||
|
|
||||||
|
Die Pipeline wendet **keine** Manifeste an und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt; ein neuer Build geht per `kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>` in Betrieb, danach wird `image:` in `k8s/05-app-deployment.yaml` nachgezogen. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
|
||||||
|
|
||||||
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
|
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
|
||||||
|
|
||||||
@@ -513,36 +526,37 @@ Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe
|
|||||||
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
|
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
|
||||||
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
|
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
|
||||||
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
|
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
|
||||||
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Ist dieser letzte Eintrag eine Cloudflare-Edge, gilt stattdessen `CF-Connecting-IP`; IPv6-Adressen zählen je /64-Netz (`lib/rate-limit.ts`). Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
||||||
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
|
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
|
||||||
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
|
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
|
||||||
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
|
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
|
||||||
- **Abhängigkeits-Sicherheitsscan**: `pnpm audit` vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive `nanoid`-DoS-Lücke über `pnpm-workspace.yaml`s `overrides` gepatcht).
|
- **Abhängigkeits-Sicherheitsscan**: Die CI bricht ab, sobald `pnpm audit --prod --audit-level high` eine Lücke ab „high“ in Laufzeit-Abhängigkeiten findet; ohne grünen Audit entsteht kein Image. Lokal mit `pnpm audit` prüfen (siehe „Nützliche Befehle“ unten). Zuletzt geschlossen wurden High-/Critical-Advisories in Commit `835323e` (2026-10-14).
|
||||||
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
|
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
|
||||||
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
|
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
|
||||||
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
|
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
|
||||||
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
|
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
|
||||||
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
|
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
|
||||||
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
||||||
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren).
|
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren); hinter Cloudflare aus `CF-Connecting-IP`, siehe Rate-Limiting oben.
|
||||||
|
- **Nur über Cloudflare erreichbar**: Der Ingress hängt an der Middleware `wabenhain-cloudflare-only` (nur Cloudflare-Adressen und private Netze), direkte Zugriffe auf den Origin beantwortet Traefik mit 403. Die Middleware liegt nicht in diesem Repo, sondern in den Cluster-Manifesten (siehe Kommentar in `k8s/06-ingress.yaml`).
|
||||||
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
||||||
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
|
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
|
||||||
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
||||||
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`.
|
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestellnummern (`WH-<Jahr>-<8 Hex-Zeichen>`) kommen aus `crypto.randomBytes`; Rechnungsnummern sind dagegen fortlaufend (siehe „Rechnungsnummer“).
|
||||||
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
||||||
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
|
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
|
||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
|
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
|
||||||
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `Payment`, `Shipment`, `Pickup`, `Invoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `NewsletterSubscriber`, `BusinessInquiry`.
|
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `ApiCoreStockEvent`, `Payment`, `Shipment`, `Pickup`, `InvoiceCounter`, `Invoice`, `CancellationCounter`, `CancellationInvoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `SiteContent`, `NewsletterSubscriber`, `BusinessInquiry`, `WithdrawalDeclaration`.
|
||||||
|
|
||||||
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
|
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- **Unit-/Integrationstests** (`src/**/*.test.ts`, [Vitest](https://vitest.dev/)): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — `lib/shipping-calc.ts`, `lib/discount-calc.ts`, `lib/format.ts` sowie die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen.
|
- **Unit-/Integrationstests** (`src/**/*.test.ts(x)`, [Vitest](https://vitest.dev/), Stand 2026-10-16: 65 Testdateien, die meisten unter `src/lib`): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — unter anderem Versand- und Rabattberechnung, Umsatzsteuer, Rechnung und E-Rechnung, Checkout-, Abbruch- und Webhook-Route, Bestandsabgleich mit ApiCore, Sessions, CSRF, Client-IP und die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen; die CI führt sie bei jedem Push aus.
|
||||||
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden.
|
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. Die CI führt sie bisher **nicht** aus.
|
||||||
|
|
||||||
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
|
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
|
||||||
|
|
||||||
@@ -554,7 +568,13 @@ Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bu
|
|||||||
pnpm dev # Entwicklungsserver
|
pnpm dev # Entwicklungsserver
|
||||||
pnpm build # Produktions-Build
|
pnpm build # Produktions-Build
|
||||||
pnpm lint # ESLint
|
pnpm lint # ESLint
|
||||||
|
pnpm typecheck # TypeScript ohne Ausgabe (erzeugt vorher Prisma-Clients und Next-Routentypen)
|
||||||
|
pnpm test # Vitest (Unit-/Komponententests)
|
||||||
|
pnpm test:e2e # Playwright gegen eine laufende Instanz
|
||||||
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
|
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
|
||||||
|
pnpm orders:release-stale # haengende, bei Stripe abgelaufene Bestellungen freigeben (wie CronJob)
|
||||||
|
pnpm stock:apicore-sync # Bestandsabgleich mit ApiCore nachholen (wie CronJob)
|
||||||
|
pnpm places:find "Name Ort" # Google-Place-ID nachschlagen
|
||||||
pnpm db:studio # Prisma Studio (DB-GUI)
|
pnpm db:studio # Prisma Studio (DB-GUI)
|
||||||
pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
|
pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
|
||||||
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
|
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
|
||||||
|
|||||||
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,59 @@ 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.
|
**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
|
||||||
|
|
||||||
|
**Stand Livekonto (2026-10-16, eingerichtet):**
|
||||||
|
|
||||||
|
- Webhook `https://wabenhain.com/api/checkout/webhook` mit allen zwölf
|
||||||
|
Ereignissen (die sechs bisherigen plus `charge.dispute.closed`,
|
||||||
|
`customer.subscription.created/updated/deleted`, `invoice.paid`,
|
||||||
|
`invoice.payment_failed`). Das Signatur-Geheimnis bleibt dasselbe.
|
||||||
|
- Kundenportal `bpc_1URCIfEvHaDWQ1U0nD4jVWXc`: nur Zahlungsart ändern, keine
|
||||||
|
Kündigung, kein Tarifwechsel, keine Kundendaten, keine Rechnungshistorie.
|
||||||
|
Steht als `STRIPE_PORTAL_CONFIGURATION_ID` in `k8s/01-configmap.yaml`.
|
||||||
|
- Zahlungsmethoden-Domains `wabenhain.com` und `www.wabenhain.com` registriert
|
||||||
|
(Apple Pay, Google Pay, PayPal, Link, Klarna im eingebetteten Checkout).
|
||||||
|
|
||||||
|
**Nur im Dashboard möglich (Stripe bietet dafür keine API):**
|
||||||
|
|
||||||
|
1. Einstellungen → Billing → Abos und E-Mails → Wiederholungsversuche:
|
||||||
|
Smart Retries an, nach dem letzten Versuch **Abo kündigen**.
|
||||||
|
2. Einstellungen → Billing → Abos und E-Mails → Kunden-E-Mails: Mails zu
|
||||||
|
erfolgreichen Zahlungen, anstehenden Verlängerungen und fehlgeschlagenen
|
||||||
|
Zahlungen **aus** (Wabenhain verschickt eigene Rechnungen und Mails).
|
||||||
|
3. Einstellungen → Zahlungsmethoden: Google Pay ist in der Konfiguration
|
||||||
|
„Default“ als nicht verfügbar markiert — dort aktivieren.
|
||||||
|
|
||||||
|
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
|
## 6. Optionale Dienste
|
||||||
@@ -272,6 +325,8 @@ Alle folgenden Werte dürfen leer bleiben. Die Funktion ruht dann, der Shop läu
|
|||||||
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
|
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
|
||||||
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
|
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
|
||||||
| **ApiCore-Synchronisierung** | `APICORE_API_KEY` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | API-Schlüssel aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel, nur Berechtigung `shop-sync:read`; kein Benutzerkonto, kein Passwort |
|
| **ApiCore-Synchronisierung** | `APICORE_API_KEY` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | API-Schlüssel aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel, nur Berechtigung `shop-sync:read`; kein Benutzerkonto, kein Passwort |
|
||||||
|
| **Bestandsabgleich mit ApiCore** | zusätzlich `APICORE_WEBHOOK_SECRET` (Secret), Berechtigung `shop-sync:write` am Schlüssel, CronJob `wabenhain-apicore-stock` | Der Webhook `/api/apicore/webhook` antwortet mit 503, Entnahmen in ApiCore kommen erst mit dem nächsten Abgleich an | ApiCore → Automatisierung → Webhooks: Webhook für `stock.changed` auf `https://wabenhain.com/api/apicore/webhook`, dessen Secret hier eintragen. Einzelheiten im README, Abschnitt „Bestandsabgleich“ |
|
||||||
|
| **ERPNext-Export** (Rechnungen, Stornorechnungen, Kunden, Firmenanfragen) | `ERP_EXPORT_TOKEN` (Secret), mindestens 32 Zeichen | Die Endpunkte unter `/api/erp/…` lehnen jeden Abruf ab, ERPNext übernimmt nichts | Zufallswert erzeugen (wie `SESSION_SECRET` in Abschnitt 2) und denselben Wert in ERPNext als Token für Wabenhain hinterlegen |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -302,6 +357,8 @@ cp k8s/02-secret.example.yaml k8s/02-secret.yaml
|
|||||||
| `TURNSTILE_SECRET_KEY` | empfohlen | Turnstile-Secret | 6 |
|
| `TURNSTILE_SECRET_KEY` | empfohlen | Turnstile-Secret | 6 |
|
||||||
| `GOOGLE_PLACES_API_KEY` | nein | Google-API-Schlüssel | 6 |
|
| `GOOGLE_PLACES_API_KEY` | nein | Google-API-Schlüssel | 6 |
|
||||||
| `APICORE_API_KEY` | nein | ApiCore-API-Schlüssel (`ack_…`) | 6 |
|
| `APICORE_API_KEY` | nein | ApiCore-API-Schlüssel (`ack_…`) | 6 |
|
||||||
|
| `APICORE_WEBHOOK_SECRET` | nein | Secret des ApiCore-Webhooks `stock.changed` | 6 |
|
||||||
|
| `ERP_EXPORT_TOKEN` | nein | Bearer-Token für den ERPNext-Export, ≥ 32 Zeichen | 6 |
|
||||||
| `DHL_*` (6 Werte) | nein | DHL-Zugangsdaten | 6 |
|
| `DHL_*` (6 Werte) | nein | DHL-Zugangsdaten | 6 |
|
||||||
|
|
||||||
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
|
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
|
||||||
@@ -364,6 +421,15 @@ kubectl -n wabenhain patch secret wabenhain-secrets --type=json -p '[{"op":"remo
|
|||||||
|
|
||||||
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
|
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
|
||||||
|
|
||||||
|
**Neuen Build ausrollen:** Die Pipeline baut nur Images und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt, ein `rollout restart` startet deshalb nur denselben Build neu. Einen neuen Build setzt man so ein (Digest aus der Gitea-Registry, Paket `wabenhain`, Tag `sha-<kurzer Commit>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
|
||||||
|
kubectl -n wabenhain rollout status deploy/wabenhain-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `image:` in `k8s/05-app-deployment.yaml` auf denselben Wert ziehen. Einzelheiten: `k8s/README.md`, Abschnitt „Bei einem neuen Image“.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Prüfen
|
## 9. Prüfen
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
# Bienenpatenschaft: Urkunde, Abo, Medaillen und 3D-Ansicht im Konto
|
||||||
|
|
||||||
|
Datum: 2026-10-16
|
||||||
|
Status: Entwurf zur Freigabe, Implementierung ausstehend
|
||||||
|
Gitea: #31
|
||||||
|
|
||||||
|
Dieses Dokument ersetzt den bisherigen Implementierungsauftrag zur
|
||||||
|
Bienenpatenschaft vollständig. Grundlage sind die verbindlichen Vorgaben des
|
||||||
|
Inhabers vom 2026-10-16 und seine Ergänzungen vom selben Tag (Abo,
|
||||||
|
Kundenkonto, 3D-Ansicht, Medaillen, Treuestufen, Übertragung). Wo ältere
|
||||||
|
Fassungen etwas anderes sagen, gilt dieses Dokument.
|
||||||
|
|
||||||
|
## Problem und Ziel
|
||||||
|
|
||||||
|
Wabenhain verkauft symbolische Bienenpatenschaften als Geschenk oder für sich
|
||||||
|
selbst, einmalig bezahlt oder als monatliches Abo. Das zentrale Produkt ist
|
||||||
|
eine hochwertige, persönlich gestaltete Urkunde. Im Kundenkonto erleben
|
||||||
|
Kunden ihre Urkunde und ihre Medaillen in einer interaktiven 3D-Ansicht und
|
||||||
|
laden alles herunter.
|
||||||
|
|
||||||
|
Nach dem Kauf läuft alles automatisch: Zahlung, Urkunde, Medaillen,
|
||||||
|
Abo-Abrechnung, Rechnungen, Lieferaufträge für den Abo-Honig. Die Handarbeit
|
||||||
|
des Inhabers beschränkt sich auf Drucken, Verpacken und Versenden.
|
||||||
|
|
||||||
|
Erfolg heißt: Eine Testbestellung erzeugt ohne manuelle Nachbearbeitung eine
|
||||||
|
korrekt personalisierte, hochwertige und druckfähige Urkunde, die sofort im
|
||||||
|
Konto in 3D erscheint. Ein Test-Abo rechnet monatlich ab und erzeugt die
|
||||||
|
Honiglieferung erst, wenn sie bezahlt ist.
|
||||||
|
|
||||||
|
## Teilprojekte und Reihenfolge
|
||||||
|
|
||||||
|
Der Umfang ist zu groß für einen Plan. Jedes Teilprojekt bekommt einen
|
||||||
|
eigenen Umsetzungsplan und wird einzeln geprüft und ausgerollt:
|
||||||
|
|
||||||
|
| Nr. | Teilprojekt | Hängt ab von | Schätzung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Urkunde, Einmal-Patenschaft, Kontopflicht, Kontobereich mit Downloads, Admin (Druck, Korrektur) | – | 4–5 Tage |
|
||||||
|
| 2 | 3D-Ansicht von Urkunde und Medaille, Patenschafts-Medaillen je Paket, „Alles herunterladen“ | 1 | 3–4 Tage |
|
||||||
|
| 3 | Übertragung an Beschenkte per befristetem Link | 1 | 1 Tag |
|
||||||
|
| 4 | Abo: Stripe-Abrechnung, Kündigungsbutton, Lieferzyklen, Honigkontingent, Rechnungen | 1 | 5–7 Tage |
|
||||||
|
| 5 | Treuestufen: Stammkunden-Stufen, Abo-Stufen, VIP | 2, 4 | 2 Tage |
|
||||||
|
|
||||||
|
Teilprojekt 1 ist allein verkaufsfähig. Jedes weitere macht das Angebot
|
||||||
|
reicher, ohne das vorherige umzubauen.
|
||||||
|
|
||||||
|
## Nicht im Umfang
|
||||||
|
|
||||||
|
Diese Leistungen entfallen. Sie werden weder auf der Verkaufsseite noch in
|
||||||
|
E-Mails, Urkunden, FAQs oder Rechtstexten versprochen:
|
||||||
|
|
||||||
|
- Besuche beim Imker oder am Bienenstand
|
||||||
|
- Patentage, Veranstaltungen, Terminbuchungen
|
||||||
|
- Videos, individuelle Bienenfotos, Saisonberichte
|
||||||
|
- jede Pflicht zur laufenden Erstellung von Inhalten
|
||||||
|
- Veranstaltungs- oder Teilnehmerverwaltung
|
||||||
|
- Zusagen zu Naturschutzprojekten oder unbelegte Umweltversprechen
|
||||||
|
- ein Vorlagen-Auswahlschritt für Kunden (es gibt genau eine Gestaltung)
|
||||||
|
- Gastbestellungen von Patenschaften
|
||||||
|
|
||||||
|
Später möglich, jetzt nicht gebaut: Bilder oder andere Medien im
|
||||||
|
Patenschaftsbereich des Kontos. Das Datenmodell lässt dafür Platz (siehe
|
||||||
|
„Erweiterbarkeit“), es gibt aber keine Oberfläche und kein Versprechen.
|
||||||
|
|
||||||
|
## Angebote
|
||||||
|
|
||||||
|
Alle Angebote sind im Admin pflegbar: Name, Beschreibung, Leistungsliste,
|
||||||
|
Preis, aktiv/inaktiv, Reihenfolge, verknüpftes Honigprodukt, Gläser,
|
||||||
|
Steueraufteilung, Medaillenstufe. Es kann beliebig viele Angebote beider Arten
|
||||||
|
geben.
|
||||||
|
|
||||||
|
### Einmal-Patenschaften
|
||||||
|
|
||||||
|
| Paket | Testpreis | Leistungen | Versand |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| Bienenfreund | 19 € | symbolische Namensgebung, PDF-Urkunde, Medaille, Konto mit 3D-Ansicht und Downloads | keiner |
|
||||||
|
| Honigfreund | 39 € | digitale Leistungen, gedruckte Urkunde, 1 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
|
||||||
|
| Wabenfreund | 59 € | digitale Leistungen, gedruckte Urkunde, 3 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
|
||||||
|
|
||||||
|
- Laufzeit zwölf Monate ab Startdatum, endet automatisch.
|
||||||
|
- Nach Ablauf trägt die Urkunde weiter ihr Von-bis-Datum. Urkunde, Medaille
|
||||||
|
und Downloads bleiben im Konto verfügbar.
|
||||||
|
- Alle Pakete bekommen dieselbe Urkunde in derselben Qualität. Sie
|
||||||
|
unterscheiden sich in den physischen Leistungen und in der Medaillenstufe.
|
||||||
|
|
||||||
|
### Abo-Patenschaften
|
||||||
|
|
||||||
|
Zwei Ausgangsangebote, Preise vom Inhaber festzulegen:
|
||||||
|
|
||||||
|
| Angebot | Zahlung | Honig | Lieferung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Patenschaft im Abo, vierteljährlich | monatlich | z. B. 2 × 500 g je Lieferung | nach jeweils 3 bezahlten Monaten |
|
||||||
|
| Patenschaft im Abo, nach der Ernte | monatlich | z. B. 3 × 500 g je Lieferung | in den Erntefenstern, frühestens nach 6 bezahlten Monaten seit der letzten Lieferung |
|
||||||
|
|
||||||
|
- Monatlich kündbar zum Ende des bezahlten Monats.
|
||||||
|
- Versand ist im Monatspreis enthalten. Die Verkaufsseite sagt das deutlich.
|
||||||
|
- Ein Abo gilt einer Biene mit einem Namen und hat eine Urkunde.
|
||||||
|
- Abo und Einmalkauf werden getrennt bezahlt (eigene Stripe-Sitzung je Abo).
|
||||||
|
- Ein Abo lässt sich in dieser Fassung nicht übertragen, weil die Zahlung am
|
||||||
|
Konto des Käufers hängt.
|
||||||
|
|
||||||
|
### Grußkarte
|
||||||
|
|
||||||
|
Vorgedruckt, nicht personalisiert, im Wabenhain-Design. Persönliche Worte
|
||||||
|
stehen in der Widmung auf der Urkunde. So entsteht kein Handschreiben.
|
||||||
|
|
||||||
|
## Kundenkonto ist Pflicht
|
||||||
|
|
||||||
|
- Patenschaften kauft nur, wer angemeldet ist. Das betrifft Einmal- und
|
||||||
|
Abo-Angebote. Für andere Produkte bleibt die Gastbestellung wie heute.
|
||||||
|
- Die Personalisierung lässt sich ohne Anmeldung ausprobieren, samt Vorschau.
|
||||||
|
Beim Klick auf „In den Warenkorb“ oder „Abo abschließen“ führt die Seite zur
|
||||||
|
Anmeldung oder Registrierung. Die Eingaben bleiben dabei erhalten
|
||||||
|
(Entwurf im `sessionStorage`, nach der Anmeldung zurückgeschrieben).
|
||||||
|
- Der Checkout prüft serverseitig, dass ein Konto angemeldet ist, sobald eine
|
||||||
|
Patenschaft im Warenkorb liegt.
|
||||||
|
|
||||||
|
## Personalisierung
|
||||||
|
|
||||||
|
### Felder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Name der Patenbiene | ja | symbolischer Name |
|
||||||
|
| Name der Person auf der Urkunde | ja | für wen die Urkunde ausgestellt ist |
|
||||||
|
| Schenkende | nein | eine oder mehrere Personen als Freitext („Anna und Ben“) |
|
||||||
|
| Widmung | nein | wird unverändert übernommen |
|
||||||
|
| Anlass | ja | für mich selbst, Geburtstag, Weihnachten, Dankeschön, allgemeines Geschenk |
|
||||||
|
| Startdatum | ja | Einmal: frühestens Bestelltag, spätestens sechs Monate danach. Abo: Tag der ersten Zahlung |
|
||||||
|
|
||||||
|
Bei „für mich selbst“ blendet das Formular „Schenkende“ aus.
|
||||||
|
|
||||||
|
### Eingabelimits
|
||||||
|
|
||||||
|
Die endgültigen Limits ergeben sich aus der Layout-Testmatrix. Startwerte:
|
||||||
|
|
||||||
|
- Bienenname: 2–24 Zeichen
|
||||||
|
- Name auf der Urkunde: 2–60 Zeichen
|
||||||
|
- Schenkende: bis 80 Zeichen
|
||||||
|
- Widmung: bis 280 Zeichen und höchstens 5 Zeilenumbrüche
|
||||||
|
|
||||||
|
Das Formular zeigt jedes Limit vor der Bestellung als Zeichenzähler an. Server
|
||||||
|
und Client prüfen mit demselben zod-Schema.
|
||||||
|
|
||||||
|
### Zeichen
|
||||||
|
|
||||||
|
- Eingaben werden nach Unicode NFC normalisiert, Steuerzeichen entfernt,
|
||||||
|
Mehrfach-Leerzeichen zusammengefasst.
|
||||||
|
- Zulässig ist jedes Zeichen, für das die eingebetteten Schriften eine Glyphe
|
||||||
|
haben. Die Prüfung läuft serverseitig über die Font-Metriken (fontkit).
|
||||||
|
Fehlt eine Glyphe (zum Beispiel Emoji), lehnt das Formular die Eingabe mit
|
||||||
|
klarer Meldung ab, statt ein Ersatzkästchen zu drucken.
|
||||||
|
- Umlaute, ß, Akzente, Bindestriche und Apostrophe funktionieren.
|
||||||
|
|
||||||
|
## Urkundentext
|
||||||
|
|
||||||
|
Der Text entsteht aus hinterlegten Bausteinen. Es gibt keine KI-Anfrage pro
|
||||||
|
Bestellung. Der Kunde sieht den endgültigen Wortlaut vor dem Kauf in der
|
||||||
|
Vorschau.
|
||||||
|
|
||||||
|
### Regeln
|
||||||
|
|
||||||
|
- Eigene und verschenkte Patenschaft haben unterschiedliche Formulierungen.
|
||||||
|
- Geburtstag, Weihnachten und Dankeschön haben je eine kurze Anlasszeile.
|
||||||
|
„Für mich selbst“ und „allgemeines Geschenk“ haben keine.
|
||||||
|
- Leere Felder erzeugen keine Platzhalter, keine leeren Zeilen und keine
|
||||||
|
verwaisten Bezeichnungen wie „Von:“ ohne Namen.
|
||||||
|
- Keine erfundenen Eigenschaften, Beziehungen oder Ereignisse.
|
||||||
|
- Keine Geschlechtszuordnung: keine Pronomen, keine aus dem Namen
|
||||||
|
abgeleiteten Anreden.
|
||||||
|
- Keine Formulierung, die eine individuelle Betreuung oder Verfolgung einer
|
||||||
|
einzelnen Biene behauptet.
|
||||||
|
- Die Widmung wird wörtlich übernommen, nur sicher dargestellt (kein HTML,
|
||||||
|
kein Markup) und sauber umbrochen.
|
||||||
|
|
||||||
|
### Bausteine (Entwurf, vor Abnahme sprachlich final prüfen)
|
||||||
|
|
||||||
|
Überschrift: „Bienenpatenschaft“, darunter klein „Urkunde“.
|
||||||
|
|
||||||
|
Anlasszeile: „Zum Geburtstag“, „Zu Weihnachten“, „Als Dankeschön“.
|
||||||
|
|
||||||
|
Haupttext, eigene Patenschaft:
|
||||||
|
|
||||||
|
> {Name auf der Urkunde} hat einer Biene der Imkerei Wabenhain symbolisch den
|
||||||
|
> Namen
|
||||||
|
> **{Bienenname}**
|
||||||
|
> gegeben und unterstützt damit die Imkerei und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Haupttext, verschenkte Patenschaft:
|
||||||
|
|
||||||
|
> Für {Name auf der Urkunde}
|
||||||
|
> Einer Biene der Imkerei Wabenhain wurde symbolisch der Name
|
||||||
|
> **{Bienenname}**
|
||||||
|
> gegeben. Die Patenschaft unterstützt die Imkerei und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Schenkende, nur wenn ausgefüllt: „Geschenkt von {Schenkende}“.
|
||||||
|
|
||||||
|
Widmung, nur wenn ausgefüllt: eigener, typografisch abgesetzter Block.
|
||||||
|
|
||||||
|
Laufzeit:
|
||||||
|
|
||||||
|
- Einmal: „Patenschaft vom {Start} bis {Ende}“, Ende = Start plus zwölf Monate
|
||||||
|
minus ein Tag.
|
||||||
|
- Abo, aktiv: „Patenschaft seit {Start}“.
|
||||||
|
- Abo, gekündigt: „Patenschaft vom {Start} bis {Ende}“, Ende = letzter
|
||||||
|
bezahlter Tag. Diese Fassung entsteht automatisch als neue Revision, sobald
|
||||||
|
die Kündigung wirksam wird (siehe „Abo“).
|
||||||
|
|
||||||
|
Kennung: „Nr. {Patenschaftsnummer}“, Format `WH-P-JJJJ-NNNNN`, fortlaufend über
|
||||||
|
einen eigenen Zähler (wie `InvoiceCounter`).
|
||||||
|
|
||||||
|
Hinweis, immer vorhanden und gut lesbar:
|
||||||
|
|
||||||
|
> Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene,
|
||||||
|
> sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Sprache: Die Urkunde erscheint in der beim Kauf gewählten Sprache (de/en).
|
||||||
|
Beide Fassungen durchlaufen dieselbe Testmatrix.
|
||||||
|
|
||||||
|
## Gestaltung der Urkunde
|
||||||
|
|
||||||
|
### Anspruch
|
||||||
|
|
||||||
|
Die Urkunde soll wie ein rahmenwürdiges Geschenk wirken:
|
||||||
|
|
||||||
|
- elegante Typografie mit klarer Hierarchie
|
||||||
|
- großzügiger Weißraum, ausgewogene Proportionen
|
||||||
|
- Bienenname und Name auf der Urkunde als gestalterischer Mittelpunkt
|
||||||
|
- dezente Bienen-, Blüten- oder Wabenillustration als feine Strichgrafik
|
||||||
|
- zurückhaltende Markenfarben, feine Rahmen oder Ornamente
|
||||||
|
- harmonisch eingebundene Widmung
|
||||||
|
- diskrete Patenschaftsnummer und Datumsangaben
|
||||||
|
- keine Cliparts, keine Office-Vorlagenoptik, keine erfundenen Gütesiegel,
|
||||||
|
keine Unterschrift
|
||||||
|
|
||||||
|
### Markengrundlage
|
||||||
|
|
||||||
|
- Schriften: Cormorant Garamond für Überschrift, Namen und Widmung, Inter für
|
||||||
|
Kleintext. Beide stehen unter der SIL Open Font License und dürfen in PDFs
|
||||||
|
eingebettet werden. Die TTF-Dateien kommen samt Lizenz nach `assets/fonts/`.
|
||||||
|
- Farben aus `src/app/globals.css`: Forest `#24352b` für Text, Honey Dark
|
||||||
|
`#8a6531` für Akzente und Linien, Honey `#b88a44` nur klein, Sage `#788774`
|
||||||
|
für Ornamente. Gold ist auf Papier eine flache Druckfarbe, nie Verlauf oder
|
||||||
|
Folienimitat. Die Urkunde verspricht keine Goldfolie.
|
||||||
|
- Logo: das hochgeladene Markenlogo (`getBrandAssets`), für den Druck als SVG
|
||||||
|
oder als PNG mit mindestens 300 dpi in Druckgröße. Fehlt es, nutzt die
|
||||||
|
Urkunde das gezeichnete Waben-Zeichen aus `lib/brand-icon.ts`. Das Logo
|
||||||
|
wird nicht verzerrt, umgefärbt oder nachgezeichnet.
|
||||||
|
- Weißes Papier, keine randlosen Farbflächen.
|
||||||
|
|
||||||
|
### Drei Varianten, dann eine Entscheidung
|
||||||
|
|
||||||
|
Zuerst entstehen drei Varianten mit identischen Beispieldaten:
|
||||||
|
|
||||||
|
1. Klassisch-editorial: zentrierte Achse, feiner Doppelrahmen in Honey Dark,
|
||||||
|
kleine Biene als Strichzeichnung über der Überschrift.
|
||||||
|
2. Wabe: asymmetrischer Aufbau, feines Hexagon-Linienraster als Eckmotiv,
|
||||||
|
Namen linksbündig groß.
|
||||||
|
3. Botanisch: Lindenblüten- oder Kleezweig als Strichillustration, die den
|
||||||
|
Textblock an zwei Ecken einfasst.
|
||||||
|
|
||||||
|
Alle drei werden als echte PDFs gerendert und nebeneinander beurteilt. Die
|
||||||
|
passendste Variante wird vollständig umgesetzt. Die anderen bleiben nicht im
|
||||||
|
Produktcode. Illustrationen werden selbst als Vektor gezeichnet. Es gibt
|
||||||
|
keine fremden Bildrechte und keine Rasterillustrationen.
|
||||||
|
|
||||||
|
## Druck- und PDF-Anforderungen
|
||||||
|
|
||||||
|
- A4 hochkant, genau eine Seite
|
||||||
|
- Text als echter Text mit eingebetteten Schrift-Teilmengen, Illustrationen
|
||||||
|
und Rahmen als Vektor
|
||||||
|
- Rasterbilder (nur das Logo, falls kein SVG) mit mindestens 300 dpi
|
||||||
|
- sicherer Rand: Inhalt mindestens 15 mm vom Blattrand, Rahmenlinien
|
||||||
|
mindestens 12 mm
|
||||||
|
- keine abgeschnittenen Namen, keine Überlagerungen
|
||||||
|
- kleinste Schrift 8 pt (Kennung, Hinweis), Fließtext mindestens 11 pt
|
||||||
|
- PDF-Metadaten: Titel „Bienenpatenschaft {Bienenname}“, Autor „Imkerei
|
||||||
|
Wabenhain“
|
||||||
|
|
||||||
|
Lange Inhalte löst das Layout über Regeln, nicht durch Verkleinern der ganzen
|
||||||
|
Seite:
|
||||||
|
|
||||||
|
1. Layoutvarianten: ohne Widmung, mit kurzer Widmung, mit langer Widmung. Die
|
||||||
|
Variante folgt aus der gemessenen Höhe.
|
||||||
|
2. Kontrollierte Umbrüche: Namen brechen nur an Leerzeichen oder Bindestrich,
|
||||||
|
höchstens auf zwei Zeilen, nie mitten im Wort.
|
||||||
|
3. Begrenzte Schriftstufen: jedes Feld hat eine feste Liste von Größen (zum
|
||||||
|
Beispiel Bienenname 54, 46, 40 pt). Passt der Inhalt in der kleinsten
|
||||||
|
Stufe nicht, ist das ein Validierungsfehler. Die Eingabelimits verhindern
|
||||||
|
diesen Fall im Betrieb.
|
||||||
|
|
||||||
|
## Medaillen und Treuestufen
|
||||||
|
|
||||||
|
Es gibt drei Arten von Medaillen. Alle werden deterministisch als Vektor
|
||||||
|
erzeugt (SVG), im Konto in 3D gezeigt und als PNG (2048 px, transparent) und
|
||||||
|
SVG heruntergeladen. Auf dem Bildschirm dürfen Medaillen metallisch glänzen.
|
||||||
|
Das betrifft nur die digitale Darstellung, nicht den Druck.
|
||||||
|
|
||||||
|
### 1. Patenschafts-Medaille (Teilprojekt 2)
|
||||||
|
|
||||||
|
- Eine je Patenschaft, mit Bienenname, Jahr und Patenschaftsnummer.
|
||||||
|
- Die Stufe richtet sich nach dem Angebot: Bienenfreund, Honigfreund und
|
||||||
|
Wabenfreund haben eigene Prägung und eigenes Metall (zum Beispiel Bronze,
|
||||||
|
Silber, Gold). Abo-Angebote haben eine eigene Prägung. Die Zuordnung ist am
|
||||||
|
Angebot im Admin einstellbar.
|
||||||
|
- Sie gehört zur Patenschaft und bleibt dauerhaft, auch nach Ablauf oder
|
||||||
|
Kündigung. Bei einer Übertragung wandert sie mit.
|
||||||
|
|
||||||
|
### 2. Stammkunden-Stufen (Teilprojekt 5)
|
||||||
|
|
||||||
|
- Für alle Kunden mit Konto, über den ganzen Shop.
|
||||||
|
- Grundlage: Anzahl bezahlter, nicht erstatteter oder stornierter
|
||||||
|
Bestellungen. Abo-Monatszahlungen zählen hier nicht, sie haben eigene
|
||||||
|
Stufen.
|
||||||
|
- Stufen und Schwellen im Admin pflegbar. Vorschlag: „Blütenbesuch“ ab 2,
|
||||||
|
„Wabenbau“ ab 5, „Honigschatz“ ab 10, „Goldwabe“ ab 25 Bestellungen.
|
||||||
|
- Die Stufe bleibt, sie sinkt nur, wenn Bestellungen erstattet oder storniert
|
||||||
|
werden.
|
||||||
|
|
||||||
|
### 3. Abo-Stufen und VIP (Teilprojekt 5)
|
||||||
|
|
||||||
|
- Grundlage: ununterbrochen bezahlte Abo-Monate.
|
||||||
|
- Vorgabe: Stufe 1 bis 24, je bezahltem Monat eine Stufe, danach eine Stufe
|
||||||
|
ohne Zahl, „VIP“, als Zeichen besonders hoher Wertschätzung. Die Schwellen
|
||||||
|
sind im Admin pflegbar und lassen sich ohne Codeänderung anpassen.
|
||||||
|
- VIP bekommt eine eigene Medaille und eine VIP-Kennzeichnung im Konto. Weitere
|
||||||
|
Vorteile (Rabatt, Versand) gehören nicht zum Umfang und lassen sich später
|
||||||
|
über die bestehenden Rabattcodes ergänzen.
|
||||||
|
- Wird das Abo gekündigt, verschwindet die Abo-Stufe samt Medaille, sobald die
|
||||||
|
Kündigung wirksam ist. Ein neues Abo beginnt wieder bei null.
|
||||||
|
- Ausfälle durch fehlgeschlagene Zahlung unterbrechen die Dauer nur, wenn das
|
||||||
|
Abo deshalb endet.
|
||||||
|
|
||||||
|
Stufen werden aus den Daten berechnet, nicht als Zustand gespeichert. So gibt
|
||||||
|
es keine Abweichung zwischen Stufe und tatsächlichen Bestellungen oder
|
||||||
|
Zahlungen.
|
||||||
|
|
||||||
|
## Kontobereich „Patenschaft“
|
||||||
|
|
||||||
|
Neuer Bereich `/konto/patenschaft` neben „Bestellungen“ und „Wunschliste“.
|
||||||
|
|
||||||
|
### Übersicht
|
||||||
|
|
||||||
|
- alle Patenschaften des Kontos mit Bienenname, Art (einmalig/Abo), Laufzeit
|
||||||
|
und Status (aktiv, abgelaufen, gekündigt)
|
||||||
|
- Medaillenleiste: Patenschafts-Medaillen, aktuelle Stammkunden-Stufe,
|
||||||
|
aktuelle Abo-Stufe oder VIP
|
||||||
|
- bei Abos: nächste Abbuchung, nächste Lieferung, Fortschritt bis zur
|
||||||
|
nächsten Lieferung („2 von 3 Monaten bezahlt“), Kündigungsbutton
|
||||||
|
|
||||||
|
### Detail einer Patenschaft
|
||||||
|
|
||||||
|
- 3D-Ansicht der eigenen Urkunde und der Patenschafts-Medaille (Teilprojekt 2;
|
||||||
|
in Teilprojekt 1 eine statische Ansicht)
|
||||||
|
- Downloads: Urkunde (PDF), Medaille (PNG, SVG), „Alles herunterladen“ (ZIP)
|
||||||
|
- Revisionen sind für Kunden nicht sichtbar. Sie sehen immer die aktuelle
|
||||||
|
Fassung.
|
||||||
|
- Menüpunkt „Übertragen“ (nur Einmal-Patenschaften, Teilprojekt 3)
|
||||||
|
|
||||||
|
### 3D-Ansicht (Teilprojekt 2)
|
||||||
|
|
||||||
|
Ziel: Die Ansicht soll beeindrucken und sich hochwertig anfühlen, ohne die
|
||||||
|
Urkunde zu verfälschen.
|
||||||
|
|
||||||
|
- Technik: three.js mit `@react-three/fiber` und `@react-three/drei`, nur auf
|
||||||
|
dieser Seite und erst im Browser geladen (dynamischer Import), damit der
|
||||||
|
übrige Shop nicht schwerer wird.
|
||||||
|
- Urkunde als Papierblatt mit leichter Wölbung, weichem Schatten und einem
|
||||||
|
langsam wandernden Lichtreflex. Die Textur ist ein hochaufgelöstes Bild
|
||||||
|
(2048 px breit) derselben Revision, aus demselben Layout wie die PDF und
|
||||||
|
ohne Wasserzeichen. Sie wird nur angemeldeten Besitzern ausgeliefert.
|
||||||
|
- Rückseite der Urkunde: Wabenhain-Zeichen und Patenschaftsnummer.
|
||||||
|
- Medaille als Münze mit Rand und erhabener Prägung (SVG-Pfade extrudiert),
|
||||||
|
Metall mit Umgebungsspiegelung. Die Umgebungstextur liegt lokal im Projekt,
|
||||||
|
kein Abruf von fremden Servern.
|
||||||
|
- Bewegung: im Ruhezustand sanftes Schweben und Drehen. Der Mauszeiger neigt
|
||||||
|
das Blatt leicht. Ziehen dreht mit Nachschwung. Doppelklick oder
|
||||||
|
Doppeltippen wendet Urkunde oder Medaille. Zoom per Mausrad oder Zwei-Finger-
|
||||||
|
Geste, begrenzt.
|
||||||
|
- Smartphone: Wischen dreht, Neigen des Geräts bewegt die Szene erst nach
|
||||||
|
einem Tipp auf „Bewegung aktivieren“ (iOS verlangt die Erlaubnis).
|
||||||
|
- `prefers-reduced-motion`: kein Schweben, keine automatische Drehung,
|
||||||
|
Ziehen bleibt möglich.
|
||||||
|
- Ohne WebGL oder bei Fehlern: statisches Bild der Urkunde und der Medaille,
|
||||||
|
Downloads funktionieren unverändert.
|
||||||
|
- Leistung: Pixeldichte höchstens 2, Darstellung pausiert, wenn der Tab im
|
||||||
|
Hintergrund oder die Szene außer Sicht ist. Ziel 60 fps auf einem
|
||||||
|
Mittelklasse-Smartphone.
|
||||||
|
- Bedienbarkeit: Tastatur dreht (Pfeiltasten) und wendet (Leertaste), die
|
||||||
|
Szene hat eine Textbeschreibung für Screenreader, alle Downloads sind
|
||||||
|
normale Links.
|
||||||
|
|
||||||
|
### Erweiterbarkeit
|
||||||
|
|
||||||
|
`Sponsorship` bekommt später eine Medienliste. Die Detailseite ist so
|
||||||
|
aufgebaut, dass ein weiterer Abschnitt („Bilder“) ohne Umbau hinzukommen
|
||||||
|
kann. In diesem Auftrag wird dafür nichts gebaut.
|
||||||
|
|
||||||
|
## Übertragung an Beschenkte (Teilprojekt 3)
|
||||||
|
|
||||||
|
Nur Einmal-Patenschaften lassen sich übertragen.
|
||||||
|
|
||||||
|
1. Der Besitzer wählt im Menü der Patenschaft „Übertragen“.
|
||||||
|
2. Er wählt die Gültigkeit des Links: 24 Stunden, 1 Woche oder 30 Tage.
|
||||||
|
3. Ein Bestätigungsdialog fragt: „Möchtest du den Übertragungslink wirklich
|
||||||
|
erstellen? Jede Person, die diesen Link hat, kann die Patenschaft in ihr
|
||||||
|
Konto übernehmen. Gib ihn nur an die beschenkte Person weiter. Die
|
||||||
|
Weitergabe geschieht auf eigene Verantwortung.“ (Wortlaut rechtlich
|
||||||
|
prüfen.)
|
||||||
|
4. Erst nach Bestätigung entsteht der Link. Er ist einmal nutzbar, enthält ein
|
||||||
|
zufälliges Token mit mindestens 128 Bit, gespeichert wird nur dessen Hash.
|
||||||
|
5. Bis zur Einlösung sieht der Besitzer den offenen Link mit Ablaufzeit und
|
||||||
|
kann ihn widerrufen.
|
||||||
|
6. Die beschenkte Person öffnet den Link, meldet sich an oder registriert sich
|
||||||
|
und bestätigt die Übernahme. Danach gehören Urkunde, Medaille und
|
||||||
|
Downloads ihrem Konto. Beim bisherigen Besitzer verschwindet die
|
||||||
|
Patenschaft aus dem Bereich „Patenschaft“. Bestellung und Rechnung bleiben
|
||||||
|
bei ihm.
|
||||||
|
7. Abgelaufene, widerrufene oder eingelöste Links zeigen eine neutrale Meldung
|
||||||
|
ohne Daten der Patenschaft.
|
||||||
|
8. Jede Übertragung steht im Audit-Log (erstellt, widerrufen, eingelöst, von
|
||||||
|
welchem Konto an welches).
|
||||||
|
|
||||||
|
Eine Übertragung verändert die Urkunde nicht. Die Stammkunden-Stufe zählt den
|
||||||
|
Kauf weiter beim Käufer.
|
||||||
|
|
||||||
|
## Abo (Teilprojekt 4)
|
||||||
|
|
||||||
|
### Abrechnung
|
||||||
|
|
||||||
|
- Stripe Billing: Jedes Abo-Angebot hat einen Stripe-Preis, der beim
|
||||||
|
Speichern im Admin angelegt wird. Eine Preisänderung erzeugt einen neuen
|
||||||
|
Stripe-Preis für neue Abos. Laufende Abos behalten ihren Preis.
|
||||||
|
- `Customer` bekommt eine `stripeCustomerId`.
|
||||||
|
- Der Abschluss läuft über eine Stripe-Checkout-Sitzung im Modus
|
||||||
|
`subscription`.
|
||||||
|
- Neue Webhook-Ereignisse: `customer.subscription.created`,
|
||||||
|
`customer.subscription.updated`, `customer.subscription.deleted`,
|
||||||
|
`invoice.paid`, `invoice.payment_failed`. Jedes Ereignis wird über die
|
||||||
|
Stripe-ID idempotent verarbeitet.
|
||||||
|
- Jede bezahlte Monatsrechnung erzeugt eine Wabenhain-Rechnung im bestehenden
|
||||||
|
Rechnungssystem (Nummernkreis, PDF, E-Rechnung) und eine Mail.
|
||||||
|
- Zahlungsart ändern: über das Stripe-Kundenportal, nur für die Zahlungsart.
|
||||||
|
|
||||||
|
### Schutz vor Verlust: Lieferung erst nach Bezahlung
|
||||||
|
|
||||||
|
Der Kunde kann monatlich kündigen, bekommt aber nie mehr Honig, als er bezahlt
|
||||||
|
hat:
|
||||||
|
|
||||||
|
- Vierteljährlich: Der Anspruch auf eine Lieferung entsteht nach jeweils drei
|
||||||
|
bezahlten Monaten. Wer nach zwei Monaten kündigt, hatte zwei Monate
|
||||||
|
Patenschaft, aber keinen Honig.
|
||||||
|
- Nach der Ernte: Der Inhaber legt im Admin die Erntefenster fest. Geliefert
|
||||||
|
wird im nächsten Fenster, sobald seit der letzten Lieferung mindestens sechs
|
||||||
|
Monate bezahlt sind.
|
||||||
|
- Bezahlt heißt: Stripe meldet die Rechnung als bezahlt, bei SEPA zusätzlich
|
||||||
|
die Regeln unter „SEPA-Lastschrift und Rückbuchungen“.
|
||||||
|
- Fehlgeschlagene Zahlung: Stripe versucht es automatisch erneut. Solange das
|
||||||
|
Abo im Verzug ist, entsteht keine Lieferung. Scheitern alle Versuche, endet
|
||||||
|
das Abo wie bei einer Kündigung.
|
||||||
|
- Die Verkaufsseite und die Abo-Bedingungen sagen klar: „Der Honig wird nach
|
||||||
|
jeweils drei bezahlten Monaten verschickt“ beziehungsweise die
|
||||||
|
entsprechende Regel für die Ernte-Variante.
|
||||||
|
- Widerruf innerhalb von 14 Tagen: Bis dahin ist kein Honig verschickt, es
|
||||||
|
entsteht kein Warenverlust.
|
||||||
|
|
||||||
|
### SEPA-Lastschrift und Rückbuchungen
|
||||||
|
|
||||||
|
Abos lassen sich mit Karte, Apple Pay, Google Pay und SEPA-Lastschrift
|
||||||
|
bezahlen.
|
||||||
|
|
||||||
|
Grenze, die keine Einstellung aufhebt: Bei einer SEPA-Lastschrift kann der
|
||||||
|
Kunde acht Wochen lang ohne Angabe von Gründen bei seiner Bank die Erstattung
|
||||||
|
verlangen, bei einer nicht autorisierten Lastschrift 13 Monate lang. Das ist
|
||||||
|
Bankrecht. Stripe kann es nicht verhindern und solche Rückbuchungen nicht
|
||||||
|
anfechten. Stripe berechnet dafür außerdem eine Gebühr. Der Schutz liegt
|
||||||
|
deshalb in der Lieferlogik von Wabenhain:
|
||||||
|
|
||||||
|
1. Lieferung nur gegen bezahlte Monate (siehe oben). Eine Rückbuchung kann
|
||||||
|
höchstens Honig betreffen, der schon bezahlt erschien.
|
||||||
|
2. Neukunden-Frist: Bei SEPA zählt die erste Abo-Zahlung eines Kontos erst
|
||||||
|
nach einer Frist für den Lieferanspruch. Vorgabe 56 Tage, also die
|
||||||
|
Acht-Wochen-Frist. Die erste Lieferung kommt dadurch bei SEPA etwas später,
|
||||||
|
alle weiteren normal. Die Frist ist im Admin einstellbar (0 schaltet sie
|
||||||
|
ab). Die Verkaufsseite nennt sie bei der Zahlart.
|
||||||
|
3. Rückbuchung erkannt (`charge.dispute.created`, im Webhook schon
|
||||||
|
abonniert): Das Abo geht sofort auf `IM_VERZUG`, offene Lieferungen werden
|
||||||
|
gestoppt und ihre Reservierung freigegeben, die Abo-Stufe ruht. Der Kunde
|
||||||
|
bekommt eine Mail mit der Bitte, den Betrag zu begleichen. Der Fall
|
||||||
|
erscheint in der Aufmerksamkeitsliste.
|
||||||
|
4. Wird der Betrag nicht binnen 14 Tagen bezahlt, endet das Abo wie bei einer
|
||||||
|
Kündigung. Der Anspruch auf den Betrag bleibt bestehen. Ob er eingefordert
|
||||||
|
wird, entscheidet der Inhaber.
|
||||||
|
5. Ein Konto mit einer Rückbuchung kann neue Abos nur noch mit Karte oder
|
||||||
|
Wallet abschließen. Der Admin kann diese Sperre aufheben.
|
||||||
|
|
||||||
|
### Stripe-Einrichtung
|
||||||
|
|
||||||
|
Die Einrichtung erfolgt in Teilprojekt 4, zuerst im Testmodus, dann
|
||||||
|
identisch im Livemodus. Was per API geht, legt ein Einrichtungsskript im Repo
|
||||||
|
an (wiederholbar, ohne Geheimnisse im Code). Was nur im Dashboard geht,
|
||||||
|
steht als Checkliste in `docs/EINRICHTUNG.md`.
|
||||||
|
|
||||||
|
- Zahlarten für Abo-Checkout: Karte, Apple Pay, Google Pay, SEPA-Lastschrift
|
||||||
|
(Mandat wird im Checkout erteilt).
|
||||||
|
- Billing: Smart Retries für fehlgeschlagene Zahlungen, nach dem letzten
|
||||||
|
Versuch Abo kündigen. Stripe-eigene Kundenmails zu Rechnungen aus, weil
|
||||||
|
Wabenhain eigene Rechnungen und Mails schickt.
|
||||||
|
- Kundenportal: nur Zahlungsart ändern. Kündigung und Tarifwechsel im Portal
|
||||||
|
aus, weil die Kündigung über den eigenen Kündigungsbutton läuft.
|
||||||
|
- Webhook-Endpunkt: bestehende Ereignisse plus
|
||||||
|
`customer.subscription.created`, `customer.subscription.updated`,
|
||||||
|
`customer.subscription.deleted`, `invoice.paid`,
|
||||||
|
`invoice.payment_failed`, `charge.dispute.closed`.
|
||||||
|
- Produkte und Preise der Abo-Angebote legt der Admin über Wabenhain an,
|
||||||
|
nicht von Hand im Stripe-Dashboard.
|
||||||
|
- Tests mit Stripe Test Clocks (Abo über mehrere Monate) und
|
||||||
|
SEPA-Testkonten, darunter eines, das eine Rückbuchung auslöst.
|
||||||
|
- Entwicklung und CI in einer eigenen Stripe-Sandbox mit eingeschränktem
|
||||||
|
API-Schlüssel (`rk_`), nicht im Livekonto.
|
||||||
|
|
||||||
|
Ist-Stand Livekonto „Wabenhain“ (gelesen am 2026-10-16, nichts geändert):
|
||||||
|
|
||||||
|
- Zahlarten-Konfiguration „Default“: Karte, Apple Pay, SEPA-Lastschrift,
|
||||||
|
PayPal, Klarna, Link und weitere sind aktiv. Google Pay ist nicht verfügbar
|
||||||
|
und muss im Dashboard unter Zahlungsmethoden geprüft werden. Checkout zeigt
|
||||||
|
im Abo-Modus nur Zahlarten, die wiederkehrende Zahlungen unterstützen.
|
||||||
|
- Ein Webhook-Endpunkt `https://wabenhain.com/api/checkout/webhook` mit den
|
||||||
|
sechs bestehenden Ereignissen. Die Abo-Ereignisse fehlen und kommen mit
|
||||||
|
Teilprojekt 4 dazu.
|
||||||
|
- Kein Kundenportal konfiguriert.
|
||||||
|
- Die Stripe-Anbindung in Claude erreicht nur den Livemodus. Änderungen am
|
||||||
|
Livekonto erfolgen erst nach Abnahme von Teilprojekt 4 und mit Freigabe.
|
||||||
|
|
||||||
|
### Lieferaufträge
|
||||||
|
|
||||||
|
Ein täglicher Lauf im CronJob `wabenhain-certificates` prüft fällige
|
||||||
|
Lieferungen. Ist der Anspruch erfüllt und Bestand reserviert, legt er einen
|
||||||
|
Lieferauftrag als normale Bestellung an (Status `BEZAHLT`, Betrag 0 €, mit
|
||||||
|
Verweis auf das Abo). Er erscheint in der bestehenden Pack- und DHL-Liste. Der
|
||||||
|
Inhaber packt und versendet wie bei jeder Bestellung.
|
||||||
|
|
||||||
|
### Honigbestand und Kontingent
|
||||||
|
|
||||||
|
Kein Abo darf ohne Honig bleiben, weil der Shop zu viele Gläser einzeln
|
||||||
|
verkauft hat:
|
||||||
|
|
||||||
|
- Kontingent: Jedes Abo-Angebot hat eine Höchstzahl aktiver Abos, im Admin
|
||||||
|
pflegbar. Ist sie erreicht, zeigt die Verkaufsseite „Derzeit ausgebucht“
|
||||||
|
mit der bestehenden Benachrichtigung, sobald wieder Plätze frei sind.
|
||||||
|
- Reservierung, vierteljährlich: Für jedes aktive Abo sind die Gläser der
|
||||||
|
nächsten fälligen Lieferung im Bestand des verknüpften Honigprodukts
|
||||||
|
reserviert. Der Shop verkauft einzeln nur den freien Bestand: Bestand minus
|
||||||
|
Abo-Reservierung minus offene Bestellungen.
|
||||||
|
- Reservierung, Ernte-Variante: Der Inhaber trägt je Erntefenster die
|
||||||
|
geplanten Abo-Gläser ein. Sobald die Abfüllung im Bestand ist, wird dieser
|
||||||
|
Anteil reserviert, bevor er in den Freiverkauf geht.
|
||||||
|
- Deckungsanzeige im Admin-Dashboard: „Bestand deckt den Abo-Bedarf der
|
||||||
|
nächsten 2 Lieferungen.“ Unter einer Lieferung erscheint eine Warnung in der
|
||||||
|
Aufmerksamkeitsliste.
|
||||||
|
- Reicht die Deckung für neue Abos nicht, stoppt der Shop neue Abschlüsse
|
||||||
|
automatisch, bis der Inhaber nachlegt.
|
||||||
|
|
||||||
|
### Kündigung
|
||||||
|
|
||||||
|
- Kündigungsbutton nach § 312k BGB: „Verträge hier kündigen“. Im Konto mit
|
||||||
|
einem Klick auf die Bestätigungsseite, zusätzlich öffentlich erreichbar
|
||||||
|
unter `/vertraege-kuendigen` mit Name, E-Mail und Abo-Nummer, nach dem
|
||||||
|
Muster der bestehenden Route `vertrag-widerrufen`. Rechtlich prüfen lassen.
|
||||||
|
- Die Kündigung wirkt zum Ende des bezahlten Monats (`cancel_at_period_end`).
|
||||||
|
Eine Bestätigungsmail geht sofort hinaus.
|
||||||
|
- Wird die Kündigung wirksam:
|
||||||
|
1. Die Urkunde bekommt automatisch eine neue Revision mit „vom … bis …“.
|
||||||
|
Ersteller „System“, Grund „Abo beendet“. Sie bleibt im Konto
|
||||||
|
herunterladbar.
|
||||||
|
2. Die Abo-Stufe und ihre Medaille verschwinden.
|
||||||
|
3. Die Patenschafts-Medaille und alle Downloads bleiben.
|
||||||
|
4. Offene Reservierungen werden freigegeben.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Ein Layout, mehrere Ausgaben
|
||||||
|
|
||||||
|
Vorschau, 3D-Textur und finale PDF müssen identisch umbrechen. Deshalb gibt es
|
||||||
|
genau eine Layout-Berechnung auf dem Server:
|
||||||
|
|
||||||
|
```
|
||||||
|
Personalisierung + Textbausteine + Vorlagenversion
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
composeCertificateText() → aufgelöste Textblöcke (reine Funktion)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
layoutCertificate() → positionierte Elemente: Zeilen mit x/y,
|
||||||
|
Schriftgröße, Schrift, Pfade (reine Funktion,
|
||||||
|
misst mit fontkit an denselben TTF-Dateien)
|
||||||
|
│
|
||||||
|
├──► renderCertificatePdf() pdfkit, Text als Text
|
||||||
|
├──► renderCertificatePreview() SVG, Glyphen als Pfade, sharp → WebP,
|
||||||
|
│ Wasserzeichen, Bildschirmauflösung
|
||||||
|
└──► renderCertificateTexture() wie Vorschau, ohne Wasserzeichen,
|
||||||
|
2048 px, nur für Besitzer
|
||||||
|
```
|
||||||
|
|
||||||
|
Bild-Ausgaben setzen Glyphen als Pfade aus denselben Schriftdateien an
|
||||||
|
dieselben Positionen. Sie hängen nicht von Browser-Schriften ab. Ein Test
|
||||||
|
vergleicht die Zeilen aller Ausgaben.
|
||||||
|
|
||||||
|
Module unter `src/lib/certificate/`:
|
||||||
|
|
||||||
|
| Modul | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| `schema.ts` | zod-Schema der Personalisierung, gemeinsam für Client und Server |
|
||||||
|
| `text.ts` | Bausteine je Sprache, Anlass, eigene/verschenkte Patenschaft, Laufzeitform |
|
||||||
|
| `layout.ts` | Messen, Umbrechen, Schriftstufen, Layoutvarianten |
|
||||||
|
| `templates/` | Vorlagen, je Version eine Datei, alte Versionen bleiben erhalten |
|
||||||
|
| `render-pdf.ts` | pdfkit-Ausgabe |
|
||||||
|
| `render-image.ts` | SVG-Ausgabe, Vorschau und Textur |
|
||||||
|
| `storage.ts` | Ablage der PDFs |
|
||||||
|
| `issue.ts` | Erzeugung nach Zahlung, idempotent, mit Wiederholung |
|
||||||
|
|
||||||
|
Weitere Module: `src/lib/medals/` (Medaillen-SVG, Stufenberechnung),
|
||||||
|
`src/lib/sponsorship-subscription/` (Abo, Lieferanspruch, Reservierung),
|
||||||
|
`src/lib/sponsorship-transfer.ts`.
|
||||||
|
|
||||||
|
### Vorlagenversion
|
||||||
|
|
||||||
|
Jede Vorlage hat eine feste Kennung, etwa `wabe-classic@1`. Eine
|
||||||
|
Designänderung erzeugt eine neue Version, die alte bleibt im Code. Neue
|
||||||
|
Patenschaften nutzen die aktuelle Version. Bestehende behalten ihre Version
|
||||||
|
und ihre gespeicherte PDF. Systemrevisionen (Abo-Ende) nutzen die Version der
|
||||||
|
vorherigen Revision. Medaillen sind ebenso versioniert.
|
||||||
|
|
||||||
|
### Datenmodell (Prisma, beide Provider)
|
||||||
|
|
||||||
|
- `SponsorshipOffer`: Art (`EINMALIG`, `ABO`), Name, Beschreibung,
|
||||||
|
Leistungen, Preis (einmalig) oder Monatspreis, aktiv, Reihenfolge, physisch,
|
||||||
|
verknüpftes Honigprodukt, Gläser je Lieferung, Lieferzyklus
|
||||||
|
(`VIERTELJAEHRLICH`, `ERNTE`), Kontingent, Stripe-Preis-ID,
|
||||||
|
Steueraufteilung, Medaillenstufe
|
||||||
|
- `Sponsorship`: Besitzerkonto, Angebot, Bezug auf `OrderItem` (einmalig)
|
||||||
|
oder `SponsorshipSubscription` (Abo), jeweils eindeutig als
|
||||||
|
Idempotenzschlüssel, Patenschaftsnummer, Start, Ende (leer bei aktivem Abo),
|
||||||
|
Status, aktuelle Revision, Zustellfelder `pdfAttempts`, `nextAttemptAt`,
|
||||||
|
`lastError`, `mailSentAt`, `printedAt`
|
||||||
|
- `SponsorshipRevision`: unveränderlich: Personalisierung (JSON), aufgelöster
|
||||||
|
Text, Vorlagenversion, PDF-Pfad, SHA-256, Ersteller (System, Admin),
|
||||||
|
Grund, Zeitpunkt
|
||||||
|
- `SponsorshipCounter`: fortlaufende Nummer je Jahr
|
||||||
|
- `SponsorshipSubscription`: Konto, Angebot, `stripeSubscriptionId`
|
||||||
|
(eindeutig), Status (`AKTIV`, `IM_VERZUG`, `GEKUENDIGT`, `BEENDET`),
|
||||||
|
Beginn, Kündigung wirksam zum, Ende
|
||||||
|
- `SubscriptionPayment`: Abo, `stripeInvoiceId` (eindeutig), bezahlt am,
|
||||||
|
Betrag, Zahlart, zählt für Lieferung ab (Datum, bei SEPA-Neukunden nach
|
||||||
|
der Frist), zurückgebucht am, Wabenhain-Rechnung
|
||||||
|
- `SubscriptionDelivery`: Abo, fällig am, Gläser, Status, erzeugte Bestellung
|
||||||
|
- `HarvestWindow`: Zeitraum, geplante Abo-Gläser je Angebot
|
||||||
|
- `SponsorshipTransfer`: Patenschaft, Token-Hash, gültig bis, erstellt von,
|
||||||
|
eingelöst von, eingelöst am, widerrufen am
|
||||||
|
- `LoyaltyLevel`: Art (`STAMMKUNDE`, `ABO`), Schwelle, Name, Medaillendesign;
|
||||||
|
VIP als Stufe ohne Zahl
|
||||||
|
- `Customer.stripeCustomerId`, `Customer.sepaBlockedAt` (gesetzt nach einer
|
||||||
|
Rückbuchung)
|
||||||
|
|
||||||
|
Statuswerte der Patenschaft: `AUSSTEHEND`, `FEHLER`, `BEREIT`, `GEDRUCKT`.
|
||||||
|
Aktiv, abgelaufen und gekündigt werden aus Daten berechnet.
|
||||||
|
|
||||||
|
### Warenkorb und Checkout (Einmal-Patenschaft)
|
||||||
|
|
||||||
|
- Patenschaft als Position im bestehenden Warenkorb, Menge immer 1 je
|
||||||
|
Personalisierung.
|
||||||
|
- Die Personalisierung reist mit der Position. Der Checkout prüft sie erneut
|
||||||
|
mit demselben Schema und denselben Layoutregeln und verlangt ein
|
||||||
|
angemeldetes Konto.
|
||||||
|
- Vor dem Hinzufügen bestätigt der Kunde: „Ich habe die Vorschau und die
|
||||||
|
Schreibweise geprüft.“
|
||||||
|
- Beim Anlegen der Bestellung wird die Personalisierung mit der
|
||||||
|
Vorlagenversion an der `OrderItem` gespeichert. Genau dieser Stand wird nach
|
||||||
|
der Zahlung zur ersten Revision.
|
||||||
|
- Reine Digitalbestellungen haben keine Versandkosten. Ob dafür ein neuer
|
||||||
|
`FulfillmentMethod`-Wert `DIGITAL` nötig ist, entscheidet der
|
||||||
|
Umsetzungsplan nach Lesen von Checkout, Rechnung und DHL-Logik.
|
||||||
|
- Physische Pakete laufen über die bestehenden Versandstaffeln je Kategorie
|
||||||
|
mit eigener Staffel für Patenschaftspakete.
|
||||||
|
- Der Honig eines Pakets bucht den Bestand des verknüpften Produkts über die
|
||||||
|
vorhandene Bestandslogik (einschließlich ApiCore-Abgleich), unter
|
||||||
|
Berücksichtigung der Abo-Reservierung.
|
||||||
|
|
||||||
|
### Steuer
|
||||||
|
|
||||||
|
Pakete und Abos enthalten Leistungen mit unterschiedlichen Steuersätzen
|
||||||
|
(Urkunde und Leistung, Honig). Das Angebot speichert deshalb eine Aufteilung:
|
||||||
|
Honiganteil in Cent zum Honig-Satz, Rest zum Regelsatz. Die Rechnung zeigt
|
||||||
|
beide Steuerzeilen über die vorhandene Gruppierung in `lib/vat.ts`. Die
|
||||||
|
Methode muss die Steuerberatung bestätigen. Bei Kleinunternehmerregelung
|
||||||
|
entfällt die Aufteilung wie heute.
|
||||||
|
|
||||||
|
### Ablauf nach der Zahlung (Einmal-Patenschaft)
|
||||||
|
|
||||||
|
1. Der Stripe-Webhook markiert die Bestellung wie heute über den atomaren
|
||||||
|
Claim als bezahlt. Nur dieser Lauf legt die `Sponsorship`-Zeilen an.
|
||||||
|
Ein Duplikat scheitert am eindeutigen `orderItemId` und bricht still ab.
|
||||||
|
2. Danach versucht der Webhook die Erzeugung direkt: Text, Layout, PDF,
|
||||||
|
Ablage, Hash, erste Revision.
|
||||||
|
3. Die Bestellbestätigung verlinkt auf `/konto/patenschaft/<id>`. Der
|
||||||
|
Mailversand wird über `updateMany … where mailSentAt = null` beansprucht,
|
||||||
|
damit doppelte Webhooks keine zweite Mail erzeugen. Erst danach ist der
|
||||||
|
Status `BEREIT`.
|
||||||
|
4. Scheitert die Erzeugung, antwortet der Webhook trotzdem 200 (die Zahlung
|
||||||
|
ist erfolgt). Die Patenschaft bleibt `AUSSTEHEND`. Die Mail sagt: „Deine
|
||||||
|
Urkunde wird gerade erstellt und erscheint in Kürze in deinem Konto.“
|
||||||
|
5. Der CronJob `wabenhain-certificates` (alle 10 Minuten,
|
||||||
|
`concurrencyPolicy: Forbid`, wie die Jobs in `k8s/07-backup-cronjob.yaml`)
|
||||||
|
holt offene Erzeugungen und Mails nach, mit wachsendem Abstand, höchstens
|
||||||
|
8 Versuche, danach `FEHLER` und Hinweis in der Aufmerksamkeitsliste.
|
||||||
|
|
||||||
|
Die finale Urkunde entsteht nie vor bestätigter Zahlung. SEPA-Zahlungen
|
||||||
|
erzeugen sie erst bei `async_payment_succeeded`. Beim Abo entsteht sie mit der
|
||||||
|
ersten bezahlten Rechnung.
|
||||||
|
|
||||||
|
### Ablage und Zugriff
|
||||||
|
|
||||||
|
- PDFs liegen in `CERTIFICATE_DIR`, nicht unter `UPLOAD_DIR`. Die
|
||||||
|
Startprüfung aus `lib/invoice-storage.ts` wird übernommen. Das Verzeichnis
|
||||||
|
kommt auf das gesicherte Volume und in den Sicherungs-CronJob.
|
||||||
|
- Alle Downloads und die 3D-Textur liefern Routen aus, die Anmeldung und
|
||||||
|
Besitz prüfen. Fremde IDs ergeben 404.
|
||||||
|
- Downloads liefern immer die gespeicherte PDF der aktuellen Revision.
|
||||||
|
- Kein QR-Code auf der Urkunde: Er brächte kaum Nutzen und würde die
|
||||||
|
Gestaltung stören.
|
||||||
|
|
||||||
|
### Vorschau
|
||||||
|
|
||||||
|
- `POST /api/patenschaft/vorschau`: prüft die Personalisierung, gibt ein WebP
|
||||||
|
mit Wasserzeichen „Unverbindliche Vorschau“ in Bildschirmauflösung zurück.
|
||||||
|
Rate-Limit je Besucher. Keine Anmeldung nötig.
|
||||||
|
- Der Client fordert sie etwa 400 ms nach der letzten Eingabe an und zeigt bis
|
||||||
|
dahin die letzte gültige Vorschau.
|
||||||
|
|
||||||
|
## Adminbereich
|
||||||
|
|
||||||
|
- Angebote: Einmal- und Abo-Angebote bearbeiten, Kontingent, Erntefenster,
|
||||||
|
Medaillenstufe.
|
||||||
|
- Patenschaften: Liste mit Filtern (ausstehend, Fehler, bereit, zu drucken,
|
||||||
|
gedruckt, Art, Angebot). Detail mit Vorschau, Revisionen, „Druck-PDF
|
||||||
|
öffnen“, „Als gedruckt markieren“, „Erneut erzeugen“, „Mail erneut senden“,
|
||||||
|
Übertragungshistorie.
|
||||||
|
- Abos: Liste mit Status, bezahlten Monaten, nächster Lieferung; Deckung des
|
||||||
|
Honigbedarfs.
|
||||||
|
- Treuestufen: Schwellen und Namen pflegen.
|
||||||
|
- Korrektur: Felder im Kundenformular ändern, Vorschau mit derselben
|
||||||
|
Layoutlogik, Pflichtfeld Änderungsgrund, neue Revision mit neuer PDF, alte
|
||||||
|
bleibt abrufbar, Eintrag über `logAdminAction`, optional Mail an den Kunden.
|
||||||
|
Standardmäßig mit der Vorlagenversion der bisherigen Revision.
|
||||||
|
- Druckablauf: Physische Bestellungen erscheinen nach Zahlung unter „zu
|
||||||
|
drucken“. PDF öffnen, auf festem Papier drucken, als gedruckt markieren,
|
||||||
|
wie gewohnt über DHL versenden.
|
||||||
|
|
||||||
|
## Verkaufsseite
|
||||||
|
|
||||||
|
Route `/patenschaft`, zweisprachig, im Design der Website.
|
||||||
|
|
||||||
|
Im Mittelpunkt:
|
||||||
|
|
||||||
|
- einer Biene symbolisch einen eigenen Namen geben
|
||||||
|
- ein persönlich gestaltetes Geschenk
|
||||||
|
- die regionale Imkerei Wabenhain unterstützen
|
||||||
|
- je nach Angebot eigenen Wabenhain-Honig, einmalig oder regelmäßig
|
||||||
|
|
||||||
|
Aufbau:
|
||||||
|
|
||||||
|
1. Kopfbereich mit großer, echter Urkundenvorschau
|
||||||
|
2. Umschalter „Einmalig“ / „Im Abo“ mit den Angeboten, Preis, Leistungen,
|
||||||
|
Versandkosten beziehungsweise „Versand inklusive“ und den Lieferregeln
|
||||||
|
3. Personalisierung in Schritten: Angebot, für wen und Anlass, Namen, Widmung
|
||||||
|
und Startdatum, Vorschau prüfen
|
||||||
|
4. Ausschnitt aus dem Kontobereich (3D-Urkunde und Medaille als Video oder
|
||||||
|
Bildfolge) mit dem Hinweis, dass der Kauf ein Kundenkonto braucht
|
||||||
|
5. Hinweis zur symbolischen Namensgebung, gut sichtbar
|
||||||
|
6. FAQ
|
||||||
|
|
||||||
|
Smartphone: Vorschau über dem jeweiligen Schritt, ein Tipp vergrößert sie.
|
||||||
|
Eingabefelder mindestens 16 px Schrift, Bedienelemente mindestens 44 px.
|
||||||
|
|
||||||
|
FAQ-Themen: symbolische Namensgebung, Laufzeit (einmalig zwölf Monate, Abo
|
||||||
|
monatlich kündbar), wann der Honig im Abo kommt, warum ein Konto nötig ist,
|
||||||
|
Übertragung an Beschenkte, Versandkosten, Tippfehler (Kontakt, Korrektur
|
||||||
|
durch Wabenhain), was nach Ablauf oder Kündigung bleibt. Keine FAQ verspricht
|
||||||
|
Besuche, Berichte, Fotos oder Umweltwirkungen.
|
||||||
|
|
||||||
|
## Prüfung
|
||||||
|
|
||||||
|
### Layout-Testmatrix
|
||||||
|
|
||||||
|
Automatisierte Tests rendern echte PDFs und prüfen die Layoutdaten (keine
|
||||||
|
Überlappung, alles in der Sicherheitszone, Schrift nie unter Mindestgröße,
|
||||||
|
genau eine Seite) für:
|
||||||
|
|
||||||
|
- sehr kurze Namen („Li“, „Bo“)
|
||||||
|
- maximal lange Namen in beiden Feldern
|
||||||
|
- Doppelnamen („Marie-Luise Schmidt-Habermann“)
|
||||||
|
- Umlaute und Sonderzeichen („Jürgen Weiß“, „Zoë“, „Ñúñez“, „O’Brien“)
|
||||||
|
- mehrere Schenkende („Anna, Ben, Carla und Familie Özdemir“)
|
||||||
|
- ohne Widmung und mit maximal langer Widmung samt allen Zeilenumbrüchen
|
||||||
|
- jeden Anlass, eigene und verschenkte Patenschaft, Deutsch und Englisch
|
||||||
|
- alle drei Laufzeitformen (einmalig, Abo aktiv, Abo beendet)
|
||||||
|
- Vorschau, Textur und PDF ergeben dieselben Zeilen
|
||||||
|
|
||||||
|
Die Matrix legt die endgültigen Eingabelimits fest.
|
||||||
|
|
||||||
|
### Visuelle Kontrolle
|
||||||
|
|
||||||
|
PDFs werden mit 300 dpi gerastert und angesehen: ganze Seite und Ausschnitte
|
||||||
|
in hoher Vergrößerung. Probedruck auf einem Bürodrucker. Medaillen als PNG in
|
||||||
|
Originalgröße. 3D-Ansicht auf Desktop, 390 px mobil, mit
|
||||||
|
`prefers-reduced-motion` und ohne WebGL.
|
||||||
|
|
||||||
|
### Ablauftests
|
||||||
|
|
||||||
|
- doppelter Webhook: eine Patenschaft, eine Mail
|
||||||
|
- PDF-Erzeugung scheitert: Webhook 200, CronJob holt nach
|
||||||
|
- Gastkauf einer Patenschaft wird serverseitig abgewiesen
|
||||||
|
- fremde Patenschaft, fremder Download, fremde Textur: 404
|
||||||
|
- Korrektur: neue Revision, alte unverändert, Audit-Eintrag
|
||||||
|
- neue Vorlagenversion: alte Urkunden unverändert
|
||||||
|
- Übertragung: Link läuft ab, wird widerrufen, wird genau einmal eingelöst
|
||||||
|
- Abo: Lieferung erst nach drei bezahlten Monaten; Kündigung nach zwei
|
||||||
|
Monaten erzeugt keine Lieferung; Zahlungsausfall stoppt Lieferungen;
|
||||||
|
Kündigung erzeugt Von-bis-Revision und entfernt die Abo-Stufe
|
||||||
|
- Bestand: Einzelverkauf kann reservierte Abo-Gläser nicht kaufen;
|
||||||
|
Kontingent voll stoppt neue Abos
|
||||||
|
- doppelte Stripe-Abo-Ereignisse: eine Rechnung, eine Zahlung
|
||||||
|
- SEPA: erste Zahlung zählt erst nach der Neukunden-Frist; Rückbuchung
|
||||||
|
stoppt Lieferungen, setzt das Abo in Verzug, sperrt SEPA für das Konto;
|
||||||
|
ohne Ausgleich endet das Abo nach 14 Tagen
|
||||||
|
- Treuestufen: Erstattung senkt die Stammkunden-Stufe
|
||||||
|
- E2E (Playwright): Einmalkauf mit Stripe-Testkarte bis zur 3D-Ansicht im
|
||||||
|
Konto; Abo-Abschluss mit Stripe-Testuhr (Test Clock) über drei Monate
|
||||||
|
|
||||||
|
Die Repo-Skripte `lint`, `typecheck`, `test` und `build` laufen grün.
|
||||||
|
|
||||||
|
## Abnahme
|
||||||
|
|
||||||
|
Je Teilprojekt gilt: fertig erst nach den passenden Tests, der visuellen
|
||||||
|
Kontrolle und einer Vorführung. Gesamtabnahme:
|
||||||
|
|
||||||
|
1. Verkaufsseite und Personalisierung auf Desktop und 390 px mobil
|
||||||
|
2. die drei Designvarianten und die gewählte Gestaltung
|
||||||
|
3. mehrere echte PDF-Ausgaben mit unterschiedlichen Beispieldaten
|
||||||
|
4. automatischer Ablauf vom Kauf bis zur 3D-Ansicht und zum Download
|
||||||
|
5. Medaillen aller Arten und Stufen
|
||||||
|
6. Übertragung von Konto zu Konto
|
||||||
|
7. Abo über drei Monate mit Test Clock bis zur Lieferung, dann Kündigung
|
||||||
|
8. Druck- und Korrekturablauf im Admin
|
||||||
|
9. offene geschäftliche Konfiguration (Liste unten)
|
||||||
|
|
||||||
|
## Geschäftliche Konfiguration vor Veröffentlichung
|
||||||
|
|
||||||
|
1. Preise kaufmännisch prüfen: Einmalpakete und Abo-Monatspreise mit Honig,
|
||||||
|
Papier, Verpackung, Grußkarte, Versand, Stripe-Gebühren, Arbeitszeit.
|
||||||
|
2. Gläser je Abo-Lieferung, Erntefenster und Kontingente festlegen.
|
||||||
|
3. Steueraufteilung mit der Steuerberatung abstimmen.
|
||||||
|
4. AGB, Abo-Bedingungen und Widerrufsbelehrung: personalisierte Ware
|
||||||
|
(§ 312g Abs. 2 Nr. 1 BGB), digitale Inhalte (§ 356 Abs. 5 BGB),
|
||||||
|
Abo-Laufzeit und Kündigung, Kündigungsbutton (§ 312k BGB), Lieferregel
|
||||||
|
„nach bezahlten Monaten“, Übertragung auf eigene Verantwortung. Rechtlich
|
||||||
|
prüfen lassen.
|
||||||
|
5. Datenschutzerklärung: Namen Dritter, Speicherdauer, Stripe Billing.
|
||||||
|
6. Stripe-Livemodus freigeben: Die Einrichtung (siehe „Stripe-Einrichtung“)
|
||||||
|
läuft erst im Testmodus. Den Livemodus stellt das Skript erst nach
|
||||||
|
ausdrücklicher Freigabe des Inhabers um. Neukunden-Frist für SEPA
|
||||||
|
bestätigen (Vorgabe 56 Tage).
|
||||||
|
7. Versandstaffel für Patenschaftspakete anlegen.
|
||||||
|
8. Logo als SVG oder PNG mit mindestens 300 dpi hochladen.
|
||||||
|
9. Material bevorraten: Urkundenpapier (200–250 g/m², matt, naturweiß),
|
||||||
|
Geschenkverpackung, Grußkarten. Probedruck freigeben.
|
||||||
|
10. Bestellschluss für Weihnachten festlegen.
|
||||||
|
11. Textbausteine und Medaillennamen sprachlich freigeben (de und en).
|
||||||
@@ -69,6 +69,29 @@ data:
|
|||||||
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
||||||
INVOICE_DIR: "/app/invoices"
|
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.
|
||||||
|
# Gesetzt am 2026-10-16 (Livekonto, nur "Zahlungsart aendern"). Leer: 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: "bpc_1URCIfEvHaDWQ1U0nD4jVWXc"
|
||||||
|
|
||||||
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
||||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||||
# 19 = voller Satz
|
# 19 = voller Satz
|
||||||
@@ -95,6 +118,10 @@ data:
|
|||||||
VAT_RATE_PERCENT_PROPOLIS: "19"
|
VAT_RATE_PERCENT_PROPOLIS: "19"
|
||||||
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher).
|
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher).
|
||||||
VAT_RATE_PERCENT_SONSTIGES: "19"
|
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 ->
|
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
||||||
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
# und hier nachziehen. Tag plus Digest: der Tag ist lesbar, der
|
# und hier nachziehen. Tag plus Digest: der Tag ist lesbar, der
|
||||||
# Digest macht das Image unveraenderlich (Tags lassen sich in der
|
# Digest macht das Image unveraenderlich (Tags lassen sich in der
|
||||||
# Registry ueberschreiben).
|
# Registry ueberschreiben).
|
||||||
image: git.encry.de/nick.rosenloecher/wabenhain:sha-0d254db@sha256:2a81f6a0ccfa27139517558aea2b61c9c258122e15ecce00effd9abec9b36d51
|
image: git.encry.de/nick.rosenloecher/wabenhain:sha-1d88bac@sha256:49dd869c339f476fdb51a79e803f2c3cf8fba308b846cddb314b37d1d0a76731
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
+58
-24
@@ -9,9 +9,12 @@ alles laeuft: [../docs/EINRICHTUNG.md](../docs/EINRICHTUNG.md).
|
|||||||
|
|
||||||
## Einmalige Vorbereitung
|
## Einmalige Vorbereitung
|
||||||
|
|
||||||
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem
|
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` prueft bei jedem
|
||||||
Push auf `main` das Image `<registry>/<owner>/wabenhain:latest` (+
|
Push auf `main` lint, audit, typecheck und test und baut erst danach das
|
||||||
`:sha-<kurzhash>`, `:vX.Y.Z` bei Tags) in der Gitea-Container-Registry.
|
Image `<registry>/<owner>/wabenhain:latest` (+ `:sha-<kurzhash>`,
|
||||||
|
`:vX.Y.Z` bei Tags) in der Gitea-Container-Registry. Daneben entsteht
|
||||||
|
mit denselben Tags `wabenhain-tools` fuer die CronJobs in
|
||||||
|
`07-backup-cronjob.yaml`.
|
||||||
Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt
|
Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt
|
||||||
sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet
|
sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet
|
||||||
die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates
|
die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates
|
||||||
@@ -53,8 +56,9 @@ alles laeuft: [../docs/EINRICHTUNG.md](../docs/EINRICHTUNG.md).
|
|||||||
fuer Produkt- und Erntenfotos.
|
fuer Produkt- und Erntenfotos.
|
||||||
|
|
||||||
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
|
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
|
||||||
`git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen
|
`git.encry.de/nick.rosenloecher/wabenhain` und ist auf einen festen Build
|
||||||
(`:latest` passt fuer den jeweils neuesten `main`-Build). Die Domain steht
|
gepinnt (`:sha-<commit>@sha256:<digest>`), nie auf `:latest` — siehe
|
||||||
|
„Bei einem neuen Image“ unten. Die Domain steht
|
||||||
in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
|
in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
|
||||||
(`NEXT_PUBLIC_SITE_URL`) bereits als `wabenhain.com` — beide Stellen
|
(`NEXT_PUBLIC_SITE_URL`) bereits als `wabenhain.com` — beide Stellen
|
||||||
muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die
|
muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die
|
||||||
@@ -91,10 +95,25 @@ der erste Pod bereit ist.
|
|||||||
|
|
||||||
## Bei einem neuen Image (Redeploy)
|
## Bei einem neuen Image (Redeploy)
|
||||||
|
|
||||||
|
Das Deployment ist auf Tag **und** Digest gepinnt. Ein `rollout restart`
|
||||||
|
holt deshalb kein neues Image, sondern startet nur denselben Build neu (das
|
||||||
|
genuegt nach einer Aenderung an Secret oder ConfigMap). Einen neuen Build
|
||||||
|
rollt man so aus:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl rollout restart deployment/wabenhain-app -n wabenhain
|
# Digest des gewuenschten Builds aus der Gitea-Registry (Paket "wabenhain",
|
||||||
|
# Tag sha-<kurzer Commit>) uebernehmen:
|
||||||
|
kubectl -n wabenhain set image deploy/wabenhain-app \
|
||||||
|
app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
|
||||||
|
kubectl -n wabenhain rollout status deploy/wabenhain-app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Danach dieselbe Zeile in `05-app-deployment.yaml` nachziehen und
|
||||||
|
committen, damit Repo und Cluster nicht auseinanderlaufen. Laufen die
|
||||||
|
CronJobs aus `07-backup-cronjob.yaml`, gehoert ihr `wabenhain-tools`-Image
|
||||||
|
auf denselben Build (sie nutzen denselben Prisma-Client und muessen zum
|
||||||
|
Datenbankschema passen).
|
||||||
|
|
||||||
Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
|
Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
|
||||||
(idempotent, siehe oben).
|
(idempotent, siehe oben).
|
||||||
|
|
||||||
@@ -114,13 +133,24 @@ Redeploy.
|
|||||||
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
|
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
|
||||||
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
|
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
|
||||||
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
|
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
|
||||||
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`, keine
|
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`. Die
|
||||||
`NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen.
|
`NetworkPolicy`s (`default-deny-ingress`, `allow-same-namespace`,
|
||||||
|
`allow-from-traefik`) und die Traefik-Middleware `cloudflare-only` liegen
|
||||||
|
nicht hier, sondern in den Cluster-Manifesten (Haertung vom 2026-10-12,
|
||||||
|
Ordner `rke2-cluster/`, nicht unter Git).
|
||||||
|
- Repo und Cluster weichen voneinander ab (Stand 2026-10-16): Im Cluster
|
||||||
|
sind `wabenhain-uploads` (5Gi) und `wabenhain-invoices` (50Gi)
|
||||||
|
`ReadWriteMany` auf SMB mit eigenen PersistentVolumes, hier stehen
|
||||||
|
`ReadWriteOnce`-Claims gegen die Default-StorageClass (5Gi/2Gi).
|
||||||
|
`STRIPE_PUBLISHABLE_KEY` liegt im Cluster im Secret, hier in der
|
||||||
|
ConfigMap (beides funktioniert, weil die App beide Quellen per `envFrom`
|
||||||
|
liest). Vor einem `kubectl apply` auf ein bestehendes Cluster die
|
||||||
|
Unterschiede pruefen.
|
||||||
- Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
|
- Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
|
||||||
Replikation**. Eine taegliche Sicherung gibt es inzwischen
|
Replikation**. Eine taegliche Sicherung gibt es inzwischen
|
||||||
(`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
|
(`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
|
||||||
liegt aber im selben Cluster und ersetzt damit weder Replikation noch
|
liegt aber auf derselben Storage Box wie die Datenbank und ersetzt damit
|
||||||
eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
|
weder Replikation noch eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
|
||||||
Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
|
Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
|
||||||
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
|
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
|
||||||
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
|
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
|
||||||
@@ -263,7 +293,8 @@ PersistentVolume:
|
|||||||
```yaml
|
```yaml
|
||||||
spec:
|
spec:
|
||||||
mountOptions:
|
mountOptions:
|
||||||
- vers=3.0
|
- vers=3.1.1
|
||||||
|
- seal # SMB-Verschluesselung, die Storage Box liegt im Internet
|
||||||
- uid=1001 # muss zum Containerbenutzer passen
|
- uid=1001 # muss zum Containerbenutzer passen
|
||||||
- gid=1001
|
- gid=1001
|
||||||
- dir_mode=0770
|
- dir_mode=0770
|
||||||
@@ -334,9 +365,14 @@ ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \
|
|||||||
|
|
||||||
## Sicherung und Wiederherstellung
|
## Sicherung und Wiederherstellung
|
||||||
|
|
||||||
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
|
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr (Zeitzone des
|
||||||
gzip-komprimierte `pg_dump`-Sicherung auf einem eigenen Volume ab und
|
Clusters, meist UTC) eine gzip-komprimierte `pg_dump`-Sicherung und ein
|
||||||
haelt sie 30 Tage vor. Ohne diese Sicherung liegen Bestellungen,
|
`rechnungen-<Zeitstempel>.tar.gz` der Rechnungsablage auf einem eigenen
|
||||||
|
Volume ab und haelt beides 30 Tage vor. Das Volume ist ein SMB-PV auf der
|
||||||
|
Storage Box (`//u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain`,
|
||||||
|
Unterordner `db-backup`). Im Cluster ist der Job seit 2026-10-10
|
||||||
|
angelegt; die letzten drei Laeufe waren erfolgreich, zuletzt am 2026-10-16
|
||||||
|
um 03:15 UTC. Ohne diese Sicherung liegen Bestellungen,
|
||||||
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
|
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
|
||||||
jedes Netz — und Rechnungen unterliegen in Deutschland einer
|
jedes Netz — und Rechnungen unterliegen in Deutschland einer
|
||||||
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
|
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
|
||||||
@@ -354,16 +390,14 @@ kubectl logs -n wabenhain -l job-name --tail=50 # Ausgabe des letzten Laufs
|
|||||||
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
|
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
|
||||||
```
|
```
|
||||||
|
|
||||||
**Aus dem Cluster herausziehen.** Die Sicherung im Cluster schuetzt gegen
|
**Aus dem Cluster herausziehen.** Die Sicherung schuetzt gegen
|
||||||
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
|
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall der Storage Box
|
||||||
Clusters oder Standorts. Deshalb regelmaessig (oder per eigenem Cron auf
|
oder des Standorts. Deshalb regelmaessig (oder per eigenem Cron auf einem
|
||||||
einem Rechner ausserhalb) kopieren:
|
Rechner ausserhalb) an einen zweiten Ort kopieren. Die Dateien liegen auf
|
||||||
|
der Storage Box unter `Server/Disks/Customer/Wabenhain/db-backup/` und
|
||||||
```bash
|
lassen sich dort direkt abholen (SMB, SFTP oder rsync der Storage Box). Ein
|
||||||
POD=$(kubectl get pod -n wabenhain -l job-name -o name | head -1)
|
`kubectl exec` in den Sicherungs-Pod geht nicht: der Pod ist nach dem Lauf
|
||||||
kubectl exec -n wabenhain "$POD" -- ls -1 /backup
|
beendet (`Completed`).
|
||||||
kubectl cp wabenhain/"${POD#pod/}":/backup/wabenhain-2026-01-31_0315.sql.gz ./wabenhain-2026-01-31.sql.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wiederherstellen.** Vorher die App anhalten, damit waehrend des
|
**Wiederherstellen.** Vorher die App anhalten, damit waehrend des
|
||||||
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
|
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
|
||||||
|
|||||||
+7
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
||||||
"start": "node scripts/generate-prisma-clients.mjs && next start",
|
"start": "node scripts/generate-prisma-clients.mjs && next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "node scripts/generate-prisma-clients.mjs && tsc --noEmit",
|
"typecheck": "node scripts/generate-prisma-clients.mjs && next typegen && tsc --noEmit",
|
||||||
"postinstall": "node scripts/generate-prisma-clients.mjs",
|
"postinstall": "node scripts/generate-prisma-clients.mjs",
|
||||||
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
||||||
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
||||||
@@ -27,10 +27,14 @@
|
|||||||
"@prisma/adapter-mariadb": "^7.10.0",
|
"@prisma/adapter-mariadb": "^7.10.0",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
|
"@react-three/drei": "10.0.6",
|
||||||
|
"@react-three/fiber": "9.1.2",
|
||||||
"@stripe/react-stripe-js": "^6.12.0",
|
"@stripe/react-stripe-js": "^6.12.0",
|
||||||
"@stripe/stripe-js": "^9.17.0",
|
"@stripe/stripe-js": "^9.17.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"fflate": "0.8.2",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"jose": "^6.2.8",
|
"jose": "^6.2.8",
|
||||||
"mariadb": "^3.5.4",
|
"mariadb": "^3.5.4",
|
||||||
"motion": "^13.0.0",
|
"motion": "^13.0.0",
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
"stripe": "^22.4.0",
|
"stripe": "^22.4.0",
|
||||||
|
"three": "0.170.0",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -60,6 +65,7 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/three": "0.170.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
Generated
+494
-10
@@ -26,6 +26,12 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.10.0
|
specifier: ^7.10.0
|
||||||
version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||||
|
'@react-three/drei':
|
||||||
|
specifier: 10.0.6
|
||||||
|
version: 10.0.6(@react-three/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
|
'@react-three/fiber':
|
||||||
|
specifier: 9.1.2
|
||||||
|
version: 9.1.2(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
'@stripe/react-stripe-js':
|
'@stripe/react-stripe-js':
|
||||||
specifier: ^6.12.0
|
specifier: ^6.12.0
|
||||||
version: 6.12.0(@stripe/[email protected])([email protected]([email protected]))([email protected])
|
version: 6.12.0(@stripe/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -35,9 +41,15 @@ importers:
|
|||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
fflate:
|
||||||
|
specifier: 0.8.2
|
||||||
|
version: 0.8.2
|
||||||
ffmpeg-static:
|
ffmpeg-static:
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0([email protected])
|
version: 5.3.0([email protected])
|
||||||
|
fontkit:
|
||||||
|
specifier: 2.0.4
|
||||||
|
version: 2.0.4
|
||||||
jose:
|
jose:
|
||||||
specifier: ^6.2.8
|
specifier: ^6.2.8
|
||||||
version: 6.2.8
|
version: 6.2.8
|
||||||
@@ -77,12 +89,15 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: ^22.4.0
|
specifier: ^22.4.0
|
||||||
version: 22.4.0(@types/[email protected])
|
version: 22.4.0(@types/[email protected])
|
||||||
|
three:
|
||||||
|
specifier: 0.170.0
|
||||||
|
version: 0.170.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.14
|
specifier: ^5.0.14
|
||||||
version: 5.0.14(@types/[email protected])([email protected])
|
version: 5.0.14(@types/[email protected])([email protected])([email protected]([email protected]))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.62.1
|
specifier: ^1.62.1
|
||||||
@@ -120,6 +135,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.4(@types/[email protected])
|
version: 19.2.4(@types/[email protected])
|
||||||
|
'@types/three':
|
||||||
|
specifier: 0.170.0
|
||||||
|
version: 0.170.0
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.5
|
specifier: ^6.0.5
|
||||||
version: 6.0.5([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
version: 6.0.5([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -713,6 +731,14 @@ packages:
|
|||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@mediapipe/[email protected]':
|
||||||
|
resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
|
||||||
|
|
||||||
|
'@monogrid/[email protected]':
|
||||||
|
resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>= 0.159.0'
|
||||||
|
|
||||||
'@napi-rs/[email protected]':
|
'@napi-rs/[email protected]':
|
||||||
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==}
|
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||||
@@ -954,6 +980,42 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@react-three/[email protected]':
|
||||||
|
resolution: {integrity: sha512-QtiAv/a1BaP+ZYfp8BphV8BMSO0O1BNhIPye3Zqm5iDqgX6JeiknPR6f2UmzUdBfPLwjZEaqjfULgJFvTUWgkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@react-three/fiber': ^9.0.0
|
||||||
|
react: ^19
|
||||||
|
react-dom: ^19
|
||||||
|
three: '>=0.159'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@react-three/[email protected]':
|
||||||
|
resolution: {integrity: sha512-k8FR9yVHV9kIF3iuOD0ds5hVymXYXfgdKklqziBVod9ZEJ8uk05Zjw29J/omU3IKeUfLNAIHfxneN3TUYM4I2w==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '>=43.0'
|
||||||
|
expo-asset: '>=8.4'
|
||||||
|
expo-file-system: '>=11.0'
|
||||||
|
expo-gl: '>=11.0'
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
react-native: '>=0.78'
|
||||||
|
three: '>=0.156'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
expo:
|
||||||
|
optional: true
|
||||||
|
expo-asset:
|
||||||
|
optional: true
|
||||||
|
expo-file-system:
|
||||||
|
optional: true
|
||||||
|
expo-gl:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
react-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==}
|
resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1064,9 +1126,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==}
|
resolution: {integrity: sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==}
|
||||||
engines: {node: '>=12.16'}
|
engines: {node: '>=12.16'}
|
||||||
|
|
||||||
'@swc/[email protected]':
|
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||||
|
|
||||||
@@ -1197,6 +1256,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@testing-library/dom': '>=7.21.4'
|
'@testing-library/dom': '>=7.21.4'
|
||||||
|
|
||||||
|
'@tweenjs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||||
|
|
||||||
@@ -1246,6 +1308,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -1270,6 +1335,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
|
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
|
||||||
|
|
||||||
@@ -1284,9 +1352,23 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected]':
|
'@typescript-eslint/[email protected]':
|
||||||
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
|
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1466,6 +1548,14 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@use-gesture/[email protected]':
|
||||||
|
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
|
||||||
|
|
||||||
|
'@use-gesture/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8.0'
|
||||||
|
|
||||||
'@visx/[email protected]':
|
'@visx/[email protected]':
|
||||||
resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==}
|
resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==}
|
||||||
|
|
||||||
@@ -1543,6 +1633,9 @@ packages:
|
|||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||||
|
|
||||||
|
'@webgpu/[email protected]':
|
||||||
|
resolution: {integrity: sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1698,6 +1791,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1726,6 +1822,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.126.1'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==}
|
resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==}
|
||||||
|
|
||||||
@@ -1777,6 +1878,11 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||||
|
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1914,6 +2020,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1938,6 +2047,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2198,6 +2310,12 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
|
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -2328,6 +2446,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2374,6 +2495,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -2389,6 +2513,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -2397,6 +2524,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
|
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2498,6 +2628,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
@@ -2547,6 +2680,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.0.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2617,6 +2755,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2804,6 +2945,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/three': '>=0.134.0'
|
||||||
|
three: '>=0.134.0'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -2822,6 +2969,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.137'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
@@ -3123,6 +3278,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
|
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3148,6 +3306,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -3183,6 +3344,21 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.0.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13'
|
||||||
|
react-dom: '>=16.13'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3285,6 +3461,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -3373,6 +3552,15 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/three': '*'
|
||||||
|
three: '*'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
@@ -3459,6 +3647,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17.0'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -3469,6 +3662,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>= 0.159.0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.128.0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||||
|
|
||||||
@@ -3506,6 +3712,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.125.0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==}
|
||||||
|
peerDependencies:
|
||||||
|
three: '>=0.125.0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -3523,6 +3742,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3587,9 +3809,18 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3686,6 +3917,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3780,6 +4017,21 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -4261,6 +4513,13 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@mediapipe/[email protected]': {}
|
||||||
|
|
||||||
|
'@monogrid/[email protected]([email protected])':
|
||||||
|
dependencies:
|
||||||
|
promise-worker-transferable: 1.0.4
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
|
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -4494,6 +4753,61 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
|
'@react-three/[email protected](@react-three/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@mediapipe/tasks-vision': 0.10.17
|
||||||
|
'@monogrid/gainmap-js': 3.4.0([email protected])
|
||||||
|
'@react-three/fiber': 9.1.2(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
|
'@use-gesture/react': 10.3.1([email protected])
|
||||||
|
camera-controls: 2.10.1([email protected])
|
||||||
|
cross-env: 7.0.3
|
||||||
|
detect-gpu: 5.0.70
|
||||||
|
glsl-noise: 0.0.0
|
||||||
|
hls.js: 1.7.3
|
||||||
|
maath: 0.10.8(@types/[email protected])([email protected])
|
||||||
|
meshline: 3.3.1([email protected])
|
||||||
|
react: 19.2.8
|
||||||
|
stats-gl: 2.4.2(@types/[email protected])([email protected])
|
||||||
|
stats.js: 0.17.0
|
||||||
|
suspend-react: 0.1.3([email protected])
|
||||||
|
three: 0.170.0
|
||||||
|
three-mesh-bvh: 0.8.3([email protected])
|
||||||
|
three-stdlib: 2.36.1([email protected])
|
||||||
|
troika-three-text: 0.52.5([email protected])
|
||||||
|
tunnel-rat: 0.1.2(@types/[email protected])([email protected])
|
||||||
|
use-sync-external-store: 1.7.0([email protected])
|
||||||
|
utility-types: 3.11.0
|
||||||
|
zustand: 5.0.14(@types/[email protected])([email protected])([email protected]([email protected]))
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.8([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/three'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@react-three/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@types/react-reconciler': 0.28.9(@types/[email protected])
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
base64-js: 1.5.1
|
||||||
|
buffer: 6.0.3
|
||||||
|
its-fine: 2.1.1(@types/[email protected])([email protected])
|
||||||
|
react: 19.2.8
|
||||||
|
react-reconciler: 0.31.0([email protected])
|
||||||
|
react-use-measure: 2.1.7([email protected]([email protected]))([email protected])
|
||||||
|
scheduler: 0.25.0
|
||||||
|
suspend-react: 0.1.3([email protected])
|
||||||
|
three: 0.170.0
|
||||||
|
use-sync-external-store: 1.7.0([email protected])
|
||||||
|
zustand: 5.0.14(@types/[email protected])([email protected])([email protected]([email protected]))
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.8([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4551,10 +4865,6 @@ snapshots:
|
|||||||
|
|
||||||
'@stripe/[email protected]': {}
|
'@stripe/[email protected]': {}
|
||||||
|
|
||||||
'@swc/[email protected]':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4665,6 +4975,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@testing-library/dom': 10.4.1
|
'@testing-library/dom': 10.4.1
|
||||||
|
|
||||||
|
'@tweenjs/[email protected]': {}
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4713,6 +5025,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
@@ -4733,6 +5047,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
@@ -4751,10 +5067,27 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
|
'@types/[email protected](@types/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.18
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@tweenjs/tween.js': 23.1.3
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
'@webgpu/types': 0.1.74
|
||||||
|
fflate: 0.8.2
|
||||||
|
meshoptimizer: 0.18.1
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])':
|
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -4916,6 +5249,13 @@ snapshots:
|
|||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@use-gesture/[email protected]': {}
|
||||||
|
|
||||||
|
'@use-gesture/[email protected]([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@use-gesture/core': 10.3.1
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
'@visx/[email protected]':
|
'@visx/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@visx/vendor': 4.0.0-alpha.0
|
'@visx/vendor': 4.0.0-alpha.0
|
||||||
@@ -5039,6 +5379,8 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
|
|
||||||
|
'@webgpu/[email protected]': {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.18.0
|
acorn: 8.18.0
|
||||||
@@ -5213,6 +5555,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
@@ -5249,6 +5596,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5295,6 +5646,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5427,6 +5782,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
webgl-constants: 1.1.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5443,6 +5802,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -5868,6 +6229,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@derhuerst/http-basic': 8.2.4
|
'@derhuerst/http-basic': 8.2.4
|
||||||
@@ -5910,7 +6275,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.23
|
||||||
brotli: 1.3.3
|
brotli: 1.3.3
|
||||||
clone: 2.1.2
|
clone: 2.1.2
|
||||||
dfa: 1.2.0
|
dfa: 1.2.0
|
||||||
@@ -6019,6 +6384,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -6055,6 +6422,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@noble/[email protected]):
|
[email protected](@noble/[email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||||
@@ -6076,10 +6445,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -6180,6 +6553,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6234,6 +6609,13 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@types/react-reconciler': 0.28.9(@types/[email protected])
|
||||||
|
react: 19.2.8
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -6310,6 +6692,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6441,6 +6827,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@types/three': 0.170.0
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -6459,6 +6850,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
@@ -6756,6 +7153,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6784,6 +7183,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
is-promise: 2.2.2
|
||||||
|
lie: 3.3.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -6822,6 +7226,17 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
scheduler: 0.25.0
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.8([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6943,6 +7358,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -7056,6 +7473,13 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@types/three': 0.170.0
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -7155,12 +7579,32 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@types/draco3d': 1.4.10
|
||||||
|
'@types/offscreencanvas': 2019.7.3
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
draco3d: 1.5.7
|
||||||
|
fflate: 0.6.11
|
||||||
|
potpack: 1.0.2
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -7192,6 +7636,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
bidi-js: 1.0.3
|
||||||
|
three: 0.170.0
|
||||||
|
troika-three-utils: 0.52.5([email protected])
|
||||||
|
troika-worker-utils: 0.52.0
|
||||||
|
webgl-sdf-generator: 1.1.1
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
three: 0.170.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -7211,6 +7669,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
zustand: 4.5.7(@types/[email protected])([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
- react
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -7321,8 +7787,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -7373,6 +7845,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -7495,7 +7971,15 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
zustand@5.0.14(@types/[email protected])([email protected]):
|
zustand@4.5.7(@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.7.0([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected])([email protected]([email protected])):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.18
|
||||||
|
react: 19.2.8
|
||||||
|
use-sync-external-store: 1.7.0([email protected])
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Widerrufene Patenschaft: Einmalkauf storniert oder voll erstattet
|
||||||
|
-- (Stornorechnung ausgestellt). Rein additiv; der neue Wert wird in dieser
|
||||||
|
-- Migration nicht benutzt, ALTER TYPE ADD VALUE ist deshalb auch innerhalb
|
||||||
|
-- der Migrationstransaktion zulaessig.
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "SponsorshipStatus" ADD VALUE 'WIDERRUFEN';
|
||||||
@@ -76,6 +76,17 @@ model Customer {
|
|||||||
wishlistItems WishlistItem[]
|
wishlistItems WishlistItem[]
|
||||||
auditLogs AuditLog[]
|
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")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +224,10 @@ enum ProductCategory {
|
|||||||
KERZEN
|
KERZEN
|
||||||
PROPOLIS
|
PROPOLIS
|
||||||
SONSTIGES
|
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 {
|
model Product {
|
||||||
@@ -291,6 +306,11 @@ model Product {
|
|||||||
wishlistItems WishlistItem[]
|
wishlistItems WishlistItem[]
|
||||||
stockNotifications StockNotification[]
|
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])
|
@@index([archivedAt])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
@@ -384,6 +404,9 @@ enum OrderStatus {
|
|||||||
enum FulfillmentMethod {
|
enum FulfillmentMethod {
|
||||||
VERSAND
|
VERSAND
|
||||||
ABHOLUNG
|
ABHOLUNG
|
||||||
|
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
|
||||||
|
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
|
||||||
|
DIGITAL
|
||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
@@ -462,6 +485,10 @@ model Order {
|
|||||||
invoice Invoice?
|
invoice Invoice?
|
||||||
returnRequest ReturnRequest?
|
returnRequest ReturnRequest?
|
||||||
withdrawalDeclarations WithdrawalDeclaration[]
|
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
|
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
|
||||||
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
|
/// 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.
|
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
|
||||||
apicoreAllocations Json?
|
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)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
sponsorship Sponsorship?
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||||
apicoreStockEvents ApiCoreStockEvent[]
|
apicoreStockEvents ApiCoreStockEvent[]
|
||||||
@@ -951,3 +991,334 @@ model WithdrawalDeclaration {
|
|||||||
@@index([processedAt])
|
@@index([processedAt])
|
||||||
@@map("withdrawal_declarations")
|
@@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
|
||||||
|
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
|
||||||
|
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
|
||||||
|
WIDERRUFEN
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/// UNGENUTZT: Pakete und Abos bekommen ihren Honig automatisch (nie cremig,
|
||||||
|
/// neueste Ernte, genug Bestand — lib/sponsorship/honey-selection.ts). Die
|
||||||
|
/// Spalte wird weder gelesen noch geschrieben und bleibt nur stehen, damit
|
||||||
|
/// die laufende Produktion keine Migration braucht; Altwerte sind bedeutungslos.
|
||||||
|
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."
|
"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()
|
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,122 @@
|
|||||||
|
/**
|
||||||
|
* Ersteinrichtung der Bienenpatenschaft in einer bestehenden Installation.
|
||||||
|
*
|
||||||
|
* Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der
|
||||||
|
* Inhaber schon bearbeitet hat (Felder werden nur gesetzt, wenn sie leer sind).
|
||||||
|
*
|
||||||
|
* - legt die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen),
|
||||||
|
* - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und
|
||||||
|
* Traegerprodukt an (syncOfferStripePrice).
|
||||||
|
*
|
||||||
|
* Einen Honig verknuepft das Skript nicht mehr (frueher --honey=<slug>):
|
||||||
|
* Pakete und Abos bekommen ihren Honig automatisch — nie cremig, neueste
|
||||||
|
* Ernte, genug Bestand (lib/sponsorship/honey-selection.ts). Die Spalte
|
||||||
|
* honeyProductId bleibt ungenutzt im Schema; der Probelauf zeigt zur
|
||||||
|
* Kontrolle, welchen Honig die Regel gerade waehlen wuerde.
|
||||||
|
*
|
||||||
|
* Aufruf (Werkzeug-Image, Umgebung wie die App):
|
||||||
|
* tsx scripts/sponsorship-bootstrap.ts (Probelauf)
|
||||||
|
* tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren)
|
||||||
|
*/
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
|
||||||
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||||
|
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||||
|
|
||||||
|
interface AboTemplate {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
features: string[];
|
||||||
|
featuresEn: string[];
|
||||||
|
priceCents: number;
|
||||||
|
honeyJars: number;
|
||||||
|
honeyShareCentsPerMonth: number;
|
||||||
|
cycle: "VIERTELJAEHRLICH" | "ERNTE";
|
||||||
|
maxActiveSubscriptions: number;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Namen: die Verkaufsseite zeigt sie unter "Im Abo", Stripe und die
|
||||||
|
// Rechnung als "Bienenpatenschaft-Abo <Name>".
|
||||||
|
// Testpreise — vor der Aktivierung kaufmaennisch pruefen (Spec, "Geschaeftliche
|
||||||
|
// Konfiguration"). Honiganteil je Monat = Glaeser je Lieferung × 7 € ÷ Monate
|
||||||
|
// je Lieferung, zum Honig-Steuersatz.
|
||||||
|
const ABO_TEMPLATES: AboTemplate[] = [
|
||||||
|
{
|
||||||
|
slug: "abo-vierteljaehrlich",
|
||||||
|
name: "Vierteljährlich",
|
||||||
|
nameEn: "Quarterly",
|
||||||
|
description: "Monatlich zahlen, alle drei bezahlten Monate zwei Gläser Wabenhain-Honig. Versand inklusive, monatlich kündbar.",
|
||||||
|
descriptionEn: "Pay monthly, two jars of Wabenhain honey after every three paid months. Shipping included, cancel monthly.",
|
||||||
|
features: ["Symbolische Namensgebung mit Urkunde", "2 × 500 g Honig nach jeweils 3 bezahlten Monaten", "Versand inklusive", "Monatlich kündbar"],
|
||||||
|
featuresEn: ["Symbolic naming with certificate", "2 × 500 g honey after every 3 paid months", "Shipping included", "Cancel monthly"],
|
||||||
|
priceCents: 1290,
|
||||||
|
honeyJars: 2,
|
||||||
|
honeyShareCentsPerMonth: 467,
|
||||||
|
cycle: "VIERTELJAEHRLICH",
|
||||||
|
maxActiveSubscriptions: 20,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "abo-ernte",
|
||||||
|
name: "Nach der Ernte",
|
||||||
|
nameEn: "After the harvest",
|
||||||
|
description: "Monatlich zahlen, Honig frisch nach der Ernte: drei Gläser, sobald seit der letzten Lieferung sechs Monate bezahlt sind. Versand inklusive, monatlich kündbar.",
|
||||||
|
descriptionEn: "Pay monthly, honey fresh after the harvest: three jars once six months have been paid since the last delivery. Shipping included, cancel monthly.",
|
||||||
|
features: ["Symbolische Namensgebung mit Urkunde", "3 × 500 g Honig im Erntefenster", "Versand inklusive", "Monatlich kündbar"],
|
||||||
|
featuresEn: ["Symbolic naming with certificate", "3 × 500 g honey in the harvest window", "Shipping included", "Cancel monthly"],
|
||||||
|
priceCents: 990,
|
||||||
|
honeyJars: 3,
|
||||||
|
honeyShareCentsPerMonth: 350,
|
||||||
|
cycle: "ERNTE",
|
||||||
|
maxActiveSubscriptions: 10,
|
||||||
|
sortOrder: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
const honey = pickHoneyProduct(await loadHoneyCandidates(), 1);
|
||||||
|
console.log(
|
||||||
|
`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const template of ABO_TEMPLATES) {
|
||||||
|
const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } });
|
||||||
|
if (existing) {
|
||||||
|
console.log(` Abo ${template.slug}: vorhanden${existing.stripePriceId ? "" : ", Stripe-Preis fehlt"}`);
|
||||||
|
if (apply && !existing.stripePriceId) await syncOfferStripePrice(existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` Abo ${template.slug}: anlegen (inaktiv, ${(template.priceCents / 100).toFixed(2)} €/Monat) + Stripe-Preis`);
|
||||||
|
if (!apply) continue;
|
||||||
|
const created = await prisma.sponsorshipOffer.create({
|
||||||
|
data: {
|
||||||
|
...template,
|
||||||
|
kind: "ABO",
|
||||||
|
isActive: false,
|
||||||
|
isPhysical: true,
|
||||||
|
medalTier: "ABO",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await syncOfferStripePrice(created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offers = await prisma.sponsorshipOffer.findMany({
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { slug: true, kind: true, isActive: true, priceCents: true, honeyJars: true, stripePriceId: true, productId: true },
|
||||||
|
});
|
||||||
|
console.table(offers);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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/apicore-sync";
|
||||||
export * from "./actions/dhl";
|
export * from "./actions/dhl";
|
||||||
export * from "./actions/archive";
|
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";
|
||||||
@@ -10,6 +10,8 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
|
||||||
|
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
|
||||||
|
|
||||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
|||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||||
}
|
}
|
||||||
|
// Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege
|
||||||
|
// Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts).
|
||||||
|
if (await hasBlockingSubscription(id)) {
|
||||||
|
return fail(
|
||||||
|
"/admin/benutzer",
|
||||||
|
"Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateDirs = await certificateDirsOfCustomer(id);
|
||||||
|
await discardPendingCheckouts(id);
|
||||||
const deleted = await prisma.customer.delete({ where: { id } });
|
const deleted = await prisma.customer.delete({ where: { id } });
|
||||||
|
await removeCertificateDirs(certificateDirs);
|
||||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||||
|
|||||||
@@ -66,14 +66,15 @@ async function settleCancelledOrder(order: {
|
|||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
previousStatus: OrderStatus;
|
previousStatus: OrderStatus;
|
||||||
stripeCheckoutSessionId: string | null;
|
stripeCheckoutSessionId: string | null;
|
||||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
|
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
||||||
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
||||||
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
||||||
if (order.payment?.status === "SUCCEEDED") {
|
// 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten.
|
||||||
|
if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) {
|
||||||
const paymentIntentId = order.payment.stripePaymentIntentId;
|
const paymentIntentId = order.payment.stripePaymentIntentId;
|
||||||
if (!paymentIntentId) {
|
if (!paymentIntentId) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -149,8 +150,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
stripeCheckoutSessionId: true,
|
stripeCheckoutSessionId: true,
|
||||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
|
||||||
items: { select: { productId: true, quantity: true, productName: true } },
|
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
|
||||||
returnRequest: { select: { status: true } },
|
returnRequest: { select: { status: true } },
|
||||||
invoice: { select: { cancellation: { select: { number: true } } } },
|
invoice: { select: { cancellation: { select: { number: true } } } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,205 @@
|
|||||||
|
"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 { setFlash } from "@/lib/flash";
|
||||||
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||||
|
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch
|
||||||
|
* gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im
|
||||||
|
* Schema stehen, damit die laufende Produktion keine Migration braucht; ein
|
||||||
|
* dort noch stehender Altwert wird nirgends mehr gelesen.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
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. Den Honig prueft
|
||||||
|
* honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
|
||||||
|
* jeder Bestellung und jeder neuen Ernte aendert.
|
||||||
|
*/
|
||||||
|
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.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditDetail(values: OfferFormValues): string {
|
||||||
|
const state = values.isActive ? "aktiv" : "inaktiv";
|
||||||
|
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den
|
||||||
|
* die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer
|
||||||
|
* die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann
|
||||||
|
* auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis
|
||||||
|
* fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis,
|
||||||
|
* statt sich ueber ein "ausverkauftes" neues Angebot zu wundern.
|
||||||
|
*/
|
||||||
|
async function honeyWarning(values: OfferFormValues): Promise<string | null> {
|
||||||
|
if (!values.isActive || values.honeyJars <= 0) return null;
|
||||||
|
if (pickHoneyProduct(await loadHoneyCandidates(), values.honeyJars)) return null;
|
||||||
|
return "Hinweis: Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand – bestellbar erst, wenn flüssiger Honig nachgelegt ist.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach dem Speichern: Stripe-Preis eines Abo-Angebots nachziehen, Honig pruefen 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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const warning = await honeyWarning(values);
|
||||||
|
if (warning) {
|
||||||
|
await setFlash("info", `${message} ${warning}`);
|
||||||
|
redirect(target);
|
||||||
|
}
|
||||||
|
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,204 @@
|
|||||||
|
"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.",
|
||||||
|
revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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 "revoked":
|
||||||
|
return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren.";
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -17,15 +17,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
import { isDhlConfigured } from "@/lib/dhl";
|
import { isDhlConfigured } from "@/lib/dhl";
|
||||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||||
|
|
||||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
items: {
|
||||||
|
include: {
|
||||||
|
product: { select: { slug: true } },
|
||||||
|
batch: { select: { code: true } },
|
||||||
|
sponsorship: { select: { number: true, status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
payment: true,
|
payment: true,
|
||||||
shipment: true,
|
shipment: true,
|
||||||
pickup: true,
|
pickup: true,
|
||||||
@@ -36,6 +44,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
});
|
});
|
||||||
if (!order) notFound();
|
if (!order) notFound();
|
||||||
const dhlConfigured = isDhlConfigured();
|
const dhlConfigured = isDhlConfigured();
|
||||||
|
// Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts);
|
||||||
|
// ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her.
|
||||||
|
const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : []));
|
||||||
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
|
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
|
||||||
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
||||||
// auseinanderlaufen koennen.
|
// auseinanderlaufen koennen.
|
||||||
@@ -88,6 +99,12 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{revokedNumbers.length > 0 ? (
|
||||||
|
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
|
||||||
|
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{order.giftMessage ? (
|
{order.giftMessage ? (
|
||||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||||
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||||
@@ -211,7 +228,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
||||||
{item.productName}
|
{item.productName}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
{item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||||
<td className="py-2 text-right">{item.quantity}</td>
|
<td className="py-2 text-right">{item.quantity}</td>
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||||
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
|
||||||
|
where: {
|
||||||
|
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
|
||||||
|
archivedAt: null,
|
||||||
|
fulfillmentMethod: { not: "DIGITAL" },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
||||||
|
|||||||
+23
-7
@@ -3,7 +3,7 @@ import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-p
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { pendingReviewWhere } from "@/lib/review-status";
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
|
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
|
||||||
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
import { COUNTED_SALE_WHERE, SHOP_SALE_WHERE } from "@/lib/sales";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -11,6 +11,7 @@ import { checkUploadFiles } from "@/lib/upload-health";
|
|||||||
import { checkUploadVolume } from "@/lib/uploads";
|
import { checkUploadVolume } from "@/lib/uploads";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import { HexMark } from "@/components/honeycomb";
|
import { HexMark } from "@/components/honeycomb";
|
||||||
|
import { getSponsorshipAttentionItems } from "@/app/admin/patenschaften/_lib/attention";
|
||||||
|
|
||||||
const DASHBOARD_ICON_PROPS = {
|
const DASHBOARD_ICON_PROPS = {
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -129,14 +130,20 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
|
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
|
||||||
}),
|
}),
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, fulfillmentMethod: { not: "DIGITAL" } },
|
||||||
}),
|
}),
|
||||||
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
||||||
prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }),
|
// Glaeser: Patenschafts-Positionen (Urkunde, Abo-Monatszeilen) sind keine.
|
||||||
|
prisma.orderItem.aggregate({
|
||||||
|
_sum: { quantity: true },
|
||||||
|
where: { order: COUNTED_SALE_WHERE, product: { category: { not: "PATENSCHAFT" } } },
|
||||||
|
}),
|
||||||
|
// Warenkorbwert nur aus echten Shop-Bestellungen: Abo-Monatsrechnungen
|
||||||
|
// und 0-€-Abo-Lieferungen sind keine Warenkoerbe.
|
||||||
prisma.order.aggregate({
|
prisma.order.aggregate({
|
||||||
_avg: { totalCents: true },
|
_avg: { totalCents: true },
|
||||||
_count: true,
|
_count: true,
|
||||||
where: COUNTED_SALE_WHERE,
|
where: SHOP_SALE_WHERE,
|
||||||
}),
|
}),
|
||||||
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
||||||
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
|
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
|
||||||
@@ -167,7 +174,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
|
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
|
||||||
prisma.order.groupBy({
|
prisma.order.groupBy({
|
||||||
by: ["customerId"],
|
by: ["customerId"],
|
||||||
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
|
where: { customerId: { not: null }, ...SHOP_SALE_WHERE },
|
||||||
having: { customerId: { _count: { gt: 1 } } },
|
having: { customerId: { _count: { gt: 1 } } },
|
||||||
}),
|
}),
|
||||||
prisma.discountCode.findMany({
|
prisma.discountCode.findMany({
|
||||||
@@ -266,14 +273,22 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
const stockRejected = stockIssues.rejected;
|
const stockRejected = stockIssues.rejected;
|
||||||
const unlinkedProducts = stockIssues.unlinked;
|
const unlinkedProducts = stockIssues.unlinked;
|
||||||
|
|
||||||
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
|
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
|
||||||
getCurrentAdmin(),
|
getCurrentAdmin(),
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
|
||||||
|
where: {
|
||||||
|
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
|
||||||
|
archivedAt: null,
|
||||||
|
fulfillmentMethod: { not: "DIGITAL" },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.review.count({ where: pendingReviewWhere() }),
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
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 = [
|
const toDo = [
|
||||||
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
|
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
|
||||||
@@ -283,6 +298,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
|
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
|
||||||
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
|
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
|
||||||
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
|
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
|
||||||
|
...sponsorshipItems,
|
||||||
].filter((item) => item.count > 0);
|
].filter((item) => item.count > 0);
|
||||||
|
|
||||||
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
|
// 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,174 @@
|
|||||||
|
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;
|
||||||
|
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,
|
||||||
|
honeyJars: 0,
|
||||||
|
cycle: null,
|
||||||
|
maxActiveSubscriptions: null,
|
||||||
|
medalTier: "BRONZE",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OfferFormProps {
|
||||||
|
defaults: OfferFormDefaults;
|
||||||
|
/** Beim Bearbeiten ist die Art fest. */
|
||||||
|
isNew: boolean;
|
||||||
|
/** "<Name> <Jahr>" des Honigs, den die automatische Wahl gerade nimmt; null = keiner reicht. */
|
||||||
|
currentHoneyLabel: string | null;
|
||||||
|
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, currentHoneyLabel, 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">
|
||||||
|
{/* Keine Auswahl mehr: Pakete und Abos bekommen den Honig automatisch
|
||||||
|
(lib/sponsorship/honey-selection.ts). Nur Anzeige, wird nicht gespeichert. */}
|
||||||
|
<AdminField
|
||||||
|
name="honeyDisplay"
|
||||||
|
label={tf.honey}
|
||||||
|
defaultValue={tf.honeyAuto(currentHoneyLabel)}
|
||||||
|
readOnly
|
||||||
|
hint={tf.honeyAutoHint}
|
||||||
|
/>
|
||||||
|
<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,104 @@
|
|||||||
|
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";
|
||||||
|
// Widerrufen (Storno/Erstattung): nur noch das Druck-PDF zur Ansicht, keine
|
||||||
|
// Aktion, die der Kundschaft wieder etwas zustellen oder aendern wuerde.
|
||||||
|
if (status === "WIDERRUFEN") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{issued ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
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.openPrintPdf}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<AdminActionNote>{t.noteRevoked}</AdminActionNote>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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,30 @@
|
|||||||
|
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",
|
||||||
|
WIDERRUFEN: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
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,96 @@
|
|||||||
|
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 { HoneyCoverage } 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deckung der Abos durch den automatisch gewaehlten (nicht-cremigen) Honig; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||||
|
export function CoveragePanel({ coverage, t }: { coverage: HoneyCoverage; 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>
|
||||||
|
{coverage.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.colYear}</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{coverage.rows.map((row) => (
|
||||||
|
<tr key={row.productId}>
|
||||||
|
<td className="p-3">{row.productName}</td>
|
||||||
|
<td className="p-3 text-right tabular-nums">{row.harvestYear}</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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{coverage.deliveriesCovered !== null ? (
|
||||||
|
<p className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink/75">
|
||||||
|
<span className="tabular-nums">{t.perDelivery(coverage.jarsPerDelivery)}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{t.coversLabel}:</span>
|
||||||
|
{coverage.deliveriesCovered < 1 ? (
|
||||||
|
<AdminBadge tone="danger" pulse>
|
||||||
|
{t.coverageWarning}
|
||||||
|
</AdminBadge>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums text-heading">{t.covers(coverage.deliveriesCovered)}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</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,43 @@
|
|||||||
|
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;
|
||||||
|
// Honigdeckung zaehlt hoechstens 1: Abos haben keinen festen Honig mehr,
|
||||||
|
// gedeckt wird aus allem nicht-cremigen Honig zusammen (coverage.ts).
|
||||||
|
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 },
|
||||||
|
{ count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
|
||||||
|
].filter((item) => item.count > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { freeStock, subscriptionReservationByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||||
|
import { loadHoneyStock } from "@/lib/sponsorship/honey-stock";
|
||||||
|
import { rankHoneyCandidates, type HoneyCandidate } from "@/lib/sponsorship/honey-selection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
|
||||||
|
* Kontingent"). Pakete und Abos bekommen automatisch nicht-cremigen Honig
|
||||||
|
* (lib/sponsorship/honey-selection.ts) — gezeigt wird deshalb genau dieser
|
||||||
|
* Honig, in der Rangfolge der automatischen Wahl. 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 EligibleHoneyRow extends HoneyStock {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
weightGrams: number;
|
||||||
|
harvestYear: number;
|
||||||
|
consistency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoneyCoverage {
|
||||||
|
/** Nicht-cremiger, aktiver Honig in der Rangfolge der automatischen Wahl. */
|
||||||
|
rows: EligibleHoneyRow[];
|
||||||
|
/** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
|
||||||
|
jarsPerDelivery: number;
|
||||||
|
/** Wie viele Lieferrunden der gesamte passende 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passender Honig (aktiv, nicht cremig) mit Bestand, Abo-Reservierung und
|
||||||
|
* freiem Bestand, in der Rangfolge der automatischen Wahl (bei gleicher Ernte
|
||||||
|
* nach freiem Bestand). Eine Abfrage-Runde fuer alle Produkte.
|
||||||
|
*/
|
||||||
|
export async function getEligibleHoneyStock(now: Date = new Date()): Promise<EligibleHoneyRow[]> {
|
||||||
|
const stock = await loadHoneyStock();
|
||||||
|
const reserved = await subscriptionReservationByProduct(now, prisma, stock);
|
||||||
|
const withFree = stock.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
freeStock: freeStock(candidate.inventory, reserved.get(candidate.id) ?? 0),
|
||||||
|
}));
|
||||||
|
return rankHoneyCandidates(withFree).map((candidate) => ({
|
||||||
|
productId: candidate.id,
|
||||||
|
productName: candidate.name,
|
||||||
|
weightGrams: candidate.weightGrams,
|
||||||
|
harvestYear: candidate.harvestYear,
|
||||||
|
consistency: candidate.consistency,
|
||||||
|
stock: candidate.inventory,
|
||||||
|
reserved: reserved.get(candidate.id) ?? 0,
|
||||||
|
free: candidate.freeStock,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilen zurueck in Kandidaten der automatischen Wahl (freier Bestand). */
|
||||||
|
export function toHoneyCandidates(rows: readonly EligibleHoneyRow[]): HoneyCandidate[] {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.productId,
|
||||||
|
name: row.productName,
|
||||||
|
weightGrams: row.weightGrams,
|
||||||
|
harvestYear: row.harvestYear,
|
||||||
|
consistency: row.consistency,
|
||||||
|
inventory: row.stock,
|
||||||
|
freeStock: row.free,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deckung der Abos durch den passenden Honig insgesamt. */
|
||||||
|
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverage> {
|
||||||
|
const [rows, subscriptions] = await Promise.all([
|
||||||
|
getEligibleHoneyStock(now),
|
||||||
|
prisma.sponsorshipSubscription.findMany({
|
||||||
|
where: { status: { in: [...DEMANDING_STATUSES] }, offer: { cycle: "VIERTELJAEHRLICH", honeyJars: { gt: 0 } } },
|
||||||
|
select: { offer: { select: { honeyJars: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const jarsPerDelivery = subscriptions.reduce((sum, { offer }) => sum + offer.honeyJars, 0);
|
||||||
|
const totalStock = rows.reduce((sum, row) => sum + row.stock, 0);
|
||||||
|
return { rows, jarsPerDelivery, deliveriesCovered: deliveriesCovered(totalStock, jarsPerDelivery) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die Aufmerksamkeitsliste: 1, wenn der passende Honig keine volle Lieferrunde mehr deckt. */
|
||||||
|
export function countUndercovered(coverage: Pick<HoneyCoverage, "deliveriesCovered">): number {
|
||||||
|
return coverage.deliveriesCovered !== null && coverage.deliveriesCovered < 1 ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { isOfferAvailable } from "@/lib/sponsorship/offers";
|
||||||
|
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||||
|
import { getEligibleHoneyStock, toHoneyCandidates, type EligibleHoneyRow, 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: { product: { select: { id: true, priceCents: true } } },
|
||||||
|
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number];
|
||||||
|
|
||||||
|
/** Der Honig, den die automatische Wahl gerade fuer ein Angebot nimmt. */
|
||||||
|
export interface CurrentHoney extends HoneyStock {
|
||||||
|
productId: string;
|
||||||
|
/** "<Name> <Jahr>" */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferOverviewRow {
|
||||||
|
offer: OfferRow;
|
||||||
|
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
|
||||||
|
isAvailable: boolean;
|
||||||
|
activeSubscriptions: number;
|
||||||
|
/** null bei Angeboten ohne Glaeser oder wenn kein passender Honig reicht. */
|
||||||
|
currentHoney: CurrentHoney | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferOverview {
|
||||||
|
rows: OfferOverviewRow[];
|
||||||
|
/** Passender Honig (nicht cremig) in der Rangfolge der automatischen Wahl. */
|
||||||
|
eligibleHoney: EligibleHoneyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die automatische Wahl fuer `jars` Glaeser als Anzeige-Datensatz — oder null. */
|
||||||
|
export function currentHoneyFor(eligibleHoney: readonly EligibleHoneyRow[], jars: number): CurrentHoney | null {
|
||||||
|
if (jars <= 0) return null;
|
||||||
|
const picked = pickHoneyProduct(toHoneyCandidates(eligibleHoney), jars);
|
||||||
|
const row = picked ? eligibleHoney.find((candidate) => candidate.productId === picked.id) : undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
productId: row.productId,
|
||||||
|
label: honeyLabel({ name: row.productName, harvestYear: row.harvestYear }),
|
||||||
|
stock: row.stock,
|
||||||
|
reserved: row.reserved,
|
||||||
|
free: row.free,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angebote samt Live-Verfuegbarkeit und automatisch gewaehltem Honig. 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<OfferOverview> {
|
||||||
|
const offers = await loadOffers(offerId ? { id: offerId } : {});
|
||||||
|
const [eligibleHoney, groups] = await Promise.all([
|
||||||
|
getEligibleHoneyStock(),
|
||||||
|
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]));
|
||||||
|
|
||||||
|
const rows = offers.map((offer) => {
|
||||||
|
const currentHoney = currentHoneyFor(eligibleHoney, offer.honeyJars);
|
||||||
|
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
|
||||||
|
return {
|
||||||
|
offer,
|
||||||
|
activeSubscriptions,
|
||||||
|
currentHoney,
|
||||||
|
isAvailable: isOfferAvailable({
|
||||||
|
isActive: offer.isActive,
|
||||||
|
kind: offer.kind,
|
||||||
|
hasCarrierProduct: offer.productId !== null,
|
||||||
|
honeyJars: offer.honeyJars,
|
||||||
|
hasEligibleHoney: currentHoney !== null,
|
||||||
|
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||||
|
activeSubscriptions,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { rows, eligibleHoney };
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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",
|
||||||
|
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",
|
||||||
|
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 mindestens ein Glas", () => {
|
||||||
|
const result = parseOfferForm(form({ ...oneTime, honeyJars: "0", honeySharePerJar: "" }));
|
||||||
|
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht mindestens ein Glas Honig." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt kein festes Honigprodukt mehr an — der Honig wird automatisch gewaehlt", () => {
|
||||||
|
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "prod_1" }));
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.value).not.toHaveProperty("honeyProductId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erlaubt ein rein digitales Angebot ohne Honig", () => {
|
||||||
|
const result = parseOfferForm(
|
||||||
|
form({ ...oneTime, isPhysical: "", 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,180 @@
|
|||||||
|
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),
|
||||||
|
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.");
|
||||||
|
// Welcher Honig, waehlt das System automatisch (nie cremig, neueste
|
||||||
|
// Ernte — lib/sponsorship/honey-selection.ts); hier geht es nur um die
|
||||||
|
// Menge. Ein festes Honigprodukt nimmt das Formular nicht mehr an.
|
||||||
|
if (offer.isPhysical && offer.honeyJars < 1) issue("Ein physisches Angebot braucht mindestens ein Glas Honig.");
|
||||||
|
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",
|
||||||
|
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", "WIDERRUFEN", "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,52 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
|
import { nextDeliveryView } from "./subscription-view";
|
||||||
|
import { countUndercovered, deliveriesCovered } 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("meldet hoechstens einen Eintrag: wenn der passende Honig zusammen keine Lieferrunde mehr deckt", () => {
|
||||||
|
expect(countUndercovered({ deliveriesCovered: 0 })).toBe(1);
|
||||||
|
expect(countUndercovered({ deliveriesCovered: 1 })).toBe(0);
|
||||||
|
expect(countUndercovered({ deliveriesCovered: null })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 coverage={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,80 @@
|
|||||||
|
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 { 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 {
|
||||||
|
rows: [row],
|
||||||
|
} = await getOfferOverview(id);
|
||||||
|
if (!row) notFound();
|
||||||
|
const { offer, isAvailable, currentHoney, activeSubscriptions } = row;
|
||||||
|
|
||||||
|
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>
|
||||||
|
{currentHoney ? (
|
||||||
|
<p className="mt-3 text-sm tabular-nums text-ink/75">
|
||||||
|
{tf.stockLine(currentHoney.label, currentHoney.stock, currentHoney.reserved, currentHoney.free)}
|
||||||
|
</p>
|
||||||
|
) : offer.honeyJars > 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-ink/75">{tf.noEligibleHoney}</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}
|
||||||
|
currentHoneyLabel={currentHoney?.label ?? null}
|
||||||
|
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,
|
||||||
|
honeyJars: offer.honeyJars,
|
||||||
|
cycle: offer.cycle,
|
||||||
|
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||||
|
medalTier: offer.medalTier,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 { currentHoneyFor } from "../../_lib/offer-overview";
|
||||||
|
import { getEligibleHoneyStock } from "../../_lib/coverage";
|
||||||
|
|
||||||
|
export default async function AdminSponsorshipOfferNewPage() {
|
||||||
|
const t = getSponsorshipAdminStrings(await getLocale());
|
||||||
|
// Vorschau der automatischen Wahl fuer ein Glas — die echte Wahl richtet
|
||||||
|
// sich nach den Glaesern des Angebots und faellt bei jeder Bestellung neu.
|
||||||
|
const currentHoney = currentHoneyFor(await getEligibleHoneyStock(), 1);
|
||||||
|
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 currentHoneyLabel={currentHoney?.label ?? null} action={createSponsorshipOfferAction} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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 } from "../_lib/offer-overview";
|
||||||
|
|
||||||
|
export default async function AdminSponsorshipOffersPage() {
|
||||||
|
const t = getSponsorshipAdminStrings(await getLocale());
|
||||||
|
const to = t.offers;
|
||||||
|
// Honig waehlt das System automatisch (nie cremig, neueste Ernte); gezeigt
|
||||||
|
// wird hier der passende Honig in der Rangfolge der Wahl.
|
||||||
|
const { rows, eligibleHoney } = await getOfferOverview();
|
||||||
|
// "Erste Wahl" = der Honig, den ein Paket mit einem Glas gerade bekaeme.
|
||||||
|
const firstChoiceId = eligibleHoney.find((row) => row.free > 0)?.productId;
|
||||||
|
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, currentHoney }) => (
|
||||||
|
<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.honeyJars > 0 ? (
|
||||||
|
to.honeyAuto(offer.honeyJars, currentHoney?.label ?? null)
|
||||||
|
) : (
|
||||||
|
<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-1 text-lg text-heading">{to.stockHeading}</h2>
|
||||||
|
<p className="mb-3 max-w-prose text-xs text-ink/65">{to.stockIntro}</p>
|
||||||
|
{eligibleHoney.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink/65">{to.noEligibleHoney}</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.colYear}</th>
|
||||||
|
<th className="p-4">{to.colConsistency}</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">
|
||||||
|
{eligibleHoney.map((row) => (
|
||||||
|
<tr key={row.productId}>
|
||||||
|
<td className="p-4">
|
||||||
|
{row.productName}
|
||||||
|
{row.productId === firstChoiceId ? (
|
||||||
|
<span className="ml-2">
|
||||||
|
<AdminBadge tone="positive">{to.firstChoice}</AdminBadge>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right tabular-nums">{row.harvestYear}</td>
|
||||||
|
<td className="p-4">{row.consistency}</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([
|
const [products, activeCount, archivedCount] = await Promise.all([
|
||||||
prisma.product.findMany({
|
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: {
|
include: {
|
||||||
harvest: true,
|
harvest: true,
|
||||||
batch: true,
|
batch: true,
|
||||||
@@ -37,8 +38,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
}),
|
}),
|
||||||
prisma.product.count({ where: { archivedAt: null } }),
|
prisma.product.count({ where: { archivedAt: null, category: { not: "PATENSCHAFT" } } }),
|
||||||
prisma.product.count({ where: { archivedAt: { not: null } } }),
|
prisma.product.count({ where: { archivedAt: { not: null }, category: { not: "PATENSCHAFT" } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
|
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ vi.mock("@/lib/stripe", () => ({
|
|||||||
const getProductsByIds = vi.fn();
|
const getProductsByIds = vi.fn();
|
||||||
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
|
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
|
||||||
|
|
||||||
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) }));
|
const getCurrentCustomer = vi.fn<() => Promise<unknown>>(async () => null);
|
||||||
|
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: () => getCurrentCustomer() }));
|
||||||
|
|
||||||
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||||
@@ -67,6 +68,14 @@ const db = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||||
|
// Patenschaften spielen in den meisten Faellen keine Rolle: keine Angebote zu
|
||||||
|
// den Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
|
||||||
|
// Die Rabatt-Tests unten legen ein Paket-Angebot an.
|
||||||
|
const getOffersByProductIds = vi.fn<(ids: string[]) => Promise<Map<string, unknown>>>(async () => new Map());
|
||||||
|
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) }));
|
||||||
|
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) }));
|
||||||
|
const loadHoneyCandidates = vi.fn<() => Promise<unknown[]>>(async () => []);
|
||||||
|
vi.mock("@/lib/sponsorship/honey-candidates", () => ({ loadHoneyCandidates: () => loadHoneyCandidates() }));
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
const { POST } = await import("./route");
|
||||||
|
|
||||||
@@ -112,6 +121,9 @@ beforeEach(() => {
|
|||||||
rateLimit.mockReturnValue(true);
|
rateLimit.mockReturnValue(true);
|
||||||
verifyTurnstileToken.mockResolvedValue(true);
|
verifyTurnstileToken.mockResolvedValue(true);
|
||||||
getProductsByIds.mockResolvedValue([honey]);
|
getProductsByIds.mockResolvedValue([honey]);
|
||||||
|
getCurrentCustomer.mockResolvedValue(null);
|
||||||
|
getOffersByProductIds.mockResolvedValue(new Map());
|
||||||
|
loadHoneyCandidates.mockResolvedValue([]);
|
||||||
getShippingRegion.mockResolvedValue("DE");
|
getShippingRegion.mockResolvedValue("DE");
|
||||||
db.discountCode.findMany.mockResolvedValue([]);
|
db.discountCode.findMany.mockResolvedValue([]);
|
||||||
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||||
@@ -414,4 +426,152 @@ describe("Checkout-Route", () => {
|
|||||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Rabatte und Patenschaften", () => {
|
||||||
|
// Paket "Wabenfreund": 38,00 € Patenschaft (MAIN) + 3 Glaeser Honiganteil
|
||||||
|
// zu je 7,00 € (HONEY) = 59,00 €. Beides zaehlt nicht als Rabattbasis.
|
||||||
|
const carrier = { ...honey, id: "carrier", name: "Wabenfreund", slug: "wabenfreund", priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT" };
|
||||||
|
const offer = {
|
||||||
|
id: "offer",
|
||||||
|
slug: "wabenfreund",
|
||||||
|
kind: "EINMALIG",
|
||||||
|
name: "Wabenfreund",
|
||||||
|
description: "",
|
||||||
|
features: [],
|
||||||
|
priceCents: 5900,
|
||||||
|
isPhysical: true,
|
||||||
|
honeyJars: 3,
|
||||||
|
honeyShareCentsPerJar: 700,
|
||||||
|
honeyShareCentsPerMonth: 0,
|
||||||
|
cycle: null,
|
||||||
|
medalTier: "GOLD",
|
||||||
|
productId: "carrier",
|
||||||
|
honeyProduct: { id: "p1", name: honey.name, weightGrams: 500 },
|
||||||
|
isAvailable: true,
|
||||||
|
};
|
||||||
|
const personalization = {
|
||||||
|
beeName: "Summsi",
|
||||||
|
recipientName: "Erika Mustermann",
|
||||||
|
occasion: "SELBST",
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
const packageItem = { productId: "carrier", quantity: 1, personalization };
|
||||||
|
const discountCode = {
|
||||||
|
id: "dc_1",
|
||||||
|
code: "SOMMER10",
|
||||||
|
isActive: true,
|
||||||
|
type: "PERCENT" as const,
|
||||||
|
value: 10,
|
||||||
|
minOrderCents: 0,
|
||||||
|
minQuantity: null,
|
||||||
|
maxUses: null,
|
||||||
|
usedCount: 0,
|
||||||
|
validFrom: null,
|
||||||
|
validUntil: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const honeyCandidate = { id: "p1", name: honey.name, weightGrams: 500, harvestYear: 2026, consistency: "flüssig", inventory: 100, freeStock: 100 };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() });
|
||||||
|
getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]]));
|
||||||
|
loadHoneyCandidates.mockResolvedValue([honeyCandidate]);
|
||||||
|
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Honigposition nimmt den automatisch gewaehlten Honig — nie cremigen — und reserviert ihn", async () => {
|
||||||
|
const liquid = { ...honey, id: "p2", name: "Sommertracht 2026", slug: "sommertracht-2026" };
|
||||||
|
loadHoneyCandidates.mockResolvedValue([
|
||||||
|
{ ...honeyCandidate, consistency: "feincremig", harvestYear: 2027 },
|
||||||
|
{ ...honeyCandidate, id: "p2", name: liquid.name },
|
||||||
|
]);
|
||||||
|
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, liquid, carrier].filter((product) => ids.includes(product.id)));
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, items: [packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Nur die Produktdaten des nicht-cremigen Honigs werden nachgeladen.
|
||||||
|
expect(getProductsByIds).toHaveBeenCalledWith(["p2"]);
|
||||||
|
const items = db.order.create.mock.calls[0][0].data.items.create;
|
||||||
|
expect(items).toContainEqual(expect.objectContaining({ productId: "p2", sponsorshipRole: "HONEY", quantity: 3, productName: "Sommertracht 2026 – Wabenfreund" }));
|
||||||
|
expect(db.inventory.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { productId: "p2", quantity: { gte: 3 } },
|
||||||
|
data: { quantity: { decrement: 3 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne passenden Honig ist das Paket nicht bestellbar", async () => {
|
||||||
|
loadHoneyCandidates.mockResolvedValue([{ ...honeyCandidate, consistency: "cremig" }]);
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, items: [packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(db.order.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => {
|
||||||
|
// 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige.
|
||||||
|
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(db.order.create.mock.calls[0][0].data).toMatchObject({ discountCents: 0, subtotalCents: 1980 + 5900 });
|
||||||
|
expect(couponsCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Mengenrabatt: Betrag nur auf die Ware ausserhalb des Pakets, Stripe-Coupon gleich hoch", async () => {
|
||||||
|
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
|
||||||
|
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 5 }, packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// 10 % von 5 × 9,90 € = 4,95 € — nicht von 108,50 €.
|
||||||
|
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(495);
|
||||||
|
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gutscheincode: Mindestbestellwert zaehlt nur die Ware ausserhalb von Patenschaften", async () => {
|
||||||
|
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 3000 });
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") });
|
||||||
|
expect(db.order.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gutscheincode: Rabatt nur auf die Ware ausserhalb von Patenschaften", async () => {
|
||||||
|
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||||
|
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(198);
|
||||||
|
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 198 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gutscheincode bei reinem Patenschafts-Warenkorb ist ungueltig", async () => {
|
||||||
|
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||||
|
|
||||||
|
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") });
|
||||||
|
expect(db.order.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Stripe-Positionsname nennt das Gewicht nur bei Ware mit Gewicht", async () => {
|
||||||
|
await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] });
|
||||||
|
|
||||||
|
const names = sessionsCreate.mock.calls[0][0].line_items.map(
|
||||||
|
(line: { price_data: { product_data: { name: string } } }) => line.price_data.product_data.name
|
||||||
|
);
|
||||||
|
expect(names).toContain("Fruehtracht 2026 (500 g)");
|
||||||
|
expect(names).toContain("Wabenfreund");
|
||||||
|
expect(names.some((name: string) => name.includes("(0 g)"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+101
-34
@@ -11,7 +11,7 @@ import { isTrustedOrigin } from "@/lib/csrf";
|
|||||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
||||||
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
|
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
|
||||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
import { isDiscountCodeValid, calculateDiscountCents, discountBase } from "@/lib/discount-calc";
|
||||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
import { getBaseUrl } from "@/lib/url";
|
import { getBaseUrl } from "@/lib/url";
|
||||||
import { vatRateForProduct } from "@/lib/vat";
|
import { vatRateForProduct } from "@/lib/vat";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||||
|
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||||
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||||
|
import { isCreamy } from "@/lib/sponsorship/honey-selection";
|
||||||
|
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||||
|
|
||||||
class OutOfStockError extends Error {}
|
class OutOfStockError extends Error {}
|
||||||
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||||
@@ -221,8 +226,25 @@ 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);
|
||||||
|
// Honig fuer Pakete waehlt der Checkout selbst, im Moment der Bestellung
|
||||||
|
// (lib/sponsorship/honey-selection.ts) — dafuer braucht er alle Kandidaten
|
||||||
|
// mit freiem Bestand und deren Produktdaten (Charge, Gewicht, Steuersatz).
|
||||||
|
// Cremiger Honig kommt nie in ein Paket, seine Produktdaten braucht es
|
||||||
|
// deshalb nicht.
|
||||||
|
const needsHoney = [...offersByProduct.values()].some((offer) => offer.honeyJars > 0);
|
||||||
|
const honeyCandidates = needsHoney ? await loadHoneyCandidates() : [];
|
||||||
|
const honeyIds = honeyCandidates
|
||||||
|
.filter((candidate) => !isCreamy(candidate.consistency) && !productIds.includes(candidate.id))
|
||||||
|
.map((candidate) => candidate.id);
|
||||||
const products = await getProductsByIds(productIds);
|
const products = await getProductsByIds(productIds);
|
||||||
|
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
|
||||||
|
|
||||||
if (products.length !== productIds.length) {
|
if (products.length !== productIds.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -265,34 +287,75 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderItemsData = input.items.map((cartItem) => {
|
// Positionen bilden und Patenschaftspakete in Patenschaft + Honig
|
||||||
const product = products.find((p) => p.id === cartItem.productId)!;
|
// aufloesen (lib/sponsorship/checkout-expand.ts). Der Steuersatz ist Teil
|
||||||
return {
|
// des Snapshots wie der Preis: er haengt an der Art der Ware (Honig
|
||||||
productId: product.id,
|
// ermaessigt, Kerzen und Patenschaft voll). Wird ein Produkt spaeter
|
||||||
batchId: product.batchId,
|
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
|
||||||
productName: product.name,
|
const productMap = new Map<string, CheckoutProduct>(
|
||||||
unitPriceCents: product.priceCents,
|
[...products, ...honeyProducts].map((product) => [product.id, product])
|
||||||
quantity: cartItem.quantity,
|
);
|
||||||
weightGrams: product.weightGrams,
|
const expanded = expandCheckoutItems({
|
||||||
totalCents: product.priceCents * cartItem.quantity,
|
cartItems: input.items,
|
||||||
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
|
products: productMap,
|
||||||
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
|
offers: offersByProduct,
|
||||||
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
|
honeyCandidates,
|
||||||
vatRatePercent: vatRateForProduct(product, process.env),
|
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);
|
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
|
||||||
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
||||||
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
|
// der Gesamtmenge der PHYSISCHEN Ware ueber alle Positionen hinweg
|
||||||
// einer Menge zusammengefasst, nicht separat berechnet) — kein
|
// (mehrere Artikel werden zu einer Menge zusammengefasst) — eine Urkunde
|
||||||
// wertbasierter Freibetrag mehr.
|
// ohne Paket wiegt nichts und loest keinen Versand aus.
|
||||||
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
const totalQuantity = expanded.physicalQuantity;
|
||||||
const shippingCents =
|
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, wie bisher: die verbindliche Bestandspruefung bleibt
|
||||||
|
// das bedingte Update in der Transaktion. Seit die Abo-Glaeser automatisch
|
||||||
|
// auf dem gerade gewaehlten Honig liegen, kann eine gleichzeitige Bestellung
|
||||||
|
// die Reservierung zwar auf einen anderen Honig verschieben; das bedingte
|
||||||
|
// Update haelt aber den Honig, auf dem sie laut dieser Momentaufnahme
|
||||||
|
// liegt, ueber ihrem Anteil. Die Reservierung in die Serializable-
|
||||||
|
// Transaktion zu ziehen, wuerde jede Bestellung mit jeder anderen
|
||||||
|
// Honigbuchung kollidieren lassen — bei Andrang deutlich mehr Abbrueche
|
||||||
|
// fuer einen Randfall, den im schlimmsten Fall eine zurueckgestellte
|
||||||
|
// Abo-Lieferung in der Aufmerksamkeitsliste auffaengt.
|
||||||
|
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
|
||||||
|
|
||||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||||
// haelt die Rabattlogik einfach nachvollziehbar.
|
// haelt die Rabattlogik einfach nachvollziehbar. Und nur auf Ware
|
||||||
|
// ausserhalb von Patenschaften (discountBase): Schwelle, Mindestwert und
|
||||||
|
// Betrag rechnen ohne Paketpositionen.
|
||||||
|
const base = discountBase(orderItemsData);
|
||||||
let discountCents = 0;
|
let discountCents = 0;
|
||||||
let discountCode: { id: string; code: string } | null = null;
|
let discountCode: { id: string; code: string } | null = null;
|
||||||
if (input.discountCode) {
|
if (input.discountCode) {
|
||||||
@@ -301,7 +364,9 @@ export async function POST(request: Request) {
|
|||||||
where: { code: input.discountCode.trim().toUpperCase() },
|
where: { code: input.discountCode.trim().toUpperCase() },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) {
|
// Ein reiner Patenschafts-Warenkorb hat keine Rabattbasis — der Code
|
||||||
|
// gilt dann als nicht einloesbar statt still 0 € abzuziehen.
|
||||||
|
if (!code || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -326,7 +391,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||||
discountCode = code;
|
discountCode = code;
|
||||||
} else {
|
} else {
|
||||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||||
@@ -337,10 +402,10 @@ export async function POST(request: Request) {
|
|||||||
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const code = candidates
|
const code = candidates
|
||||||
.filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity))
|
.filter((candidate) => base.subtotalCents > 0 && isDiscountCodeValid(candidate, base.subtotalCents, now, base.quantity))
|
||||||
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||||
if (code) {
|
if (code) {
|
||||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||||
discountCode = code;
|
discountCode = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +414,6 @@ export async function POST(request: Request) {
|
|||||||
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
||||||
|
|
||||||
const totalCents = subtotalCents - discountCents + shippingCents;
|
const totalCents = subtotalCents - discountCents + shippingCents;
|
||||||
const customer = await getCurrentCustomer();
|
|
||||||
|
|
||||||
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
||||||
// nur fuer registrierte, eingeloggte Kund:innen.
|
// nur fuer registrierte, eingeloggte Kund:innen.
|
||||||
@@ -363,8 +427,6 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = await getLocale();
|
|
||||||
|
|
||||||
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
|
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
|
||||||
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
|
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
|
||||||
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
|
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
|
||||||
@@ -385,9 +447,13 @@ export async function POST(request: Request) {
|
|||||||
const runOrderTransaction = () =>
|
const runOrderTransaction = () =>
|
||||||
prisma.$transaction(
|
prisma.$transaction(
|
||||||
async (tx) => {
|
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({
|
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 } },
|
data: { quantity: { decrement: item.quantity } },
|
||||||
});
|
});
|
||||||
if (reserved.count === 0) {
|
if (reserved.count === 0) {
|
||||||
@@ -421,7 +487,7 @@ export async function POST(request: Request) {
|
|||||||
cancelToken,
|
cancelToken,
|
||||||
customerId: customer?.id,
|
customerId: customer?.id,
|
||||||
locale,
|
locale,
|
||||||
fulfillmentMethod: input.fulfillmentMethod,
|
fulfillmentMethod,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
@@ -537,7 +603,8 @@ export async function POST(request: Request) {
|
|||||||
price_data: {
|
price_data: {
|
||||||
currency: "eur",
|
currency: "eur",
|
||||||
unit_amount: item.unitPriceCents,
|
unit_amount: item.unitPriceCents,
|
||||||
product_data: { name: `${item.productName} (${item.weightGrams} g)` },
|
// Gewicht nur bei Ware mit Gewicht — eine Urkunde "(0 g)" waere Unsinn.
|
||||||
|
product_data: { name: item.weightGrams > 0 ? `${item.productName} (${item.weightGrams} g)` : item.productName },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...(shippingCents > 0
|
...(shippingCents > 0
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ const db = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: 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
|
// Bestandsabgleich mit ApiCore: hier nur, DASS die bezahlte Bestellung als
|
||||||
// Verkauf in die Warteschlange geht; die Buchungslogik selbst testet
|
// 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 [])),
|
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 { POST } = await import("./route");
|
||||||
|
|
||||||
const ORDER_ID = "order_1";
|
const ORDER_ID = "order_1";
|
||||||
@@ -186,6 +199,17 @@ describe("Stripe-Webhook", () => {
|
|||||||
expect(sendMail).not.toHaveBeenCalled();
|
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 () => {
|
test("antwortet mit 500, solange STRIPE_WEBHOOK_SECRET fehlt", async () => {
|
||||||
delete process.env.STRIPE_WEBHOOK_SECRET;
|
delete process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
|||||||
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||||
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
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
|
* 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);
|
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 });
|
return NextResponse.json({ received: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +351,11 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
// Webhook legt also nichts doppelt an.
|
// Webhook legt also nichts doppelt an.
|
||||||
await recordOrderSale(tx, orderId);
|
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;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,6 +365,16 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
|
|
||||||
scheduleApiCoreStockSync(order.items.map((item) => item.productId));
|
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
|
// Abgleich zwischen dem, was wir berechnet haben, und dem, was Stripe
|
||||||
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
|
// tatsaechlich eingezogen hat. Beide Betraege stammen aus derselben Quelle
|
||||||
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
|
// (Positionspreise aus der Datenbank, Versand aus der Mengenstaffel, Rabatt
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { sponsorshipExportData } from "@/lib/sponsorship/data-export";
|
||||||
|
|
||||||
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
@@ -17,7 +18,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([
|
const [addresses, orders, reviews, wishlistItems, newsletter, sponsorship] = await Promise.all([
|
||||||
prisma.address.findMany({ where: { customerId: customer.id } }),
|
prisma.address.findMany({ where: { customerId: customer.id } }),
|
||||||
prisma.order.findMany({
|
prisma.order.findMany({
|
||||||
where: { customerId: customer.id },
|
where: { customerId: customer.id },
|
||||||
@@ -33,6 +34,8 @@ export async function GET() {
|
|||||||
include: { product: { select: { name: true } } },
|
include: { product: { select: { name: true } } },
|
||||||
}),
|
}),
|
||||||
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||||
|
// Patenschaften, Abos samt Zahlungen und Uebertragungen (ohne Stripe-Kennungen).
|
||||||
|
sponsorshipExportData(customer.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
@@ -82,6 +85,9 @@ export async function GET() {
|
|||||||
newsletter: newsletter
|
newsletter: newsletter
|
||||||
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||||
: { subscribed: false, confirmed: false },
|
: { subscribed: false, confirmed: false },
|
||||||
|
sponsorships: sponsorship.sponsorships,
|
||||||
|
sponsorshipSubscriptions: sponsorship.subscriptions,
|
||||||
|
sponsorshipTransfers: sponsorship.transfers,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/** 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(),
|
||||||
|
preparePendingCheckout: 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/sponsorship-subscription/pending", () => ({ preparePendingCheckout: m.preparePendingCheckout }));
|
||||||
|
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" });
|
||||||
|
m.preparePendingCheckout.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bereitet den Abschluss vor (alter offener Abschluss desselben Angebots wird ersetzt)", async () => {
|
||||||
|
await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
|
||||||
|
|
||||||
|
expect(m.preparePendingCheckout).toHaveBeenCalledWith({ customerId: "cust_1", offerId: "offer_1" });
|
||||||
|
expect(m.preparePendingCheckout.mock.invocationCallOrder[0]).toBeLessThan(m.createSubscriptionCheckout.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mehr als drei offene Abschluesse je Konto: klare Ablehnung, kein neuer Checkout", async () => {
|
||||||
|
m.preparePendingCheckout.mockResolvedValue({ ok: false, reason: "too_many_pending" });
|
||||||
|
|
||||||
|
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "too_many_pending", error: expect.stringContaining("offene Abo-Abschlüsse") });
|
||||||
|
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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";
|
||||||
|
import { preparePendingCheckout } from "@/lib/sponsorship-subscription/pending";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
// Ein alter offener Abschluss desselben Angebots wird ersetzt; mehr als
|
||||||
|
// drei offene je Konto nimmt der Shop nicht an (pending.ts).
|
||||||
|
const pending = await preparePendingCheckout({ customerId: customer.id, offerId: offer.id });
|
||||||
|
if (!pending.ok) {
|
||||||
|
return error(
|
||||||
|
"Du hast bereits mehrere offene Abo-Abschlüsse. Bitte schließe einen davon ab oder versuche es morgen erneut.",
|
||||||
|
409,
|
||||||
|
{ code: "too_many_pending" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,78 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard";
|
||||||
|
|
||||||
|
describe("createRenderGate", () => {
|
||||||
|
test("laesst hoechstens maxConcurrent Renderings gleichzeitig zu und gibt Plaetze wieder frei", () => {
|
||||||
|
const gate = createRenderGate({ maxConcurrent: 3, maxPerMinute: 300, now: () => 0 });
|
||||||
|
const slots = [gate.tryAcquire(), gate.tryAcquire(), gate.tryAcquire()];
|
||||||
|
expect(slots.every((slot) => slot.ok)).toBe(true);
|
||||||
|
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" });
|
||||||
|
|
||||||
|
const first = slots[0];
|
||||||
|
if (first.ok) first.release();
|
||||||
|
expect(gate.tryAcquire().ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doppeltes release gibt nicht zwei Plaetze frei", () => {
|
||||||
|
const gate = createRenderGate({ maxConcurrent: 1, maxPerMinute: 300, now: () => 0 });
|
||||||
|
const slot = gate.tryAcquire();
|
||||||
|
if (slot.ok) {
|
||||||
|
slot.release();
|
||||||
|
slot.release();
|
||||||
|
}
|
||||||
|
expect(gate.tryAcquire().ok).toBe(true);
|
||||||
|
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("begrenzt die Renderings je Minute und setzt das Fenster danach zurueck", () => {
|
||||||
|
let clock = 0;
|
||||||
|
const gate = createRenderGate({ maxConcurrent: 10, maxPerMinute: 2, now: () => clock });
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const slot = gate.tryAcquire();
|
||||||
|
if (slot.ok) slot.release();
|
||||||
|
}
|
||||||
|
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "minute_cap" });
|
||||||
|
|
||||||
|
clock = 60_000;
|
||||||
|
expect(gate.tryAcquire().ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLruCache", () => {
|
||||||
|
test("verdraengt den am laengsten nicht genutzten Eintrag", () => {
|
||||||
|
const cache = createLruCache<string>(2);
|
||||||
|
cache.set("a", "A");
|
||||||
|
cache.set("b", "B");
|
||||||
|
expect(cache.get("a")).toBe("A"); // a ist jetzt frischer als b
|
||||||
|
cache.set("c", "C");
|
||||||
|
|
||||||
|
expect(cache.get("b")).toBeUndefined();
|
||||||
|
expect(cache.get("a")).toBe("A");
|
||||||
|
expect(cache.get("c")).toBe("C");
|
||||||
|
expect(cache.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previewCacheKey", () => {
|
||||||
|
const personalization = { beeName: "Summsi", recipientName: "Erika", occasion: "SELBST" as const, startDate: "2026-10-16" };
|
||||||
|
const term = { kind: "FIXED" as const, start: "2026-10-16", end: "2027-10-15" };
|
||||||
|
|
||||||
|
test("gleiche Eingabe in anderer Schluesselreihenfolge ergibt denselben Schluessel", () => {
|
||||||
|
const a = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" });
|
||||||
|
const b = previewCacheKey({
|
||||||
|
personalization: { startDate: "2026-10-16", occasion: "SELBST", recipientName: "Erika", beeName: "Summsi" },
|
||||||
|
locale: "de",
|
||||||
|
term: { end: "2027-10-15", start: "2026-10-16", kind: "FIXED" },
|
||||||
|
templateVersion: "v1",
|
||||||
|
});
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sprache, Laufzeit und Vorlage gehoeren zum Schluessel", () => {
|
||||||
|
const base = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" });
|
||||||
|
expect(previewCacheKey({ personalization, locale: "en", term, templateVersion: "v1" })).not.toBe(base);
|
||||||
|
expect(previewCacheKey({ personalization, locale: "de", term: { ...term, end: "2027-10-14" }, templateVersion: "v1" })).not.toBe(base);
|
||||||
|
expect(previewCacheKey({ personalization, locale: "de", term, templateVersion: "v2" })).not.toBe(base);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schutz der Vorschau-Ausgabe vor Ueberlast. Das Rate-Limit je Besucher
|
||||||
|
* allein reicht nicht: viele Besucher (oder ein verteilter Angriff) koennen
|
||||||
|
* gemeinsam beliebig viele sharp-Renderings gleichzeitig anstossen und den
|
||||||
|
* Container lahmlegen. Deshalb zusaetzlich global:
|
||||||
|
*
|
||||||
|
* - hoechstens N Renderings gleichzeitig (sonst 503 mit Retry-After),
|
||||||
|
* - hoechstens M Renderings je Minute,
|
||||||
|
* - ein kleiner LRU-Zwischenspeicher fuer identische Eingaben (Zurueck-
|
||||||
|
* Klicken im Formular, Doppelklick, mehrere Tabs) — kostet kein Rendering.
|
||||||
|
*
|
||||||
|
* In-Memory wie lib/rate-limit.ts: ausreichend fuer den Single-Container-
|
||||||
|
* Betrieb. Als eigenes Modul, weil route.ts nur Next.js-Exporte haben darf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GateResult = { ok: true; release: () => void } | { ok: false; reason: "busy" | "minute_cap" };
|
||||||
|
|
||||||
|
export interface RenderGate {
|
||||||
|
tryAcquire(): GateResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
|
||||||
|
export function createRenderGate(options: { maxConcurrent: number; maxPerMinute: number; now?: () => number }): RenderGate {
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
let running = 0;
|
||||||
|
let windowStart = now();
|
||||||
|
let startedInWindow = 0;
|
||||||
|
return {
|
||||||
|
tryAcquire(): GateResult {
|
||||||
|
const time = now();
|
||||||
|
if (time - windowStart >= MINUTE_MS) {
|
||||||
|
windowStart = time;
|
||||||
|
startedInWindow = 0;
|
||||||
|
}
|
||||||
|
if (running >= options.maxConcurrent) return { ok: false, reason: "busy" };
|
||||||
|
if (startedInWindow >= options.maxPerMinute) return { ok: false, reason: "minute_cap" };
|
||||||
|
running += 1;
|
||||||
|
startedInWindow += 1;
|
||||||
|
let released = false;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
release: () => {
|
||||||
|
// Ein zweiter Aufruf (z.B. aus finally und catch) darf keinen fremden Platz freigeben.
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
running -= 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LruCache<V> {
|
||||||
|
get(key: string): V | undefined;
|
||||||
|
set(key: string, value: V): void;
|
||||||
|
readonly size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LRU ueber die Einfuegereihenfolge einer Map: ein Treffer wandert ans Ende, verdraengt wird vorn. */
|
||||||
|
export function createLruCache<V>(maxEntries: number): LruCache<V> {
|
||||||
|
const entries = new Map<string, V>();
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
const value = entries.get(key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
entries.delete(key);
|
||||||
|
entries.set(key, value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
entries.delete(key);
|
||||||
|
entries.set(key, value);
|
||||||
|
while (entries.size > maxEntries) {
|
||||||
|
const oldest = entries.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
entries.delete(oldest.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return entries.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON mit sortierten Schluesseln, damit die Reihenfolge im Objekt den Schluessel nicht aendert. */
|
||||||
|
function stableStringify(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.filter((key) => record[key] !== undefined)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel aus der bereits GEPRUEFTEN und normalisierten Eingabe, nicht aus
|
||||||
|
* dem Roh-Body: "Summsi " und "Summsi" ergeben dasselbe Bild. Als SHA-256,
|
||||||
|
* damit keine Widmungstexte als Map-Schluessel im Speicher liegen.
|
||||||
|
*/
|
||||||
|
export function previewCacheKey(input: { personalization: object; locale: string; term: object; templateVersion: string }): string {
|
||||||
|
return createHash("sha256").update(stableStringify(input)).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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 { createLruCache, createRenderGate, previewCacheKey } from "./render-guard";
|
||||||
|
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;
|
||||||
|
// Globale Grenzen ueber alle Besucher (render-guard.ts): ein Rendering kostet
|
||||||
|
// spuerbar CPU und Speicher, drei gleichzeitig traegt der Container sicher.
|
||||||
|
const MAX_CONCURRENT_RENDERS = 3;
|
||||||
|
const MAX_RENDERS_PER_MINUTE = 300;
|
||||||
|
const RETRY_AFTER_SECONDS = 2;
|
||||||
|
const CACHE_ENTRIES = 200;
|
||||||
|
|
||||||
|
const gate = createRenderGate({ maxConcurrent: MAX_CONCURRENT_RENDERS, maxPerMinute: MAX_RENDERS_PER_MINUTE });
|
||||||
|
const imageCache = createLruCache<Buffer>(CACHE_ENTRIES);
|
||||||
|
|
||||||
|
function failure({ status, ...payload }: PreviewFailure, headers: Record<string, string> = {}): NextResponse {
|
||||||
|
return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageResponse(image: Buffer): Response {
|
||||||
|
return new Response(new Uint8Array(image), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/webp",
|
||||||
|
// Personalisierte Bilder gehoeren in keinen Zwischenspeicher (nur in
|
||||||
|
// den eigenen im Prozess, der nie an Dritte ausliefert).
|
||||||
|
"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 });
|
||||||
|
}
|
||||||
|
// Content-Length ist eine Angabe des Absenders, keine Grenze: bei
|
||||||
|
// "chunked" fehlt sie ganz. Deshalb mitzaehlend lesen und abbrechen, sobald
|
||||||
|
// mehr kommt als erlaubt — sonst puffert request.text() beliebig viel.
|
||||||
|
const raw = await readBodyCapped(request, MAX_BODY_BYTES);
|
||||||
|
if (raw === null) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const cacheKey = previewCacheKey({
|
||||||
|
personalization: input.personalization,
|
||||||
|
locale: input.locale,
|
||||||
|
term: input.term,
|
||||||
|
templateVersion: CURRENT_TEMPLATE_VERSION,
|
||||||
|
});
|
||||||
|
const cached = imageCache.get(cacheKey);
|
||||||
|
if (cached) return imageResponse(cached);
|
||||||
|
|
||||||
|
const slot = gate.tryAcquire();
|
||||||
|
if (!slot.ok) {
|
||||||
|
// 503 statt 429: nicht dieser Besucher ist zu schnell, sondern der
|
||||||
|
// Server gerade voll. Das Formular versucht es nach Retry-After erneut.
|
||||||
|
return failure({ status: 503, error: "busy", field: null }, { "Retry-After": String(RETRY_AFTER_SECONDS) });
|
||||||
|
}
|
||||||
|
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" }
|
||||||
|
);
|
||||||
|
imageCache.set(cacheKey, image);
|
||||||
|
return imageResponse(image);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error);
|
||||||
|
return failure({ status: 500, error: "generic", field: null });
|
||||||
|
} finally {
|
||||||
|
slot.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den Body hoechstens bis maxBytes; null, wenn er groesser ist oder nicht lesbar. */
|
||||||
|
async function readBodyCapped(request: Request, maxBytes: number): Promise<string | null> {
|
||||||
|
if (!request.body) return "";
|
||||||
|
const reader = request.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||||
|
}
|
||||||
@@ -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,73 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const getProductsByIds = vi.fn();
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
// Echtes withFreeStock aus lib/data, nur die Datenbankzugriffe sind ersetzt.
|
||||||
|
vi.mock("@/lib/data", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/data")>()),
|
||||||
|
getProductsByIds: (ids: string[]) => getProductsByIds(ids),
|
||||||
|
}));
|
||||||
|
const reservedJarsByProduct = vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, 0])));
|
||||||
|
vi.mock("@/lib/sponsorship-subscription/reservation", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/sponsorship-subscription/reservation")>()),
|
||||||
|
reservedJarsByProduct: (ids: readonly string[]) => reservedJarsByProduct(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("reports the FREE stock: jars reserved for subscriptions are not orderable", async () => {
|
||||||
|
getProductsByIds.mockResolvedValue([
|
||||||
|
{ id: "honey", category: "HONIG", isActive: true, inventory: { quantity: 5 } },
|
||||||
|
{ id: "linde", category: "HONIG", isActive: true, inventory: { quantity: 2 } },
|
||||||
|
]);
|
||||||
|
getOffersByProductIds.mockResolvedValue(new Map());
|
||||||
|
reservedJarsByProduct.mockResolvedValueOnce(new Map([["honey", 3], ["linde", 4]]));
|
||||||
|
|
||||||
|
const body = (await (await get("honey,linde")).json()) as { stock: Record<string, number> };
|
||||||
|
|
||||||
|
expect(body.stock).toEqual({ honey: 2, linde: 0 });
|
||||||
|
expect(reservedJarsByProduct).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { NextResponse } from "next/server";
|
||||||
import { getProductsByIds } from "@/lib/data";
|
import { getProductsByIds, withFreeStock } from "@/lib/data";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
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 MAX_IDS_PER_REQUEST = 50;
|
||||||
const STOCK_ATTEMPTS_PER_WINDOW = 60;
|
const STOCK_ATTEMPTS_PER_WINDOW = 60;
|
||||||
const STOCK_WINDOW_MS = 60 * 1000;
|
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
|
* Aktueller Lagerbestand fuer eine Liste von Produkt-IDs — der Warenkorb
|
||||||
@@ -33,9 +41,22 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ stock: {} });
|
return NextResponse.json({ stock: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await getProductsByIds(ids);
|
// Freier Bestand: fuer Abos reservierte Glaeser sind nicht bestellbar
|
||||||
|
// (der Checkout wuerde sie ablehnen), also auch hier nicht anzeigen.
|
||||||
|
const products = await withFreeStock(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> = {};
|
const stock: Record<string, number> = {};
|
||||||
for (const product of products) {
|
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;
|
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 bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||||
|
|
||||||
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||||
|
key: "p1",
|
||||||
productId: "p1",
|
productId: "p1",
|
||||||
slug: "fruehtracht-2026",
|
slug: "fruehtracht-2026",
|
||||||
name: "Frühtracht 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>) {
|
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
|
||||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
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(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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 dynamic from "next/dynamic";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
import Link from "next/link";
|
||||||
|
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly, cartDiscountBase } 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 CheckoutLoading from "./loading";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
@@ -116,10 +120,17 @@ export function CheckoutForm({
|
|||||||
stripePublishableKey?: string;
|
stripePublishableKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const sponsorshipStrings = getSponsorshipShopStrings(locale);
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||||
const subtotal = cartTotalCents(items);
|
const subtotal = cartTotalCents(items);
|
||||||
const totalQuantity = cartItemCount(items);
|
// Die Versandstaffel zaehlt 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
|
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
|
||||||
// Token-Rueckgabe statt eines versteckten Feldes.
|
// Token-Rueckgabe statt eines versteckten Feldes.
|
||||||
@@ -130,6 +141,9 @@ export function CheckoutForm({
|
|||||||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
const [isGift, setIsGift] = useState(false);
|
||||||
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
|
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
|
||||||
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
|
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
|
||||||
@@ -146,7 +160,8 @@ export function CheckoutForm({
|
|||||||
const [packstationError, setPackstationError] = useState<string | null>(null);
|
const [packstationError, setPackstationError] = useState<string | null>(null);
|
||||||
const [selectedPackstation, setSelectedPackstation] = useState<Packstation | 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() {
|
async function searchPackstations() {
|
||||||
setPackstationSearching(true);
|
setPackstationSearching(true);
|
||||||
@@ -184,10 +199,12 @@ export function CheckoutForm({
|
|||||||
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||||
const shipping =
|
const shipping =
|
||||||
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||||
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
// Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie discountBase im Server).
|
||||||
|
const discountable = cartDiscountBase(items);
|
||||||
|
const activeBulkDiscount = discountable.quantity > 0 ? resolveBulkDiscount(bulkDiscountTiers, discountable.quantity) : null;
|
||||||
const bulkDiscountCents = activeBulkDiscount
|
const bulkDiscountCents = activeBulkDiscount
|
||||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||||
: 0;
|
: 0;
|
||||||
const total = subtotal - bulkDiscountCents + shipping;
|
const total = subtotal - bulkDiscountCents + shipping;
|
||||||
|
|
||||||
@@ -208,6 +225,7 @@ export function CheckoutForm({
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setLoginRequired(false);
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -215,7 +233,7 @@ export function CheckoutForm({
|
|||||||
firstName: String(formData.get("firstName") ?? ""),
|
firstName: String(formData.get("firstName") ?? ""),
|
||||||
lastName: String(formData.get("lastName") ?? ""),
|
lastName: String(formData.get("lastName") ?? ""),
|
||||||
phone: String(formData.get("phone") ?? "") || undefined,
|
phone: String(formData.get("phone") ?? "") || undefined,
|
||||||
fulfillmentMethod: fulfillment,
|
fulfillmentMethod: digitalOnly ? "DIGITAL" : fulfillment,
|
||||||
// Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular,
|
// Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular,
|
||||||
// sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den
|
// sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den
|
||||||
// lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und
|
// lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und
|
||||||
@@ -225,19 +243,31 @@ export function CheckoutForm({
|
|||||||
? selectedPackstation
|
? selectedPackstation
|
||||||
? `Packstation ${selectedPackstation.number}`
|
? `Packstation ${selectedPackstation.number}`
|
||||||
: undefined
|
: undefined
|
||||||
: String(formData.get("street") ?? "") || undefined,
|
: showsAddress
|
||||||
|
? String(formData.get("street") ?? "") || undefined
|
||||||
|
: undefined,
|
||||||
zip: usesPackstation
|
zip: usesPackstation
|
||||||
? selectedPackstation?.zip
|
? selectedPackstation?.zip
|
||||||
: String(formData.get("zip") ?? "") || undefined,
|
: showsAddress
|
||||||
|
? String(formData.get("zip") ?? "") || undefined
|
||||||
|
: undefined,
|
||||||
city: usesPackstation
|
city: usesPackstation
|
||||||
? selectedPackstation?.city
|
? selectedPackstation?.city
|
||||||
: String(formData.get("city") ?? "") || undefined,
|
: showsAddress
|
||||||
|
? String(formData.get("city") ?? "") || undefined
|
||||||
|
: undefined,
|
||||||
country,
|
country,
|
||||||
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
|
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
|
||||||
postNumber: usesPackstation
|
postNumber: usesPackstation
|
||||||
? String(formData.get("postNumber") ?? "").trim() || undefined
|
? String(formData.get("postNumber") ?? "").trim() || undefined
|
||||||
: 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,
|
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
||||||
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
||||||
turnstileToken: turnstileToken ?? undefined,
|
turnstileToken: turnstileToken ?? undefined,
|
||||||
@@ -258,6 +288,7 @@ export function CheckoutForm({
|
|||||||
// aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
|
// aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas,
|
||||||
// das niemand einloesen konnte (siehe api/checkout/route.ts).
|
// das niemand einloesen konnte (siehe api/checkout/route.ts).
|
||||||
setError(data.error ?? t.checkoutPage.genericError);
|
setError(data.error ?? t.checkoutPage.genericError);
|
||||||
|
setLoginRequired(response.status === 401);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -365,6 +396,15 @@ export function CheckoutForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -420,8 +460,9 @@ export function CheckoutForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
|
{!digitalOnly && fulfillment === "VERSAND" && packstationSearchAvailable ? (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
|
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
|
||||||
<button
|
<button
|
||||||
@@ -443,7 +484,7 @@ export function CheckoutForm({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{fulfillment === "VERSAND" && !usesPackstation ? (
|
{showsAddress ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Field
|
<Field
|
||||||
name="street"
|
name="street"
|
||||||
@@ -627,6 +668,14 @@ export function CheckoutForm({
|
|||||||
{error ? (
|
{error ? (
|
||||||
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
|
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
|
||||||
{error}
|
{error}
|
||||||
|
{loginRequired ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<Link href={loginPathWithNext("/checkout")} className="font-medium text-heading underline underline-offset-4">
|
||||||
|
{sponsorshipStrings.checkout.loginLink}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -647,10 +696,11 @@ export function CheckoutForm({
|
|||||||
<h2 className="text-2xl text-heading mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
<h2 className="text-2xl text-heading mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-3 text-sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.productId} className="flex items-baseline justify-between gap-4">
|
<li key={item.key} className="flex items-baseline justify-between gap-4">
|
||||||
<span>
|
<span className="min-w-0">
|
||||||
<span className="text-ink/55 tabular-nums">{item.quantity} ×</span>{" "}
|
<span className="text-ink/55 tabular-nums">{item.quantity} ×</span>{" "}
|
||||||
<span className="font-serif text-lg text-heading">{item.name}</span>
|
<span className="font-serif text-lg text-heading">{item.name}</span>
|
||||||
|
{item.sponsorship ? <SponsorshipLineDetails sponsorship={item.sponsorship} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
<span className="tabular-nums">{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -663,9 +713,11 @@ export function CheckoutForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-ink/70">
|
<div className="flex justify-between text-ink/70">
|
||||||
<span>
|
<span>
|
||||||
{fulfillment === "VERSAND"
|
{digitalOnly
|
||||||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
? sponsorshipStrings.checkout.digitalShipping
|
||||||
: t.checkoutPage.shippingPickup}
|
: fulfillment === "VERSAND"
|
||||||
|
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||||
|
: t.checkoutPage.shippingPickup}
|
||||||
</span>
|
</span>
|
||||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -395,7 +395,10 @@ export default async function ProductDetailPage(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="mt-8 grid gap-x-6 gap-y-2.5 text-sm text-ink/75 sm:grid-cols-2">
|
<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">
|
<li key={item} className="flex items-start gap-2.5">
|
||||||
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
|
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
|
||||||
{item}
|
{item}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||||
import { succeed } from "@/lib/flash";
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
|
||||||
|
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||||
@@ -16,6 +18,10 @@ import { succeed } from "@/lib/flash";
|
|||||||
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
||||||
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
||||||
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
||||||
|
*
|
||||||
|
* Mit laufendem Patenschafts-Abo wird nicht geloescht (erst kuendigen), und
|
||||||
|
* die Urkunden-Dateien der eigenen Patenschaften werden mit entfernt
|
||||||
|
* (lib/sponsorship/customer-deletion.ts).
|
||||||
*/
|
*/
|
||||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
@@ -42,10 +48,20 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await hasBlockingSubscription(customer.id)) {
|
||||||
|
return fail(
|
||||||
|
"/konto/einstellungen",
|
||||||
|
"Bitte kündige zuerst dein Abo (Konto → Patenschaften). Dein Konto kannst du löschen, sobald das Abo beendet ist."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||||
await sendMail({ to: customer.email, subject, html, text });
|
await sendMail({ to: customer.email, subject, html, text });
|
||||||
|
|
||||||
|
const certificateDirs = await certificateDirsOfCustomer(customer.id);
|
||||||
|
await discardPendingCheckouts(customer.id);
|
||||||
await prisma.customer.delete({ where: { id: customer.id } });
|
await prisma.customer.delete({ where: { id: customer.id } });
|
||||||
|
await removeCertificateDirs(certificateDirs);
|
||||||
await destroySession();
|
await destroySession();
|
||||||
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Customer } from "@/generated/prisma/client";
|
|||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
import { isTurnstileValid } from "@/lib/turnstile";
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
import { succeed } from "@/lib/flash";
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { safeNextPath, loginPathWithNext } from "@/lib/safe-next-path";
|
||||||
|
|
||||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
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> {
|
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();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
|
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,
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
if (!(await isTurnstileValid(formData, ip))) {
|
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({
|
const parsed = loginSchema.safeParse({
|
||||||
@@ -52,24 +57,24 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
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
|
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
|
||||||
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
|
// 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)) {
|
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 } });
|
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
|
|
||||||
if (customer?.blockedAt) {
|
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()) {
|
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
||||||
return fail(
|
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."
|
"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 || !passwordValid) {
|
||||||
if (customer) await registerFailedLogin(customer);
|
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) {
|
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);
|
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||||
redirect("/konto");
|
redirect(next ?? "/konto");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
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 { isPasswordBreached } from "@/lib/password-breach-check";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
import { succeed } from "@/lib/flash";
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { safeNextPath, loginPathWithNext } from "@/lib/safe-next-path";
|
||||||
|
|
||||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
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;
|
const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
export async function registerAction(formData: FormData): Promise<void> {
|
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();
|
const ip = await getClientIp();
|
||||||
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
|
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") ?? "");
|
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
||||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
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({
|
const parsed = registerSchema.safeParse({
|
||||||
@@ -34,16 +38,16 @@ export async function registerAction(formData: FormData): Promise<void> {
|
|||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
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")) {
|
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)) {
|
if (await isPasswordBreached(parsed.data.password)) {
|
||||||
return fail(
|
return fail(
|
||||||
"/konto",
|
registerPage,
|
||||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
"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.
|
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
||||||
await hashPassword(parsed.data.password);
|
await hashPassword(parsed.data.password);
|
||||||
return fail(
|
return fail(
|
||||||
"/konto",
|
registerPage,
|
||||||
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
"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);
|
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||||
if (!mailSent) {
|
if (!mailSent) {
|
||||||
return fail(
|
return fail(
|
||||||
"/konto",
|
registerPage,
|
||||||
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
|
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return succeed(
|
return succeed(
|
||||||
"/konto",
|
next ?? "/konto",
|
||||||
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
|
`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 { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { isEntraConfigured } from "@/lib/entra";
|
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 {
|
import {
|
||||||
registerAction,
|
registerAction,
|
||||||
loginAction,
|
loginAction,
|
||||||
@@ -18,9 +21,16 @@ import {
|
|||||||
export const metadata: Metadata = { title: "Mein Konto" };
|
export const metadata: Metadata = { title: "Mein Konto" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function KontoPage() {
|
export default async function KontoPage({ searchParams }: PageProps<"/konto">) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const customer = await getCurrentCustomer();
|
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) {
|
if (!customer) {
|
||||||
return (
|
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="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||||
<div className="md:pr-12">
|
<div className="md:pr-12">
|
||||||
<form action={loginAction} className="space-y-4">
|
<form action={loginAction} className="space-y-4">
|
||||||
|
{nextField}
|
||||||
<h2 className="text-3xl text-heading">{t.account.login}</h2>
|
<h2 className="text-3xl text-heading">{t.account.login}</h2>
|
||||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||||
@@ -76,6 +87,7 @@ export default async function KontoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||||
|
{nextField}
|
||||||
<h2 className="text-3xl text-heading">{t.account.register}</h2>
|
<h2 className="text-3xl text-heading">{t.account.register}</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
<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}
|
title={t.account.navSettings.title}
|
||||||
description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`}
|
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" ? (
|
{customer.role === "ADMIN" ? (
|
||||||
<AccountNavCard
|
<AccountNavCard
|
||||||
href="/admin"
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user