Commit Graph
52 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 b7a75dddca feat(admin): Formulare fuer Gutscheine, Versand, Benutzer, Ernten und Chargen vereinheitlicht
Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.

Benutzer bearbeiten: Sicherheitsbereich mit Status-Badges, Passwort-Reset
als Sekundaerknopf, Sperren/Loeschen in eigenem abgesetzten Bereich.
AdminField/AdminSelect koennen jetzt min und einen Hinweistext.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:10 +02:00
Nick SzaunigandClaude Opus 5.5 a09057d960 feat(admin): Listen mit Status-Badges, Leerzustaenden und klaren Aktionen
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).

Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:51:50 +02:00
Nick SzaunigandClaude Opus 5.5 c125e91941 feat(admin): Produkt anlegen gegliedert wie Produkt bearbeiten
Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:41:59 +02:00
Nick SzaunigandClaude Opus 5.5 76ebe7d042 feat(admin): Bestelldetail mit Zeitleiste, Produktformular gegliedert
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste
  (dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand
- Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und
  Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld
  isActive), Speicherleiste bleibt beim Scrollen unten erreichbar
- Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger
  Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 19:48:29 +02:00
Nick SzaunigandClaude Opus 5.5 c95c713717 feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
  Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
  offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
  Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
  tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
  "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
  mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
  #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
  2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
  selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
  Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:45:24 +02:00
Nick SzaunigandClaude Opus 5.5 578ec767b2 feat(ui): Checkout mit Schrittanzeige und Lieferart-Karten
- Schrittanzeige "Deine Daten -> Bezahlen" mit Waben (rein anzeigend,
  gesteuert weiter ueber den bestehenden Zahlungszustand)
- Lieferart als Karten mit Symbol, Auswahl mit Waben-Haekchen, aria-pressed
- Bestelluebersicht abgehoben, Namen und Gesamtsumme in Serifenschrift
- Keine Aenderung an Bezahllogik oder Formularfeldern

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:27:35 +02:00
Nick SzaunigandClaude Opus 5.5 ee854bd77b feat(ui): Formular-Karten, Backup-Codes als Kacheln, Gutschein zum Kopieren
- Gemeinsame FormPanel-Karte fuer Passwort- und Zwei-Faktor-Seiten
- Zwei-Faktor-Code: grosses, zentriertes Ziffernfeld
- Backup-Codes als nummerierte Kacheln statt langer Liste
- Newsletter-Bestaetigung: Willkommensrabatt als Gutschein mit
  gestricheltem Rand und Kopier-Knopf (bestaetigt "Kopiert")

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:17:58 +02:00
Nick SzaunigandClaude Opus 5.5 4ee5bafe20 feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:01:18 +02:00
Nick SzaunigandClaude Opus 5.5 839bbff595 feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)
Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:36:36 +02:00
Nick SzaunigandClaude Opus 5.5 fb9c2353c6 feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit
die aktiven Listen nur zeigen, was noch zu tun ist:
- Bestellungen: versendet/abgeholt/storniert, keine offene Retoure,
  seit einem Monat nicht bearbeitet
- Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet
- Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert)

Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle
5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind.
Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand
archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen
landen im Audit-Log.

Neue Felder archivedAt/archivedManually samt Index an orders,
return_requests und products (Migration 20260925010000_archive_fields).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:05:10 +02:00
Nick SzaunigandClaude Opus 5.5 01eaca39dc feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.

- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
  Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
  damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 03:27:58 +02:00
Nick SzaunigandClaude Opus 5 c35d61bfd6 feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Stornieren gab es bereits, Loeschen nicht — abgebrochene Checkouts und
Testbestellungen blieben dauerhaft in der Liste stehen.

Loeschbar ist bewusst nur, was nie ein Geschaeftsvorfall war: keine Zahlung
erfasst, keine Rechnung erzeugt, Status NEU oder STORNIERT. Eine bezahlte
Bestellung und ihre Rechnung sind Buchungsbelege (§ 147 AO, § 257 HGB) und
muessen jahrelang vorgelegt werden koennen; ein Loeschknopf darauf wuerde
nicht aufraeumen, sondern Unterlagen vernichten. Dafuer gibt es den Storno:
er erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.

Die Regel steht in lib/order-delete.ts als reine Logik ohne Datenbankzugriff
— dieselbe Trennung wie bei shipping-calc.ts. Server Action und Admin-Seite
teilen sie sich, statt die Bedingung zweimal zu formulieren und irgendwann
auseinanderzulaufen, und sie laesst sich ohne DATABASE_URL testen (13 neue
Faelle, u.a. dass bei Rechnung UND Zahlung die Rechnung als Grund gemeldet
wird).

