"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]>
/charge/[code] zeigt zwei Bild-Slots (Produkt-/Standortfoto). Die
bisherige Logik ersetzte bei JEDEM eigenen Chargenfoto beide Slots
komplett durch batch.images — wer nur ein Chargenfoto pflegte, verlor
dadurch das zweite (Standort-)Bild der Ernte statt es weiter anzuzeigen.
Jeder Slot faellt jetzt einzeln auf das jeweilige Erntebild zurueck.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
Fehlerbild "Unexpected token '<' ... is not valid JSON" nach dem
ffmpeg-Fix: kein Absturz mehr, sondern vermutlich ein Timeout - das
CDN/Reverse-Proxy (Cloudflare) vor der Seite bricht selbst ab, wenn der
Origin-Request zu lange nicht antwortet, und liefert dafuer eine eigene
HTML-Fehlerseite statt der JSON-Antwort. "medium"-Preset fuer ein
57-MB-Rohvideo konnte laenger brauchen, als das CDN wartet. "veryfast"
tauscht nur Encodier-Geschwindigkeit gegen Kompressionseffizienz (nicht
gegen das per -crf gesteuerte Qualitaetsziel), reduziert die
Encodierzeit deutlich.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live-Log zeigte: spawn .../ffmpeg-static/ffmpeg ENOENT bei jedem
Transcode-Versuch, unabhaengig vom Videoformat (.mov war nur zufaellig
der zuletzt getestete Fall). Die ffmpeg-static-Binary aus der
migrator-Docker-Stage kam nicht zuverlaessig im finalen Image an.
Root-Fix: ffmpeg jetzt per "apk add ffmpeg" als System-Paket in der
runner-Stage installiert - liegt fest unter /usr/bin auf PATH,
unabhaengig von jeder Node/pnpm-Kopiermechanik. lib/video-transcode.ts
versucht zuerst "ffmpeg" ueber PATH und faellt nur bei ENOENT (kein
System-ffmpeg vorhanden, typischerweise lokale Windows-/Mac-Entwicklung)
auf die per npm mitgelieferte ffmpeg-static-Binary zurueck, damit
"npm run dev" weiterhin ohne manuelle Einrichtung funktioniert. Beide
Pfade lokal gegen ein synthetisches Testvideo verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
loop-Attribut war bereits gesetzt, aber manche Browser (u.a. aeltere
iOS-Safari-Versionen) starten ein Video nach "ended" nicht zuverlaessig
neu, wenn autoplay/muted/playsinline kombiniert sind. onEnded erzwingt
jetzt zusaetzlich den Neustart, damit die Dauerschleife garantiert nie
sichtbar stehen bleibt.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Ein direkt von Kamera/Handy hochgeladenes Video (57 MB) lud spuerbar
langsam: keine Kompression, kein Ton entfernt, und ohne "faststart"
musste der Browser oft die gesamte Datei laden, bevor die Wiedergabe
ueberhaupt beginnen konnte.
Neues lib/video-transcode.ts (ffmpeg-static, per pnpm-workspace.yaml
allowBuilds freigegeben) enkodiert jedes hochgeladene Video jetzt zu
einem web-optimierten MP4 neu: Ton entfernt (Hero-Video ist ohnehin
stumm), auf max. 1920px Breite skaliert, CRF 26 mit Bitratendeckel,
"+faststart" fuer sofortigen Wiedergabestart. Nebeneffekt: ein
erfolgreicher Transcode validiert das Video jetzt genauso vollstaendig
wie reencodeImage() das bei Bildern schon tut (nicht mehr nur
Magic-Bytes). ffmpeg-static-Binary wird in der migrator-Docker-Stage
mitinstalliert (Next.js' Standalone-Tracing erkennt den Pfad hinter dem
pnpm-Store-Symlink nicht zuverlaessig), lokal per npm-Postinstall ohne
manuelle Einrichtung.
Getestet mit synthetischem Testvideo: Transcode laeuft durch, Ausgabe
enthaelt keine Audiospur mehr, ist als H.264/MP4 dekodierbar.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live-Check des hochgeladenen Hero-Videos zeigte: die Headline lag streckenweise
direkt ueber hellen Honigglas-Etiketten, der bisherige rein vertikale
Verlauf (bg-gradient-to-t, 70/10) verdunkelt dort zu wenig, wo die
Textspalte tatsaechlich sitzt (vertikal mittig links). Zusaetzliche
gleichmaessige 25%-Abdunklung ueber die gesamte Flaeche plus staerkerer,
diagonaler Verlauf (to-tr, 70/25) gezielt hinter der Textspalte - visuell
gegen das echte Video verifiziert (Screenshot-Vergleich alt/neu).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
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]>
40 MB war fuer ein reales, nicht web-optimiertes Hero-Video (57 MB) zu
knapp. Ingress-Body-Size-Limit entsprechend mitgezogen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
isTrustedOrigin() verglich den Origin-Header ausschliesslich gegen den von
Next.js aus request.url rekonstruierten Host. Hinter dem eigenen
Caddy/nginx-Reverse-Proxy (docker-compose.yml, k8s/) ist dieser Wert nicht
in jeder Konstellation zuverlaessig derselbe wie der echte, oeffentliche
Host im Origin-Header - das fuehrte live zu "Ungueltige Anfrage." bei
jedem Admin-Upload, obwohl der Request vom eigenen Frontend kam. Prueft
jetzt zusaetzlich gegen getBaseUrl() (NEXT_PUBLIC_SITE_URL), genau wie
lib/url.ts es fuer denselben Zweck bereits vormacht - als reine
ODER-Erweiterung, damit ein falsch gesetztes NEXT_PUBLIC_SITE_URL nicht
umgekehrt bislang funktionierende Endpunkte wie /api/checkout blockiert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Uploads (Bilder + Hero-Video) schlugen live fehl, lokal aber nicht: der
Container laeuft als non-root "nextjs", /app/uploads existierte nirgends
vorbereitet und gehoert root -> mkdir() scheiterte mit EACCES, unbehandelt
als HTML-500 statt JSON, der Client zeigte nur einen haengenden
"wird hochgeladen"-Zustand ohne Fehlermeldung.
- Dockerfile legt /app/uploads jetzt vor dem User-Wechsel an und
uebereignet es dem nextjs-Nutzer
- docker-compose.yml persistiert Uploads jetzt ueber ein eigenes Volume
(vorher gingen sie bei jedem Redeploy verloren) und setzt UPLOAD_DIR
explizit
- Beide Upload-Routen fangen Dateisystemfehler jetzt ab und liefern eine
echte JSON-Fehlermeldung statt eine unbehandelte Exception
- k8s-Manifeste vorsorglich mitgefixt (fsGroup fuer den Volume-Mount,
Ingress-Body-Size-Limit) fuer den Fall eines spaeteren
Kubernetes-Wechsels
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
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]>
Same gap as /imkerei and /herkunft (previous commit): last remaining
hardcoded PlaceholderImage in the app, now wired to the same
content-registry "pages" group / ContentImage pattern.
Verified: tsc clean, 151/151 tests pass, page still returns 200.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
Second silent-failure-hunter pass (stock/auth/admin-action focus,
explicitly scoped away from the payment paths fixed in the previous
commit) plus a self-review of that previous commit's own diff.
Self-correction: my prior fix to fulfillOrder's NEU->BEZAHLT guard
replaced the original atomic `updateMany({status:"NEU"})` claim with a
plain findUnique+update to let a retry backfill Payment/Invoice/email
after BEZAHLT. That accidentally dropped the atomicity the original
code relied on to prevent double-counting a discount code on two
concurrent webhook deliveries. Restored it: the NEU->BEZAHLT
transition is an atomic updateMany claim again (count===1 decides
"firstTime", which now also gates the discount-code increment); a
losing/retry call separately checks for "already BEZAHLT" to still
allow the backfill.
Three further real gaps, confirmed by hand before fixing:
- failOrder (same webhook file) and admin's updateOrderStatusAction
had the identical TOCTOU pattern: findUnique then unconditional
update then restockOrderItems. Two concurrent webhook deliveries
(Stripe documents at-least-once delivery) or two admin tabs
double-clicking "Stornieren" could both pass the stale read before
either write landed, silently double-crediting inventory. Both now
use the same atomic-claim pattern as fulfillOrder.
- 2FA backup-code verification used findFirst({usedAt:null}) then a
separate update — two concurrent submissions of the same code could
both pass the check and both authenticate, defeating single-use.
Replaced with one atomic updateMany claim.
- Two silent no-ops: an invalid/stale order-status update returned
with zero admin-visible feedback despite revalidatePath still
firing; discount-code deletion had no existence check, so a
double-submitted delete threw an unhandled Prisma P2025 (500)
instead of a clear message. Both now use the existing fail() helper.
Verified: tsc clean, 151/151 existing tests still pass.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Dispatched a read-only silent-failure audit (silent-failure-hunter
agent) over the checkout/webhook/DHL/mail code paths, then verified
each finding by hand before fixing. Four confirmed, real gaps:
- Stripe webhook (fulfillOrder): Payment/Invoice creation and the
confirmation email ran after the order's status flip to "BEZAHLT",
outside any try/catch. If any of those three failed (DB blip), the
handler threw uncaught -> Stripe retried -> the retry's
status==="NEU" guard now found 0 rows and silently skipped the
retry forever, leaving a paid order with no invoice, no payment
record, and no confirmation email, with nothing logged. Fixed by
allowing "BEZAHLT" as a valid retry entry point (idempotent
upserts) and wrapping the post-transaction block in try/catch that
logs loudly and re-throws (so Stripe still retries).
- DHL label creation: the already-billed tracking number's DB write
sat outside the try/catch that protects the (paid) DHL API call.
A failed write there lost the tracking number with zero record of
it, while the claim sentinel permanently blocked any retry through
the UI. Fixed by wrapping the write, logging the tracking number
for manual reconciliation, and deliberately leaving the claim in
place (not resettable) to avoid triggering a second, separately
billed label.
- Checkout route: a DB write failure for the (currently unread,
write-only) stripeCheckoutSessionId field discarded an already
live, payable Stripe session and showed the customer a generic
error, risking a duplicate order + stock reservation on retry.
Made that specific write best-effort/non-fatal.
- DHL fetch calls had no request timeout, so a hung (non-erroring)
DHL API could block an admin action indefinitely. Added a 20s
AbortSignal.timeout to both.
Verified: tsc clean, 151/151 existing tests still pass. No dedicated
DB-integration test harness exists yet for route/webhook handlers in
this codebase (only pure-function unit tests) — building one is a
separate, larger undertaking than this fix warrants.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live Lighthouse audit of /konto found the same class of bug as the
prior checkout/cart commit, one shade lighter: text-ink/50 measures
3.08:1 against the cream background, under the 4.5:1 WCAG AA minimum
for normal-size text. Grepped for every other use of the same token
(login divider, language switcher, sold-out label, admin image-upload
status text, admin hint text) and bumped them all to text-ink/70
(5.61:1, same value used in the earlier checkout fix) for one
consistent, compliant muted-text shade. Verified: tsc clean, 151/151
tests pass, /konto Lighthouse accessibility 96 -> 100.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live Lighthouse + performance-trace audits against the running dev app
found two real, measurable defects:
- /checkout and /warenkorb rendered the cart-store's default empty
state before Zustand's persist middleware finished reading
localStorage, so any customer who already had items in their cart
saw an "empty cart" flash before the real form appeared (CLS 0.82).
Fixed by tracking hasHydrated in the store and rendering the
existing route skeleton until rehydration completes. Verified via
Chrome DevTools performance trace: CLS 0.82 -> 0.00 on both pages.
- axe-core (via Lighthouse) flagged text-ink/60 on the cream
background at 4.09:1 contrast, under the 4.5:1 WCAG AA minimum for
normal-size text. Bumped to text-ink/70 (5.61:1) everywhere the same
token is used at text-xs/text-sm across 7 files.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- 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.
- 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.
- 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.
Konkrete, verifizierte Luecken auf den Seiten, die Kund:innen von Google
aus oder per Link erreichen:
- Product-JSON-LD auf der Produktdetailseite ergaenzt um "image" (absolute
URLs der hochgeladenen Produktfotos) — fehlte bisher trotz vorhandener
Bilder und ist fuer Google Rich-Results/Merchant-Listing mit Sternebewertung
+ Preis im Suchergebnis erforderlich.
- Alt-Text fuer echte (hochgeladene) Bilder aussagekraeftiger: ContentImage
uebernimmt jetzt das bereits vorhandene, bisher nur auf Platzhalterkacheln
sichtbare "label" (Ernte-Jahr/Typ, Kategorie, Ort) mit in den Alt-Text —
wirkt projektweit auf Produktkarten, Ernte-Uebersicht/-Detail und
Produkt-Teaser. Produktgalerie-Hauptbild zeigt zusaetzlich die
Geschmacksnoten im Alt-Text statt nur des Produktnamens.
- Checkout: allgemeine Fehlermeldung nach fehlgeschlagenem Absenden bekommt
role="alert" und Fokus wird aktiv dorthin verschoben — bisher rein farblich
markiert, Screenreader-Nutzer:innen haetten den Fehler beim geldwerten
Bestellabschluss sonst gar nicht mitbekommen.
- Cart-Drawer und Warenkorb-Seite: Lagerbestand-Hinweise (Ausverkauft/Nur
noch N) treffen asynchron erst nach dem ersten Rendern ein — jetzt in
einem persistenten aria-live-Wrapper, damit der Uebergang tatsaechlich
angesagt wird statt stumm zu bleiben.
Geprueft und bereits solide befunden (keine Aenderung noetig): Shop-Kanonical
ignoriert bewusst Such-/Filter-Query-Parameter (kein Duplicate-Content-Risiko),
Sitemap deckt Produkt- und Ernte-Detailseiten mit echtem lastmod ab,
Produktgalerie-Tastaturbedienung ueber echte <button>-Kacheln mit
aria-label/aria-current, Cart-Drawer-Fokusfalle/Fokus-Rueckgabe, Mengen-Steller-
aria-live, Farbkontrast von text-honey-dark/text-red-700 auf dem Hintergrund
(~4.9:1 bzw. ~6.1:1, beide AA-konform).
tsc/vitest/build bleiben gruen (142 Tests).
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).
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]>
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]>
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]>
app/icon.svg deckt moderne Chrome/Android-Browser-Tabs und den
manifest.ts-Eintrag ab, aber iOS/iPadOS liest fuer "Zum Home-Bildschirm
hinzufuegen" ausschliesslich ein dediziertes Apple-Touch-Icon und
akzeptiert dafuer kein SVG (nur PNG/JPG) — ohne diese Datei waere
stattdessen ein Screenshot der Seite als Icon verwendet worden.
Rendert dieselbe Grafik wie icon.svg per next/og ImageResponse als
180x180-PNG. Per pnpm build + manuellem HTTP-Abruf/Sichtpruefung
verifiziert, dass Satori die SVG-Elemente korrekt rendert.
Social-Media-Icons (TikTok/YouTube/Instagram, site-footer.tsx) per
Code-Review geprueft: einfache, universell unterstuetzte SVG-Primitive
(rect/circle/path), werden nur gerendert wenn eine URL konfiguriert
ist — kein Fix noetig.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
Zwei schnell aufeinanderfolgende Klicks auf "DHL-Versandmarke
erstellen" konnten beide den findUnique-Check bestehen und zwei
echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
wie beim zuvor gefixten Retouren-Erstattungs-Bug). Ein reines
create()-mit-Unique-Constraint waere hier nicht ausreichend gewesen,
da Shipment-Datensaetze bereits ohne DHL existieren koennen
(automatisch bei Statuswechsel auf VERSENDET, oder manuell erfasst).
Fix: Serializable-Transaktion claimt den Datensatz atomar mit einem
Sentinel-Wert, bevor der DHL-Aufruf ueberhaupt startet; bei einem
fehlschlagenden DHL-Aufruf wird der Sentinel wieder entfernt.
Gefunden bei eigener Nachpruefung der neuen Admin-Actions dieser
Session (apicore-sync.ts war bereits sauber idempotent, kein Fix
noetig).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Keine der in dieser Session neu hinzugefuegten sicherheitskritischen
Module (lib/csrf.ts, lib/entra.ts, lib/dhl.ts) hatte bisher Tests.
- csrf.test.ts: Same-Origin-Erkennung, Ablehnung gefaelschter
Cross-Origin-Requests, tolerantes Verhalten bei fehlendem/kaputtem
Origin-Header.
- entra.test.ts: PKCE-Paar-Erzeugung (RFC 7636), Authorize-URL-Aufbau,
Extraktion des verifizierten E-Mail-Claims (nie ein unplausibles
Feld raten).
- dhl.test.ts: Adress-Mapping (Strasse/Hausnummer-Trennung,
ISO2->ISO3-Laendercode), Fehlerbehandlung bei nicht unterstuetztem
Land, DHL-Validierungsfehlern und HTTP-Fehlern, statt einen falschen
Erfolg vorzutaeuschen.
Alle 118 Tests (13 Dateien) gruen, tsc --noEmit clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben
Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt
nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben
maxUses:1-Code konnten sich sonst beide durchsetzen.
- Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update,
ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein
Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen
ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder
zurueckgesetzt, damit erneut versucht werden kann.
Beide Luecken kamen aus dem Security-Assessment dieser Session.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die
Harvest-isPublished-Migration waren gestaged aber nicht im letzten
Commit enthalten — Gitea-Actions-Build brach mit "Module not found"
fuer genau diese Dateien ab.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
gitea.server_url ist in manchen Gitea-Versionen leer (go-gitea/gitea#23276)
und liefert selbst im Erfolgsfall eine volle URL mit https://-Schema statt
des fuer docker login/Image-Tags benoetigten reinen Hostnamens. Registry
jetzt fest als git.encry.de hinterlegt, k8s-Manifeste entsprechend
vorausgefuellt.
Dockerfile (Multi-Stage, "output: standalone"), docker-compose.yml mit
Caddy als einzigem nach aussen offenem Reverse-Proxy (verhindert
X-Forwarded-For-Faelschung fuer das App-eigene Rate-Limiting), Gitea
Actions Workflow der bei jedem Push zwei Image-Tags in die Gitea-
Container-Registry baut (schlankes Laufzeit-Image + ein Migrations-
Image mit Prisma-CLI), sowie rohe Kubernetes-Manifeste (Namespace,
ConfigMap, Secret-Vorlage, Postgres-/MySQL-StatefulSet, Migrations-Job,
App-Deployment, Ingress) fuer den Cluster-Betrieb. Siehe k8s/README.md
fuer den vollstaendigen Einrichtungs-/Deployment-Ablauf.