diff --git a/.env.example b/.env.example index 63c8f1a..d61191c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx # Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx +# Optional: Kundenportal-Konfiguration (bpc_...) fuer "Zahlungsart aendern" im +# Abo, angelegt von `pnpm tsx scripts/stripe-setup.ts --apply`. Leer = die App +# findet sie ueber ihre Metadaten; fehlt sie ganz, bietet das Konto "Zahlungsart +# aendern" nicht an (die Stripe-Standardkonfiguration wird nie benutzt). +STRIPE_PORTAL_CONFIGURATION_ID= # Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth # geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die @@ -243,6 +248,18 @@ UPLOAD_DIR=./uploads # k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml. INVOICE_DIR=./invoices +# Ablage der Urkunden-PDFs der Bienenpatenschaft (lib/sponsorship/storage.ts). +# Aus demselben Grund wie INVOICE_DIR NIE unterhalb von UPLOAD_DIR — eine +# Urkunde traegt Namen und eine persoenliche Widmung. Die Anwendung startet +# nicht, wenn das Verzeichnis im Upload-Verzeichnis liegt. In Kubernetes ein +# Unterordner des gesicherten Rechnungs-Volumes (siehe k8s/01-configmap.yaml). +CERTIFICATE_DIR=./certificates + +# Hintergrundaufgaben im App-Prozess (src/instrumentation.ts): Urkunden +# nachholen, Abo-Lieferungen anlegen, Verzug pruefen. "off" schaltet sie ab, +# z.B. fuer eine zweite Instanz, die nur Seiten ausliefern soll. +BACKGROUND_JOBS=on + # Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung. # 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG) # 19 = voller Satz @@ -270,6 +287,8 @@ VAT_RATE_PERCENT_KERZEN=19 VAT_RATE_PERCENT_PROPOLIS=19 # Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher) VAT_RATE_PERCENT_SONSTIGES=19 +# Bienenpatenschaft (Leistung, Regelsatz; Honiganteil eigene Position). +VAT_RATE_PERCENT_PATENSCHAFT=19 # Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf # (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8. diff --git a/.gitignore b/.gitignore index 870e390..0fb4db9 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ next-env.d.ts # Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository. /invoices + +# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR). +/certificates diff --git a/assets/fonts/CormorantGaramond-Italic.ttf b/assets/fonts/CormorantGaramond-Italic.ttf new file mode 100644 index 0000000..e7dc782 Binary files /dev/null and b/assets/fonts/CormorantGaramond-Italic.ttf differ diff --git a/assets/fonts/CormorantGaramond-Medium.ttf b/assets/fonts/CormorantGaramond-Medium.ttf new file mode 100644 index 0000000..dfb728e Binary files /dev/null and b/assets/fonts/CormorantGaramond-Medium.ttf differ diff --git a/assets/fonts/CormorantGaramond-MediumItalic.ttf b/assets/fonts/CormorantGaramond-MediumItalic.ttf new file mode 100644 index 0000000..cc5bbe4 Binary files /dev/null and b/assets/fonts/CormorantGaramond-MediumItalic.ttf differ diff --git a/assets/fonts/CormorantGaramond-Regular.ttf b/assets/fonts/CormorantGaramond-Regular.ttf new file mode 100644 index 0000000..1cf2389 Binary files /dev/null and b/assets/fonts/CormorantGaramond-Regular.ttf differ diff --git a/assets/fonts/CormorantGaramond-SemiBold.ttf b/assets/fonts/CormorantGaramond-SemiBold.ttf new file mode 100644 index 0000000..d31097e Binary files /dev/null and b/assets/fonts/CormorantGaramond-SemiBold.ttf differ diff --git a/assets/fonts/CormorantGaramond-SemiBoldItalic.ttf b/assets/fonts/CormorantGaramond-SemiBoldItalic.ttf new file mode 100644 index 0000000..d3816e0 Binary files /dev/null and b/assets/fonts/CormorantGaramond-SemiBoldItalic.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/LICENSE-Cormorant.txt b/assets/fonts/LICENSE-Cormorant.txt new file mode 100644 index 0000000..507d70f --- /dev/null +++ b/assets/fonts/LICENSE-Cormorant.txt @@ -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. diff --git a/assets/fonts/LICENSE-Inter.txt b/assets/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/LICENSE-Inter.txt @@ -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. diff --git a/docs/EINRICHTUNG.md b/docs/EINRICHTUNG.md index 775f0a6..d7120fa 100644 --- a/docs/EINRICHTUNG.md +++ b/docs/EINRICHTUNG.md @@ -260,6 +260,37 @@ Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOO **Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren. +### 5.4 Abo der Bienenpatenschaft + +Das Abo läuft über Stripe Billing (Checkout im Modus „subscription“). Produkte und Monatspreise der Abo-Angebote legt Wabenhain selbst an, sobald ein Abo-Angebot im Admin gespeichert oder zum ersten Mal abgeschlossen wird — **nicht** von Hand im Dashboard anlegen. + +**Einrichtungsskript** (wiederholbar, schreibt ohne `--apply` nichts): + +```bash +# Probelauf: zeigt, was fehlt +pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com +# Schreiben (Testmodus) +pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply +# Livekonto: nur nach Freigabe des Inhabers, zusätzlich --live-ok +pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply --live-ok +``` + +Das Skript liest `STRIPE_SECRET_KEY` aus der Umgebung (`.env`) und + +1. ergänzt den Webhook-Endpunkt `/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 diff --git a/docs/superpowers/plans/2026-10-16-bienenpatenschaft.md b/docs/superpowers/plans/2026-10-16-bienenpatenschaft.md new file mode 100644 index 0000000..86ab4f4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-bienenpatenschaft.md @@ -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 `@`. +- 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, `" } }, + NOW, + "de" + ); + expect(result.ok && result.personalization.dedication).toBe(""); + }); +}); diff --git a/src/app/api/patenschaft/vorschau/validate.ts b/src/app/api/patenschaft/vorschau/validate.ts new file mode 100644 index 0000000..3059ab2 --- /dev/null +++ b/src/app/api/patenschaft/vorschau/validate.ts @@ -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 }; +} diff --git a/src/app/api/shop/cart-stock/route.test.ts b/src/app/api/shop/cart-stock/route.test.ts new file mode 100644 index 0000000..9372bf0 --- /dev/null +++ b/src/app/api/shop/cart-stock/route.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +const getProductsByIds = vi.fn(); +vi.mock("@/lib/data", () => ({ getProductsByIds: (ids: string[]) => getProductsByIds(ids) })); + +const getOffersByProductIds = vi.fn(); +vi.mock("@/lib/sponsorship/offers", () => ({ + getOffersByProductIds: (ids: string[], locale: string) => getOffersByProductIds(ids, locale), +})); + +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: () => true, + getClientIp: async () => "127.0.0.1", +})); + +const { GET } = await import("./route"); + +function get(ids: string): Promise { + 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 }; + + expect(body.stock.honey).toBe(3); + expect(body.stock.pkg).toBeGreaterThan(5); + expect(getOffersByProductIds).toHaveBeenCalledWith(["pkg"], "de"); + }); + + test("marks a sponsorship as sold out when its offer is not available", async () => { + getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]); + getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]])); + + const body = (await (await get("pkg")).json()) as { stock: Record }; + + expect(body.stock.pkg).toBe(0); + }); +}); diff --git a/src/app/api/shop/cart-stock/route.ts b/src/app/api/shop/cart-stock/route.ts index d7ec011..1d24b6c 100644 --- a/src/app/api/shop/cart-stock/route.ts +++ b/src/app/api/shop/cart-stock/route.ts @@ -1,10 +1,18 @@ import { NextResponse } from "next/server"; import { getProductsByIds } from "@/lib/data"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { getOffersByProductIds } from "@/lib/sponsorship/offers"; +import { defaultLocale } from "@/i18n/config"; const MAX_IDS_PER_REQUEST = 50; const STOCK_ATTEMPTS_PER_WINDOW = 60; const STOCK_WINDOW_MS = 60 * 1000; +/** + * Bestand, den eine bestellbare Patenschaft meldet: eine Urkunde ist nie + * ausverkauft, und der Wert liegt bewusst ueber der "Nur noch N"-Schwelle + * der Warenkorbanzeige. + */ +const SPONSORSHIP_AVAILABLE_STOCK = 99; /** * Aktueller Lagerbestand fuer eine Liste von Produkt-IDs — der Warenkorb @@ -34,8 +42,19 @@ export async function GET(request: Request) { } const products = await getProductsByIds(ids); + // Patenschaften haben am Traegerprodukt keinen eigenen Bestand. Ob sie + // bestellbar sind, entscheidet das Angebot (bei Paketen der freie + // Honigbestand abzueglich Abo-Reservierung, siehe lib/sponsorship/offers.ts) — + // ohne diese Weiche stuende jede Patenschaft im Warenkorb als "Ausverkauft". + const sponsorshipIds = products.filter((product) => product.category === "PATENSCHAFT").map((product) => product.id); + const offers = await getOffersByProductIds(sponsorshipIds, defaultLocale); const stock: Record = {}; 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; } diff --git a/src/app/checkout/checkout-form.test.tsx b/src/app/checkout/checkout-form.test.tsx index c2a6ed5..ab9d927 100644 --- a/src/app/checkout/checkout-form.test.tsx +++ b/src/app/checkout/checkout-form.test.tsx @@ -39,6 +39,7 @@ const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCen const bulkDiscountTiers: BulkDiscountTierLike[] = []; const item = (overrides: Partial = {}): CartItem => ({ + key: "p1", productId: "p1", slug: "fruehtracht-2026", name: "Frühtracht 2026", @@ -62,6 +63,24 @@ function renderCheckout(items: CartItem[], stripePublishableKey: string | undefi ); } +const sponsorshipItem = (key: string, beeName: string, honeyJars = 0): CartItem => ({ + key, + productId: "pkg-bienenfreund", + slug: "bienenfreund", + name: "Bienenfreund", + weightGrams: 0, + priceCents: 1900, + image: null, + quantity: 1, + sponsorship: { + offerSlug: "bienenfreund", + offerName: "Bienenfreund", + isPhysical: honeyJars > 0, + honeyJars, + personalization: { beeName, recipientName: "Erika Muster", occasion: "GEBURTSTAG", startDate: "2026-11-01" }, + }, +}); + async function fillAndSubmit(userEventInstance: ReturnType) { await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika"); await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann"); @@ -179,4 +198,58 @@ describe("CheckoutForm", () => { expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument(); expect(fetchMock).not.toHaveBeenCalled(); }); + + test("a cart with only digital sponsorships needs no delivery and is sent as DIGITAL with personalization", async () => { + const userEventInstance = userEvent.setup(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }), + } as Response); + + renderCheckout([sponsorshipItem("sp_1", "Frieda"), sponsorshipItem("sp_2", "Frieda")]); + + expect(screen.queryByRole("button", { name: /abholung/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/straße|strasse/i)).not.toBeInTheDocument(); + expect(screen.getAllByText("Biene „Frieda“")).toHaveLength(2); + + await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika"); + await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann"); + await userEventInstance.type(screen.getByLabelText(/e-mail/i), "erika@example.org"); + 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), "erika@example.org"); + 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"); + }); }); diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx index e54e46f..f0b79ce 100644 --- a/src/app/checkout/checkout-form.tsx +++ b/src/app/checkout/checkout-form.tsx @@ -4,7 +4,11 @@ import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from import dynamic from "next/dynamic"; import { Container, SectionHeading, Button } from "@/components/ui"; import { TrustBadges } from "@/components/trust-badges"; -import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; +import Link from "next/link"; +import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly } from "@/store/cart-store"; +import { SponsorshipLineDetails } from "@/components/sponsorship-cart-line"; +import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop"; +import { loginPathWithNext } from "@/lib/safe-next-path"; import CheckoutLoading from "./loading"; import { formatPrice } from "@/lib/format"; import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc"; @@ -116,10 +120,17 @@ export function CheckoutForm({ stripePublishableKey?: string; }) { const { t, locale } = useI18n(); + const sponsorshipStrings = getSponsorshipShopStrings(locale); const items = useCartStore((s) => s.items); const hasHydrated = useCartStore((s) => s.hasHydrated); const subtotal = cartTotalCents(items); - const totalQuantity = cartItemCount(items); + // Versandstaffel und Mengenrabatt zaehlen nur Glaeser — eine Urkunde ohne + // Paket wiegt nichts (wie physicalQuantity im Checkout-Server). + const totalQuantity = cartPhysicalQuantity(items); + // Nur Patenschaften ohne Honig: nichts zu versenden oder abzuholen. Dann + // entfaellt die Lieferart-Wahl samt Anschrift, und die Bestellung geht als + // DIGITAL an den Server (der das aus dem Warenkorb selbst noch einmal prueft). + const digitalOnly = cartIsDigitalOnly(items); // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit // Token-Rueckgabe statt eines versteckten Feldes. @@ -130,6 +141,9 @@ export function CheckoutForm({ const [fulfillment, setFulfillment] = useState("VERSAND"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // 401 vom Server: eine Patenschaft im Warenkorb braucht ein Konto. Dann + // steht neben der Meldung ein Weg zur Anmeldung, der hierher zurueckfuehrt. + const [loginRequired, setLoginRequired] = useState(false); const [isGift, setIsGift] = useState(false); // Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der // Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt, @@ -146,7 +160,8 @@ export function CheckoutForm({ const [packstationError, setPackstationError] = useState(null); const [selectedPackstation, setSelectedPackstation] = useState(null); - const usesPackstation = fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION"; + const usesPackstation = !digitalOnly && fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION"; + const showsAddress = !digitalOnly && fulfillment === "VERSAND" && !usesPackstation; async function searchPackstations() { setPackstationSearching(true); @@ -184,7 +199,7 @@ export function CheckoutForm({ // Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf // die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt. const shipping = - fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity); + digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity); const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity); const bulkDiscountCents = activeBulkDiscount ? Math.round((subtotal * activeBulkDiscount.percent) / 100) @@ -208,6 +223,7 @@ export function CheckoutForm({ setSubmitting(true); setError(null); + setLoginRequired(false); const formData = new FormData(event.currentTarget); const payload = { @@ -215,7 +231,7 @@ export function CheckoutForm({ firstName: String(formData.get("firstName") ?? ""), lastName: String(formData.get("lastName") ?? ""), phone: String(formData.get("phone") ?? "") || undefined, - fulfillmentMethod: fulfillment, + fulfillmentMethod: digitalOnly ? "DIGITAL" : fulfillment, // Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular, // sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den // lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und @@ -225,19 +241,31 @@ export function CheckoutForm({ ? selectedPackstation ? `Packstation ${selectedPackstation.number}` : undefined - : String(formData.get("street") ?? "") || undefined, + : showsAddress + ? String(formData.get("street") ?? "") || undefined + : undefined, zip: usesPackstation ? selectedPackstation?.zip - : String(formData.get("zip") ?? "") || undefined, + : showsAddress + ? String(formData.get("zip") ?? "") || undefined + : undefined, city: usesPackstation ? selectedPackstation?.city - : String(formData.get("city") ?? "") || undefined, + : showsAddress + ? String(formData.get("city") ?? "") || undefined + : undefined, country, packstationNumber: usesPackstation ? selectedPackstation?.number : undefined, postNumber: usesPackstation ? String(formData.get("postNumber") ?? "").trim() || undefined : undefined, - items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })), + // Patenschaften tragen ihre Personalisierung mit; zwei gleiche Pakete + // bleiben zwei Positionen mit derselben productId. + items: items.map((item) => + item.sponsorship + ? { productId: item.productId, quantity: 1, personalization: item.sponsorship.personalization } + : { productId: item.productId, quantity: item.quantity } + ), discountCode: String(formData.get("discountCode") ?? "").trim() || undefined, giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined, turnstileToken: turnstileToken ?? undefined, @@ -258,6 +286,7 @@ export function CheckoutForm({ // aber auf keinem Weg bezahlbar, und die Seite versprach damit etwas, // das niemand einloesen konnte (siehe api/checkout/route.ts). setError(data.error ?? t.checkoutPage.genericError); + setLoginRequired(response.status === 401); setSubmitting(false); return; } @@ -365,6 +394,15 @@ export function CheckoutForm({ /> + {digitalOnly ? ( +