Drei Dinge, die ein blosses delete() falsch gemacht haette:

- LAGERBESTAND: bei NEU liegt die Ware reserviert und wird zurueckgebucht,
  bei STORNIERT ist das beim Stornieren bereits geschehen. Dort erneut
  gutzuschreiben wuerde den Bestand erhoehen, ohne dass je ein Glas zurueckkam.
- ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
  Stripe-Sitzung haben. Wird sie geloescht, ohne die Sitzung zu schliessen,
  kann Minuten spaeter noch bezahlt werden — der Webhook findet dann keine
  Bestellung mehr, und das Geld liegt bei Stripe, ohne dass im Shop etwas
  davon zu sehen waere. Die Sitzung wird deshalb VOR dem Loeschen geschlossen;
  gelingt das nicht sicher, wird gar nicht geloescht. Ist bei Stripe
  zwischenzeitlich bezahlt worden, verweigert die Aktion das Loeschen.
- WETTLAUF: die Bedingungen stehen zusaetzlich in der WHERE-Klausel des
  DELETE. Zwischen Pruefung und Loeschen koennte der Webhook eine Zahlung
  eingetragen haben; die Pruefung sieht nur einen Schnappschuss, die
  WHERE-Klausel wird atomar gegen den aktuellen Zeilenstand ausgewertet.

Der Audit-Log-Eintrag ist ausfuehrlicher als bei anderen Aktionen und traegt
Bestellnummer, Status, Betrag, E-Mail und Positionsanzahl — der Datensatz ist
danach weg, dieser Eintrag ist die einzige verbleibende Spur.

Die Oberflaeche zeigt den Knopf am Seitenende in einem abgesetzten Bereich,
damit er nicht neben dem Statusfeld liegt, mit Browser-Rueckfrage ueber
ConfirmForm. Ist die Bestellung nicht loeschbar, steht dort statt des Knopfes
der Grund und der Verweis auf den Storno. Die Bestellliste zeigt jetzt auch
Erfolgsmeldungen, nicht nur Fehler.

tsc: 0 Fehler. vitest: 372/372 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:05:43 +02:00
Nick SzaunigandClaude Opus 5 b9f92b5c0d feat: Rechtstexte nennen alle Empfaenger, denen tatsaechlich Daten zugehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m11s
Die Datenschutzerklaerung beschrieb Hosting, Bestellung, Konto, Newsletter,
Kontaktformular und Google-Rezensionen — drei Empfaenger fehlten, obwohl
Daten an sie gehen. Abgeglichen wurde gegen die Content-Security-Policy in
next.config.ts: sie listet abschliessend, wohin der Browser ueberhaupt
Verbindungen aufbauen darf, und ist damit ein belastbares Inventar.

Neu aufgenommen:

- Cloudflare Turnstile als eigener Abschnitt 8. Das Skript laedt auf jeder
  Seite mit Formular aus dem Browser des Besuchers nach; IP-Adresse und
  Geraetemerkmale gehen dabei an Cloudflare in den USA. Bisher stand davon
  nirgends etwas — weder in der Datenschutzerklaerung noch unter "Externe
  Inhalte" in der Cookie-Richtlinie, wo Stripe bereits korrekt beschrieben
  ist. Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO, Drittlandtransfer ueber
  EU-US Data Privacy Framework.
- DHL in Abschnitt 4: Name und Lieferanschrift, bei Packstationen zusaetzlich
  die Standortnummer, und bei der Packstationssuche im Bestellvorgang der
  eingegebene Ort.
- Microsoft Entra ID in Abschnitt 5. Die Anmeldung damit ist kundenseitig auf
  /konto verlinkt, tauchte aber in keinem Rechtstext auf.

Die bisherigen Abschnitte 8 bis 10 ruecken auf 9 bis 11.

Ausserdem zwei Korrekturen am Bestand:

- Der Verweis auf die EU-Plattform zur Online-Streitbeilegung entfaellt in
  Impressum UND AGB. Die ODR-Verordnung ist aufgehoben, die Plattform
  eingestellt; ein Link auf einen abgeschalteten Dienst ist schlechter als
  keiner. Die Erklaerung nach § 36 Abs. 1 Nr. 1 VSBG bleibt und ist jetzt
  ausdruecklich als solche benannt.
- Die Cookie-Richtlinie zitierte das TTDSG. Das Gesetz heisst seit Mai 2024
  TDDDG.

Unveraendert bleibt der Cookie-Banner: er bietet eine Auswahl, die derzeit
nichts schaltet, weil keine einwilligungspflichtigen Cookies gesetzt werden —
das ist Absicht, damit spaeter hinzukommende Cookies den Mechanismus schon
vorfinden.

Alle Texte auch auf Englisch. Kein Ersatz fuer eine anwaltliche Pruefung.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:29:18 +02:00
Nick SzaunigandClaude Opus 5 5870c60c50 feat: Ruecksendekosten sind geregelt und ausgewiesen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
Bisher sagte kein Text des Shops, wer die Kosten einer Ruecksendung traegt.
Nach § 357 Abs. 6 BGB traegt sie dann der VERKAEUFER: die Belehrung des
Kaeufers ist die Voraussetzung dafuer, sie ihm auferlegen zu duerfen. Ohne
sie war jede Retoure auf eigene Rechnung.

Die Regelung steht bewusst an zwei Stellen, nicht an einer:

- Widerrufsbelehrung: nur der amtliche Mustersatz "Du traegst die
  unmittelbaren Kosten der Ruecksendung der Waren." (Anlage 1 zu Art. 246a § 1
  Abs. 2 EGBGB), unveraendert uebernommen. Eine Abweichung vom Muster kostet
  dessen Gesetzlichkeitsfiktion — die Belehrung waere dann angreifbar.

- Versand und Zahlung, neuer Abschnitt "Rücksendung": derselbe Grundsatz in
  Alltagssprache plus die Ausnahme fuer mangelhafte Ware. Geht ein Mangel auf
  die Produktion oder auf uns als Verkaeufer zurueck, tragen wir die Kosten
  vollstaendig.

Die Ausnahme gehoert NICHT in die Widerrufsbelehrung: sie folgt aus der
Gewaehrleistung (§ 439 Abs. 2 BGB) und gilt unabhaengig davon, ob widerrufen
wird. Beides dort zu vermischen wuerde zwei verschiedene Rechte als eines
darstellen.

Im Product-Schema kommt damit returnFees hinzu, das bisher aus genau diesem
Grund fehlte — ReturnFeesCustomerResponsibility, der Wert fuer "Kaeufer traegt
die Ruecksendung". Er braucht keinen Betrag, was hier auch nicht ginge: die
Ruecksendung laeuft ueber den Kaeufer, nicht ueber unser Versandkonto. Der
guenstigere Sonderfall (Mangel) bleibt im Schema unabgebildet; schema.org
kennt dafuer kein Feld, und ihn generell zuzusagen waere falsch.

Beide Texte auch auf Englisch.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:51:59 +02:00
Nick SzaunigandClaude Opus 5 7f9ed7262f refactor: Liefergebiet bekommt einen eigenen Abschnitt "Versand"
Die Einstellung lag unter "Firmendaten" — zwischen Firmenname, Slogan und
Anschrift. Dort sucht sie niemand: eine Versandregel ist keine
Stammdatenangabe. Genau das ist beim ersten Suchen auch passiert.

Jetzt ein eigener Abschnitt "Versand" (englisch "Shipping") unter
Admin → Inhalte → Texte, direkt hinter den Firmendaten. Der
Feldtyp-Vertrag der Registry bekommt dafuer die Gruppe "shipping".

Rein organisatorisch — Schluessel, Wert und Verhalten bleiben gleich, ein
bereits gepflegter Wert wandert einfach mit.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 02:16:06 +02:00
Nick SzaunigandClaude Opus 5 1bdf4df04a feat: Gebiete mit Zollpflicht vom Versand ausgenommen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m7s
Vorgabe war: nur dorthin liefern, wo keine Zollabwicklung noetig ist. Die
EU-Einstellung erfuellt das auf Laenderebene — die Union ist eine
Zollunion, eine Sendung von Deutschland nach Frankreich braucht keine
Zollanmeldung. Der Laendercode allein genuegt aber nicht.

Einige Gebiete gehoeren zu einem EU-Mitgliedstaat, liegen aber ausserhalb
des Zoll- und/oder Steuergebiets der Union. Dorthin ist jede Sendung eine
Ausfuhr mit Zollinhaltserklaerung, Handelsrechnung und moeglichen
Einfuhrabgaben beim Empfaenger. Zwei davon liegen in DEUTSCHLAND:

  Helgoland (27498) und Büsingen am Hochrhein (78266)