+ + {sponsorshipStrings.checkout.digitalNote} +

+ ) : (

{t.checkoutPage.fulfillmentLabel}

@@ -420,8 +458,9 @@ export function CheckoutForm({

) : null}
+ )} - {fulfillment === "VERSAND" && packstationSearchAvailable ? ( + {!digitalOnly && fulfillment === "VERSAND" && packstationSearchAvailable ? (
{(["ADRESSE", "PACKSTATION"] as const).map((option) => ( + {open ? ( + + ) : null} +
+ ); +} diff --git a/src/app/konto/patenschaft/[id]/_components/detail-blocks.tsx b/src/app/konto/patenschaft/[id]/_components/detail-blocks.tsx new file mode 100644 index 0000000..e4c3e27 --- /dev/null +++ b/src/app/konto/patenschaft/[id]/_components/detail-blocks.tsx @@ -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 ( +
+

+ {title} +

+ {children} +
+ ); +} + +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 ( +
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ); +} + +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: }, + { key: "png", href: file("medaille.png"), label: d.png, hint: d.pngHint, icon: }, + { key: "svg", href: file("medaille.svg"), label: d.svg, hint: d.svgHint, icon: }, + ]; + + return ( + <> +

{t.detail.downloadsIntro}

+ + + + + + {d.all} + {d.allHint} + + + + + + + + ); +} diff --git a/src/app/konto/patenschaft/[id]/_components/open-transfer-notice.tsx b/src/app/konto/patenschaft/[id]/_components/open-transfer-notice.tsx new file mode 100644 index 0000000..cefc405 --- /dev/null +++ b/src/app/konto/patenschaft/[id]/_components/open-transfer-notice.tsx @@ -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 ( +
+ + + +
+

+ {t.transfer.sectionHeading} +

+

+ {t.transfer.sectionIntro}{" "} + {t.transfer.openLinkExpires(formatDateTime(transfer.expiresAt, locale))} +

+

{t.transfer.openLinkHidden}

+
+ +
+ ); +} diff --git a/src/app/konto/patenschaft/[id]/_components/revoke-transfer-button.tsx b/src/app/konto/patenschaft/[id]/_components/revoke-transfer-button.tsx new file mode 100644 index 0000000..c75e8cb --- /dev/null +++ b/src/app/konto/patenschaft/[id]/_components/revoke-transfer-button.tsx @@ -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(null); + const [isPending, startTransition] = useTransition(); + + return ( +
+ +

+ {message} +

+
+ ); +} diff --git a/src/app/konto/patenschaft/[id]/_components/sponsorship-actions.tsx b/src/app/konto/patenschaft/[id]/_components/sponsorship-actions.tsx new file mode 100644 index 0000000..f864cf5 --- /dev/null +++ b/src/app/konto/patenschaft/[id]/_components/sponsorship-actions.tsx @@ -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(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 ( + <> + + {canTransfer ? ( + + ) : null} + + ); +} diff --git a/src/app/konto/patenschaft/[id]/_components/transfer-dialog.tsx b/src/app/konto/patenschaft/[id]/_components/transfer-dialog.tsx new file mode 100644 index 0000000..9886375 --- /dev/null +++ b/src/app/konto/patenschaft/[id]/_components/transfer-dialog.tsx @@ -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; + 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 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(null); + const router = useRouter(); + const [step, setStep] = useState({ kind: "choose" }); + const [validity, setValidity] = useState("7d"); + const [error, setError] = useState(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 ( + 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" + > +
+ + +

+ {title} +

+ + {step.kind === "choose" ? ( + { + event.preventDefault(); + setStep({ kind: "confirm" }); + }} + > +

{labels.dialogIntro}

+
+ {labels.validityLegend} +
+ {TRANSFER_VALIDITIES.map((option) => ( + + ))} +
+
+ + {labels.cancel} + {labels.next} + + + ) : null} + + {step.kind === "confirm" ? ( +
+

{labels.confirmText}

+

+ {labels.validityLegend} {labels.validity[validity]} +

+ {error ? ( +

+ {error} +

+ ) : null} + + setStep({ kind: "choose" })} disabled={isPending}> + {labels.back} + + + {isPending ? labels.creating : labels.confirmButton} + + +
+ ) : null} + + {step.kind === "done" ? ( +
+

{labels.doneText.replace("{date}", formatExpiry(step.expiresAt))}

+ +
+ 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" + /> + +
+

+ + {labels.onceHint} +

+ + {labels.close} + +
+ ) : null} +
+
+ ); +}); + +function DialogButtons({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +type ButtonProps = React.ComponentPropsWithoutRef<"button">; + +function PrimaryButton({ className = "", type = "button", ...props }: ButtonProps) { + return ( + + + {t.confirm.back} + + + + +
+ ); +} diff --git a/src/app/konto/patenschaft/abo/[id]/page.tsx b/src/app/konto/patenschaft/abo/[id]/page.tsx new file mode 100644 index 0000000..7101a34 --- /dev/null +++ b/src/app/konto/patenschaft/abo/[id]/page.tsx @@ -0,0 +1,133 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { Button, Container, LinkButton, SectionHeading } from "@/components/ui"; +import { BackLink } from "@/components/back-link"; +import { getCurrentCustomer } from "@/lib/auth"; +import { formatDate, formatPrice } from "@/lib/format"; +import { canCancel, getOwnedSubscription, type OwnedSubscriptionView } from "@/lib/sponsorship-subscription/account"; +import { getLocale } from "@/i18n/locale"; +import { getAboStrings, type AboStrings } from "@/i18n/sponsorship/abo"; +import type { Locale } from "@/i18n/config"; +import { AboSection, DeliveryProgress, FactList, Notice } from "./_components/abo-blocks"; +import { openPaymentPortalAction } from "./actions"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ id: string }>; + searchParams: Promise>; +}; + +export async function generateMetadata(): Promise { + return { title: getAboStrings(await getLocale()).detail.metaTitle }; +} + +function facts(view: OwnedSubscriptionView, locale: Locale, t: AboStrings): { label: string; value: string }[] { + const rows = [ + { label: t.detail.contractNumber, value: view.contractReference }, + { label: t.detail.statusLabel, value: t.status[view.status] }, + { label: t.detail.monthlyPrice, value: formatPrice(view.monthlyCents, "EUR", locale) }, + { label: t.detail.paidMonths, value: String(view.paidMonths) }, + ]; + const end = view.cancelAt ?? view.endedAt; + if (end) rows.splice(2, 0, { label: t.detail.endsOn, value: formatDate(end, locale) }); + else if (view.currentPeriodEnd && view.status !== "BEENDET") { + rows.splice(2, 0, { label: t.detail.nextCharge, value: formatDate(view.currentPeriodEnd, locale) }); + } + return rows; +} + +function Deliveries({ view, locale, t }: { view: OwnedSubscriptionView; locale: Locale; t: AboStrings }) { + if (view.deliveries.length === 0) return

{t.detail.deliveriesEmpty}

; + return ( +
    + {view.deliveries.map((delivery) => ( +
  • + {delivery.status === "ANGELEGT" + ? t.detail.deliveryPrepared(formatDate(delivery.dueAt, locale), delivery.jars) + : t.detail.deliveryDeferred(formatDate(delivery.dueAt, locale))} +
  • + ))} +
+ ); +} + +function StatusNotice({ view, locale, t, query }: { view: OwnedSubscriptionView; locale: Locale; t: AboStrings; query: Record }) { + if (query.fehler === "kuendigung") return {t.detail.cancelFailed}; + if (query.fehler === "zahlungsart") return {t.detail.paymentUnavailable}; + if (query.gekuendigt === "1" && view.cancelAt) return {t.detail.cancelDone(formatDate(view.cancelAt, locale))}; + if (view.status === "IM_VERZUG") return {t.detail.delinquentNote}; + return null; +} + +export default async function AboDetailPage({ params, searchParams }: Props) { + const { id } = await params; + const customer = await getCurrentCustomer(); + if (!customer) redirect(`/konto?next=${encodeURIComponent(`/konto/patenschaft/abo/${id}`)}`); + const view = await getOwnedSubscription(customer.id, id); + if (!view) notFound(); + const locale = await getLocale(); + const t = getAboStrings(locale); + const query = await searchParams; + const progressLabel = + view.progress && (view.cycle === "ERNTE" ? t.detail.progressHarvest : t.detail.progress)(view.progress.paid, view.progress.required); + const running = view.status !== "BEENDET" && view.status !== "AUSSTEHEND"; + + return ( +
+ + + + + + {view.sponsorshipId ? ( + + {t.detail.certificateLink} + + ) : null} + + {view.progress && progressLabel && running ? ( + + + {view.heldPayments > 0 ?

{t.detail.sepaHoldNote}

: null} +
+ ) : null} + + {view.progress ? ( + + + + ) : null} + + {running && view.stripeCustomerId ? ( + +

{t.detail.paymentText}

+
+ + +
+
+ ) : null} + + + {canCancel(view) ? ( + <> +

{t.detail.cancelText}

+ {/* § 312k Abs. 2 BGB: eindeutig beschrifteter Knopf, der direkt zur Bestaetigungsseite fuehrt. */} + + {t.detail.cancelButton} + + + ) : view.status === "BEENDET" && view.endedAt ? ( +

{t.detail.endedNote(formatDate(view.endedAt, locale))}

+ ) : view.cancelAt ? ( +

{t.detail.cancelledNote(formatDate(view.cancelAt, locale))}

+ ) : null} +
+
+
+ ); +} diff --git a/src/app/konto/patenschaft/medaille/[datei]/route.ts b/src/app/konto/patenschaft/medaille/[datei]/route.ts new file mode 100644 index 0000000..98bde5d --- /dev/null +++ b/src/app/konto/patenschaft/medaille/[datei]/route.ts @@ -0,0 +1,52 @@ +import { renderMedalSvg } from "@/lib/medals"; +import { getCurrentCustomer } from "@/lib/auth"; +import { getCustomerLoyalty } from "@/lib/loyalty"; +import { getLocale } from "@/i18n/locale"; +import { allowRender } from "../../_lib/owner"; +import { notFoundResponse, privateFileResponse, tooManyRequestsResponse } from "../../_lib/http"; +import { + cachedMedalPng, + loyaltyMedalInput, + parseMedalSize, + renderAndCacheMedalPng, + type LoyaltyMedalKind, +} from "../../_lib/medal"; + +// Feste Liste statt freier Dateinamen: /konto/patenschaft/medaille/stammkunde.png usw. +const FILES: Record = { + "stammkunde.png": { kind: "stammkunde", format: "png" }, + "stammkunde.svg": { kind: "stammkunde", format: "svg" }, + "abo.png": { kind: "abo", format: "png" }, + "abo.svg": { kind: "abo", format: "svg" }, +}; + +/** + * Treue-Medaillen des angemeldeten Kontos (Stammkunden-Stufe, Abo-Stufe oder + * VIP). Die Stufe wird bei jedem Aufruf aus den Daten berechnet; ohne + * erreichte Stufe gibt es keine Medaille (404). + */ +export async function GET(request: Request, { params }: { params: Promise<{ datei: string }> }) { + const { datei } = await params; + const file = Object.hasOwn(FILES, datei) ? FILES[datei] : undefined; + const customer = await getCurrentCustomer(); + if (!file || !customer) return notFoundResponse(); + + const loyalty = await getCustomerLoyalty(customer.id, await getLocale()); + const input = loyaltyMedalInput(loyalty, file.kind); + if (!input) return notFoundResponse(); + const stem = file.kind === "stammkunde" ? "Medaille-Stammkunde" : input.kind === "VIP" ? "Medaille-VIP" : "Medaille-Abo"; + + if (file.format === "svg") { + return privateFileResponse(renderMedalSvg(input), { + contentType: "image/svg+xml; charset=utf-8", + downloadName: `${stem}.svg`, + }); + } + const size = parseMedalSize(new URL(request.url).searchParams.get("groesse")); + let png = cachedMedalPng(input, size); + if (!png) { + if (!allowRender(customer.id)) return tooManyRequestsResponse(); + png = await renderAndCacheMedalPng(input, size); + } + return privateFileResponse(png, { contentType: "image/png", downloadName: size === 2048 ? `${stem}.png` : undefined }); +} diff --git a/src/app/konto/patenschaft/page.tsx b/src/app/konto/patenschaft/page.tsx new file mode 100644 index 0000000..024ef74 --- /dev/null +++ b/src/app/konto/patenschaft/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Container, SectionHeading, LinkButton } from "@/components/ui"; +import { BackLink } from "@/components/back-link"; +import { AccountEmptyState } from "@/components/account-ui"; +import { getCurrentCustomer } from "@/lib/auth"; +import { listOwnedSponsorships } from "@/lib/sponsorship/access"; +import { hasActiveOffers } from "@/lib/sponsorship/offers"; +import { getCustomerLoyalty } from "@/lib/loyalty"; +import { getLocale } from "@/i18n/locale"; +import { getAccountSponsorshipStrings } from "@/i18n/sponsorship/account"; +import { MedalShelf } from "./_components/medal-shelf"; +import { SponsorshipRow } from "./_components/sponsorship-row"; +import { PendingRefresher } from "./_components/pending-refresher"; +import { BeeSealIcon } from "./_components/icons"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + return { title: getAccountSponsorshipStrings(await getLocale()).overview.metaTitle }; +} + +export default async function KontoPatenschaftPage() { + const locale = await getLocale(); + const t = getAccountSponsorshipStrings(locale); + const customer = await getCurrentCustomer(); + if (!customer) redirect("/konto"); + + const [sponsorships, loyalty] = await Promise.all([ + listOwnedSponsorships(customer.id, locale), + getCustomerLoyalty(customer.id, locale), + ]); + const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null); + + return ( +
+ + + + + {sponsorships.length === 0 ? ( + } + title={t.overview.emptyTitle} + description={t.overview.emptyDescription} + action={(await hasActiveOffers()) ? {t.overview.emptyAction} : undefined} + /> + ) : null} + + {sponsorships.length > 0 ? ( +
+

+ {t.overview.listTitle} +

+ {hasPending ? ( +

+

+ ) : null} +
    + {sponsorships.map((sponsorship, index) => ( + + ))} +
+
+ ) : null} + + {sponsorships.length > 0 || loyalty.regular.level || loyalty.subscription?.level ? ( + + ) : null} + + {hasPending ? : null} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c775546..1e6b795 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { I18nProvider } from "@/i18n/i18n-provider"; import { getBrandAssets } from "@/lib/brand"; +import { hasActiveOffers } from "@/lib/sponsorship/offers"; import { cookies } from "next/headers"; import { THEME_COOKIE, parseThemeChoice } from "@/lib/theme"; @@ -53,11 +54,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { // Gewaehlte Darstellung schon serverseitig setzen — sonst blitzte die Seite // beim Laden hell auf, bevor ein Skript auf dunkel umschalten koennte. const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value); - const [company, socialLinks, flash, brand] = await Promise.all([ + const [company, socialLinks, flash, brand, showSponsorship] = await Promise.all([ getCompanyInfo(), getSocialLinks(), readFlash(), getBrandAssets(), + // Navigationslink zur Bienenpatenschaft nur bei aktivem Angebot. Ein + // Datenbankfehler hier soll nicht jede Seite reissen — dann eben ohne Link. + hasActiveOffers().catch(() => false), ]); const base = getSiteUrl(); @@ -148,13 +152,19 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { logoSrc={brand.logo} logoInvertedSrc={brand.logoInverted} themeChoice={themeChoice} + showSponsorship={showSponsorship} />
{children}
-