Sie waren also selbst bei der Einstellung "Nur Deutschland" betroffen.
Dazu kommen Kanarische Inseln, Ceuta, Melilla, die franzoesischen
Ueberseegebiete, Livigno, Campione d'Italia, Åland und der Berg Athos.

Erkennbar sind sie nur an der Postleitzahl. findCustomsExcludedArea
prueft sie je Land, denn dieselbe Ziffernfolge bedeutet anderswo etwas
anderes: "22" ist in Finnland Åland, in Italien die Provinz Como. Die
Checkout-Route weist solche Anschriften mit einer Meldung ab, die das
Gebiet benennt und auf die Abholung verweist.

Azoren und Madeira sind bewusst NICHT ausgenommen: sie gehoeren voll zum
Zoll- und Steuergebiet, dorthin gilt der normale Binnenversand. Ein Test
haelt das fest, damit sie nicht versehentlich mitgesperrt werden.

Die Hinweistexte auf "Versand und Zahlung" nennen die Ausnahmen jetzt
ausdruecklich — in beiden Einstellungen und beiden Sprachen.

8 neue Tests in shipping-region.test.ts, 1 in der Checkout-Route
("weist Helgoland ab, obwohl es in Deutschland liegt").

tsc: 0 Fehler. vitest: 359/359 gruen (zuvor 350). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:03:10 +02:00
Nick SzaunigandClaude Opus 5 6d7a77c8dd feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:

1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
   Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
   Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
   Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
   einzigen Eintrag waere eine Attrappe.

2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
   ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
   laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
   Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
   Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
   schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
   geht (Adresse im Gebiet oder Abholung).

3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
   passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
   dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.

Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.

"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.

Technisch dazugekommen:
  - lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
    shipping-calc.ts), 11 Tests.
  - content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
    ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
    wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
    saehe, dass die Umstellung nicht gegriffen hat.
  - Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
    Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
  - 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
    Drittland trotzdem abgewiesen).

tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:59:15 +02:00
Nick SzaunigandClaude Opus 5 cbc81caa1a fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s
Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.

Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:

    const cacheKey = id || src;
    // Script has already loaded
    if (cacheKey && LoadCache.has(cacheKey)) {
      return;
    }

Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:

  - die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
    zwei Widgets — eines davon war immer tot;
  - jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
    statt ueber F5.

Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".

Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.

Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:

  - "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
    Containern sucht;
  - turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
  - ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
    laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
    Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
    abschicken.

Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.

Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.

tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 05:46:14 +02:00
Nick SzaunigandClaude Opus 5 1fa8dc9ce2 fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 16:57:18 +02:00
Nick SzaunigandClaude Opus 5 a3f91b83fc fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.

Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.

Drei Schichten dagegen, jede für sich wirksam:

1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
   lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
   gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
   30 Minuten" abgewiesen werden.

2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
   eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
   steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
   also die offene Zahlung einer fremden Person abbrechen. Der Token wird
   nach Gebrauch aus der Adresszeile entfernt.

   {CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
   success_url und return_url dokumentiert, nicht für cancel_url — darauf
   baut hier nichts.

3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
   genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
   ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
   Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
   blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
   aus wie jede andere offene.

Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.

Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.

Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.

Weiter in diesem Zug:

- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
  Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
  Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
  bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
  Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
  versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.

- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
  hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
  dann meldet die Prüfung "ungeprüft" statt "in Ordnung".

- Packstation-Bestellungen waren vollständig kaputt: die Muster für
  Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
  Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
  Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
  Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
  Richtige sagte. Drei Tests halten das jetzt fest.

- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
  Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
  einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
  auf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:07:22 +02:00
Nick SzaunigandClaude Opus 5 92ac826baa feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.

syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.

Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.

Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.

Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:26:09 +02:00
Nick SzaunigandClaude Opus 5 8f0f06fd13 feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht
Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht.
Neue Server Action deleteProductAction samt Knopf in der Produktliste,
im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog,
Audit-Log-Eintrag, Pruefung vor dem Loeschen.

Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit
onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen
und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen
Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits
gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht
(§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht",
sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt
aus dem Shop und der Beleg bleibt.

Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder
bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste
Datei ist harmloser als ein totes Bild an anderer Stelle.

Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der
Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas
darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft"
bzw. "in Verwendung").

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:10:09 +02:00
Nick SzaunigandClaude Opus 5 625b73a8c8 feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz
haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein
Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
Bienenwachskerze nicht.

Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt:

1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer
   Ausnahmen, die sich nicht aus der Kategorie ergeben.
2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS.
3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert.

Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen
Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt.
Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0
bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen
eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem
leeren Feld eine 0 gemacht.

Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im
selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und
Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der
Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt
das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig
auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent
verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen
exakt den Rechnungsbetrag ergibt.

Der Satz wandert von der Rechnung an die BESTELLPOSITION
(OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis:
wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen
unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz
je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte
entstand in der vorigen Migration und wurde nie in Betrieb beschrieben.

VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der
sicherere ist — als Lebensmittel waeren 7 richtig, als
Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen
vermerkt.

Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand"
gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die
Saetze ohne globalen Zustand testen lassen, und greift auf die
Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine
Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:54:26 +02:00
Nick SzaunigandClaude Opus 5 7c59283ede feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.

DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.

Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.

Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.

Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.

Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.

Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:32:12 +02:00
Nick SzaunigandClaude Opus 5 c525735285 feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.

In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.

Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.

Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.

Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.

Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:24:02 +02:00
Nick SzaunigandClaude Opus 5 78c19f1b73 feat: Produkte aus ApiCore synchronisieren
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.

ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.

Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.

Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.

Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 18:03:05 +02:00
Nick SzaunigandClaude Opus 5 c61ae92390 feat: nicht dauerhaftes Upload-Verzeichnis wird erkannt, bevor Daten verloren gehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Ergaenzt den Abgleich aus c8ddcec um die Pruefung, die frueher anschlaegt.
checkUploadFiles() erkennt fehlende Dateien — also erst, NACHDEM sie weg
sind. checkUploadVolume() erkennt die Ursache schon vorher: liegt
UPLOAD_DIR im Container statt auf einem gemounteten Volume, sind alle
Bilder und Videos beim naechsten Redeploy verloren, auch wenn gerade noch
jede Datei da ist.

Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat
eine andere als sein uebergeordnetes Verzeichnis. Bewusst eine Heuristik
und damit nur ein Hinweis, keine Blockade — ein Bind-Mount innerhalb
desselben Dateisystems kann dieselbe Kennung tragen. Ausserhalb von
NODE_ENV=production immer "gemountet", weil UPLOAD_DIR lokal als
"./uploads" im Projektverzeichnis liegt und dort sonst ein Daueralarm
entstuende.

Die Funktion liegt in lib/uploads.ts statt in lib/upload-health.ts:
Letzteres importiert Prisma und laesst sich ohne DATABASE_URL nicht
testen — dieselbe Ueberlegung wie bei lib/form-fields.ts in ff365c3. Als
reine Funktion neben UPLOAD_DIR ist sie direkt pruefbar, und dort gehoert
sie fachlich ohnehin hin.

Vier Tests: kein Alarm ausserhalb der Produktion, ein gewoehnliches
Verzeichnis wird in Produktion als nicht dauerhaft erkannt, ein fehlendes
Verzeichnis erzeugt keinen Fehlalarm, und der gepruefte Pfad kommt mit
zurueck, damit die Warnung ihn benennen kann.

Das Admin-Dashboard zeigt beide Befunde getrennt: die Vorwarnung als
Hinweis, fehlende Dateien als Fehler.

Nicht enthalten und auch nicht moeglich: das Anwenden der Manifeste im
Cluster. Auf dieser Maschine gibt es weder kubectl noch eine kubeconfig;
die beiden kubectl-Befehle aus k8s/README.md muss jemand mit
Cluster-Zugriff einmalig ausfuehren.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:09:31 +02:00
Nick SzaunigandClaude Opus 5 c8ddcec04c feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.

Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.

Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.

lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.

Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.

Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:06:33 +02:00
Nick SzaunigandClaude Opus 5 bdce9b6a0a feat: Impressum mit den echten Inhaberdaten ausgefuellt
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen,
Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden
Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug
"[Name wie oben]" und nennt jetzt ebenfalls den Namen.

Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war
eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer
vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich
selbst widersprochen.

Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die
Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine
frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin
aenderbar, ohne dass dafuer ein Deploy noetig waere.

Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig —
der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche
Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt.

Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten
werden dir vor Abschluss der Bestellung transparent angezeigt." Das war
der Ersatztext (tierFallback), der nur erschien, solange keine
Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht
leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ
und Verwendung entfernt, nicht nur geleert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:02:45 +02:00
Nick SzaunigandClaude Opus 5 9dd71ffb9a feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.

Aufbau:

- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
  (GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
  anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
  Header, kein OAuth und kein Geschaeftskundenvertrag. Die
  Packstation-Auswahl funktioniert damit auch, solange die
  Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
  DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
  (15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
  dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
  ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
  20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
  ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
  Packstation nur bei Versand, nicht bei Abholung.

Zwei Entscheidungen, die Folgearbeit ersparen:

- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
  Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
  Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
  Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
  das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
  Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
  Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
  auf eine Anschrift, die es nicht gibt.

Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.

Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.

Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 00:37:00 +02:00
Nick SzaunigandClaude Opus 5 ff365c3cb1 feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und
Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in
der allein die Reihenfolge entschied, welches Bild wohin kommt.

Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom
Standort pflegen wollte, bekam es auf Position 0 — also in die linke
Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt,
war im Formular ausserdem nirgends erkennbar.

Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im
Ernten-Formular:

  Bild 1 — linke Kachel auf der Chargenseite
  Bild 2 — rechte Kachel auf der Chargenseite (Standort)

Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen
eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und
werden in dieser Reihenfolge uebermittelt.

Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt
und filtert leere Werte heraus, wodurch ein leerer erster Slot
verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler,
der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als ""
stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt
weiterhin [] statt ["", ""]).

Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im
Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit
"??" haette dieser leere String als gueltiger Wert gegolten und das
Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]).

strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden
aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben
unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen
Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als
reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt
pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts.

Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt
offen), mit Gegenprobe, dass strArray denselben Fall weiterhin
zusammenrutschen laesst.

Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die
Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 21:10:12 +02:00
Nick Szaunig 96bb3d9c50 fix: Video-Upload als roher Body statt multipart — beseitigt die 502-Ursache
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist
ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy
vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten
im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher
verfolgte Timeout-Spur war damit falsch.

Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die
eigentliche Ursache: request.formData() liest den GESAMTEN Body in den
Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige
Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt
(arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis
100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das
Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess
(OOMKilled), und der Proxy meldet 502.

Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data.
Damit laesst sich request.body direkt auf die Platte durchreichen, und der
Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld
wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die
Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil
Content-Length nur eine Ankuendigung des Clients ist.

Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9
Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB —
also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief.

Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast
immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in
die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert —
502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes
Zeitlimit.
2026-09-15 02:27:11 +02:00
Nick Szaunig b11edd7751 fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser

  Unexpected token '<', "<!DOCTYPE "... is not valid JSON

und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.

Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.

readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.

Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
2026-09-14 00:36:10 +02:00
Nick SzaunigandClaude Sonnet 5 a0f61d328c fix: Uebersetzungs-Inkonsistenzen vereinheitlichen (spring harvest, intense)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m37s
"Spring flow" -> "spring harvest", passend zum Rest der englischen Texte.
"robust" -> "intense" fuer kraeftig in beiden Admin-Produktformularen,
passend zum bereits kundenseitig verwendeten product.tasteIntense.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 00:34:57 +02:00
Nick SzaunigandClaude Sonnet 5 88a3e367e6 fix: letzten AI-Slop-Rest in Honigwissen-Artikel entfernen
"Here's why it happens" war ein Throat-Clearing-Opener, den die
deutsche Originalfassung ("Wir erklaeren, woran das liegt") gar nicht
hatte. Jetzt woertlich angeglichen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:14:02 +02:00
Nick SzaunigandClaude Sonnet 5 7d5c172487 feat: eigene Bilder pro Charge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m21s
Chargen hatten bisher kein eigenes Bild-Feld (nur Ernten und Produkte) —
neue Migration ergaenzt Batch.images (Json-Array, gleiches Muster wie
Harvest.images/Product.images). Admin kann Chargenfotos jetzt unter
/admin/chargen/[id] hochladen (AdminImageListField, identisch zum
Ernten-Formular). Die oeffentliche Chargen-Seite (/charge/[code]) zeigt
diese Fotos jetzt bevorzugt, faellt ohne eigene Chargenbilder weiterhin
auf die Erntebilder zurueck.

Hinweis fuer den naechsten Live-Deploy: die generierte Migration hat
nebenbei eine bereits laenger bestehende Schema-Abweichung mitkorrigiert
(harvests.images und products.images/tasteTags werden jetzt als NOT NULL
erzwungen, wie im Schema schon laenger deklariert) - "prisma migrate
deploy" schlaegt fehl, falls dort noch echte NULL-Werte stehen sollten.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 04:08:39 +02:00
Nick SzaunigandClaude Sonnet 5 a3b11cc8e2 feat: Ernten und Chargen im Admin loeschbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m27s
Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger
Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur
loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge
nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung
verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus
Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den
Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit
Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:45:23 +02:00
Nick SzaunigandClaude Sonnet 5 c7286041a1 fix: echten Button statt rohem Datei-Input bei allen Admin-Uploads
Bild-/Video-Upload-Felder (admin/inhalte, admin/produkte, admin/ernten)
zeigten den unstyled Browser-Standard-Datei-Input — kaum als
Bedienelement erkennbar. Input jetzt versteckt, stattdessen ein
richtiger, zum Admin-Design passender Button, der den Datei-Dialog
oeffnet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:30:32 +02:00
Nick SzaunigandClaude Sonnet 5 1df62f20c8 fix: Klickbarkeit der Wissensdatenbank-Karten sichtbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Die Artikel-Karten auf /wissen waren nur per Hover als klickbar erkennbar
(Unterstreichung der Überschrift) — ohne sichtbaren Hinweis im Ruhezustand
war unklar, dass man draufklicken kann. Zeigt jetzt dauerhaft ein
"Weiterlesen →" je Karte.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 22:48:26 +02:00
Nick SzaunigandClaude Sonnet 5 286387d5ec refactor: /admin/inhalte in Bilder/Text trennen, KI-untypische Formulierungen in Website-Texten
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und
Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular),
statt beides pro Bereich gemischt untereinander. Ausserdem alle
Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den
Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen
ersetzt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 15:48:03 +02:00
Nick SzaunigandClaude Sonnet 5 6814e9e2c8 feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 14:53:14 +02:00
Nick SzaunigandClaude Sonnet 5 8d988515f3 feat: make /imkerei and /herkunft hero images admin-editable
Both pages are the ones most directly meant to give customers "ein
gutes Bild von der Imkerei" (per the active /goal), but their hero
images were still hardcoded PlaceholderImage tiles with no way to
ever swap in a real photo — unlike every homepage section, which
already went through this exact upload flow.

Added imkerei.image / herkunft.image to the content registry (new
"pages" group, since neither belongs under "Homepage") and switched
both pages from PlaceholderImage to ContentImage + getContentValue,
the same pattern already proven on the homepage sections. Falls back
to the identical placeholder tile until a photo is uploaded, so this
is a pure addition with no visual change until the owner uploads one.

Verified: tsc clean, 151/151 tests pass, both pages still render
correctly (curl 200 + screenshot) with the placeholder fallback.
Could not verify the admin upload UI end-to-end live — the local
admin account has 2FA enabled and I don't have the second factor;
confidence instead comes from reusing the identical, already-verified
AdminImageField/ContentImage wiring used for the four home.*.image
fields, plus generic (group-agnostic) admin field loading confirmed
by reading admin/inhalte/page.tsx.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:32:00 +02:00
Nick Szaunig 6143e5704d fix: Zahlungsmethoden-Text vereinheitlichen, Retry-Button in Zahlungsfehler-Mail
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier
  verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types,
  kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung
  aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig
  (die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden
  Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt.
- versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten
  Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README
  aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt
  gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen.
- Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei
  Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig.
  Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf,
  hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop.

Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich.
2026-09-06 03:16:34 +02:00
Nick Szaunig a579ebabeb feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass
  beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann
  (Feature existierte bereits, war aber nur im Checkout-Formular selbst
  sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen)
- Produktdetailseite (Honig): kontextuelle Links zu passenden
  Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht
  wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt
  einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion
- Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche
  Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete"
  findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte
  eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der
  Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner
  lib/search-match.ts mit eigenen Tests ausgelagert
- Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/
  Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss
  (bereits vorhanden auf Produktseite/Warenkorb/Checkout)

Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien,
+9 neue Tests fuer search-match), pnpm build erfolgreich.
2026-09-06 03:07:24 +02:00
Nick Szaunig de0f6ee249 fix: Preis-/Datumsformat an Kunden-Locale anpassen, Produktgalerie-LCP verbessern
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen
  jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte
  Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt
  hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz
  gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026".
  Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell-
  verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege,
  Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch.
- en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)"
  jetzt korrekt (vorher z.B. "Open cart, 1 items").
- product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image
  statt <img> (hochgeladene Produktfotos sind immer same-origin unter
  /uploads/..., brauchen also keine remotePatterns-Konfiguration wie der
  entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem
  sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist.
- content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer
  das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat).
- Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits
  korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung
  ohne Intervall) -- keine Aenderung noetig.

Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich.
2026-09-06 02:57:57 +02:00
Nick Szaunig 0b2196460a feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)
Konkrete Reibungspunkte entlang der Kauf-Journey behoben:
- Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur
  in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit
  lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig
  zurueckzusetzen; plus "Suche zuruecksetzen"-Link.
- Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite
  (bisher erst im Warenkorb/Checkout sichtbar).
- Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die
  Vorschau-Kacheln (mobile Kaufentscheidung).
- Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der
  Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht
  wieder erst erscrollt werden muss.
- Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und
  auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer
  Endpunkt /api/shop/cart-stock + useCartStock-Hook.
- Checkout- und Adressformulare: autoComplete/inputMode/type auf allen
  Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und
  passende Bildschirmtastaturen.
- Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung
  verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die
  Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren
  auffindbar.

Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build
bleiben gruen (142 Tests).
2026-09-05 18:02:38 +02:00
Nick SzaunigandClaude Sonnet 5 54919d82ed feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).

- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
  tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
  Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
  (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
  vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
  Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
  einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
  Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
  (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
  zur Laufzeit hinzugefuegten Dateien bedienen) — mit
  Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
  Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
  Product.images/Harvest.images (beide Felder existierten im Schema,
  hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
  (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
  Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
  (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
  Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
  gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.

Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 19:59:42 +02:00
Nick SzaunigandClaude Sonnet 5 3de9f2906e fix: Platzhalter auf Rechtstexten entfernen (Review-Hinweis, DPO, Hosting)
Auf Wunsch entfernt: der bislang auf JEDER Rechtstextseite sichtbare
"[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]"-Hinweis
(rechtliche Prüfung ist laut Auskunft bereits erfolgt). LegalPage
zeigt den Hinweis jetzt nur noch an, wenn noticeText nicht leer ist.

Datenschutzbeauftragte/r-Platzhalter durch eine begründete
Standard-Aussage ersetzt (§ 38 BDSG greift bei einem Betrieb dieser
Größe typischerweise nicht). Hosting-Platzhalter durch eine
zutreffende Aussage ersetzt: die Seite laeuft auf selbst betriebener
Infrastruktur, nicht bei einem externen Anbieter — kein AV-Vertrag
noetig.

NICHT ersetzt (siehe Chat): Name/Rechtsform/Handelsregister/USt-IdNr
im Impressum — diese Angaben kenne ich nicht und darf sie nicht
erfinden, eine falsche Handelsregisternummer waere ein echtes
rechtliches Risiko. Dafuer wurden die echten Werte beim Nutzer
angefragt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 14:49:13 +02:00
Nick SzaunigandClaude Sonnet 5 d52753cfef feat: ApiCore-Sync auf Chargen erweitern
Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich
Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im
ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains
"Charge" (model Batch) entspricht ApiCores "BottlingBatch"
(Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT
"HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code,
date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount).

- lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt
  GET /bottling-batches (inkl. verknuepfter HoneyBatch) und
  GET /extractions (inkl. verknuepfter Ernten) und loest daraus die
  zugehoerige ApiCore-Ernten-ID pro Charge auf.
- admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem
  Ernten-Sync (damit frisch angelegte Ernten schon als harvestId
  verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als
  Harvest hat Batch kein isPublished-Feld, ein Update wuerde
  insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter
  Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt
  nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten
  erhalten.
- admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an.
- .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert
  (extractions:read, bottling:read).

Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.
Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe
bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape
direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 14:37:11 +02:00
Nick SzaunigandClaude Sonnet 5 04cc3061cf perf: DB-Indizes, Footer als Server Component, Site-weite Fehlerseiten
Datenbank (prisma/migrations/20260904120000_add_performance_indexes):
7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt
(Order.customerId+createdAt, Order.status, Order.createdAt,
Order.discountCodeId, OrderItem.orderId, Address.customerId,
AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht,
Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log.

Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile)
aus dem client-seitigen site-chrome.tsx in eine eigene Server Component
(site-footer.tsx) ausgelagert — brauchte "use client" nur wegen
useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite.
Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf
(Footer steht im Root-Layout).

UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen
fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen
und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete
Standardseiten statt einer zur Seite passenden Nutzerfuehrung.
Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits
bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx,
seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet,
die neue site-weite Fehlerseite nutzt einen eigenen Schluessel
(siteErrorPage).

Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 09:07:37 +02:00