Commit Graph
100 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 15a457f66e feat(ernten): show only the remaining jars per batch
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m6s
The batch list on a harvest page no longer states the batch's total
size ("12 von 300 Gläsern verfügbar"). It now reads "Es sind noch 12
Gläser verfügbar", with dedicated wording for a single jar and for a
sold-out batch, which reads scarcer and nudges customers to buy.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:23:20 +02:00
Nick SzaunigandClaude Opus 5.5 55ae867f68 fix(cart): keep the cart icon unchanged while it glows
The landing glow was a larger, filled basket with a thicker stroke and
a blurred halo, so the icon looked bigger during the effect. The glow
is now an exact copy of the icon (same size, same stroke width, no
fill, no scaling) that only changes colour to honey for a moment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:57:52 +02:00
Nick SzaunigandClaude Opus 5.5 e19827d6af feat(cart): honey glow and drip when the drop lands in the cart
After the flying honey drop reaches the header cart icon, the basket
now lights up twice in honey colour with a warm halo, and a small
honey drop gathers at its lower edge, stretches and runs down. With
reduced motion only a single calm glow remains, without the drip.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:45:54 +02:00
Nick SzaunigandClaude Opus 5.5 3107a02588 feat(konto): settings page for security and privacy
Adds /konto/einstellungen, which gathers two-factor authentication,
"sign out on all devices", the Erntepost consent, the data export
(Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place.
The account overview now links there through a "Settings" card
instead of listing these blocks inline. Server actions are unchanged
apart from redirecting failures and revalidating the new page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 21:20:37 +02:00
Nick SzaunigandClaude Opus 5.5 3bc0693e4d refactor(apicore-sync): API key and shop feed only, no password login
The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:05:11 +02:00
Nick SzaunigandClaude Opus 5.5 55be1fe881 feat(apicore-sync): authenticate with an ApiCore API key
APICORE_API_KEY replaces the service-account login: the key is sent as
the bearer token directly, needs no password and no MFA exemption, may
only read the shop feed and can be revoked in ApiCore at any time. A
malformed key is rejected with a clear message before any request.
Email/password stays as fallback for ApiCore versions without keys.

The system status treats the key or the legacy login as configured.
README, .env.example and the k8s secret template describe the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 01:24:26 +02:00
Nick SzaunigandClaude Opus 5.5 5642f0c4af feat(apicore-sync): read from ApiCore's dedicated shop feed
The sync now fetches everything from ApiCore's GET /shop-sync in one call:
an allowlisted feed (harvest facts, article master data, bottlings) that
never contains internal notes and needs only the single permission
"shop-sync:read" instead of read access to harvests, extractions etc.

- Contract version is checked; an unknown version stops the run with a
  clear message and writes nothing. Extra fields are ignored.
- One consistent snapshot, processed only after it arrived completely: a
  failed fetch writes nothing (no more half state of new harvests and old
  batches); the batch-load-error path is gone.
- Falls back to the previous per-resource endpoints when ApiCore answers
  404, so both packages can be deployed independently.
- .env.example documents the reduced permission.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:30:33 +02:00
Nick SzaunigandClaude Opus 5.5 d38f4303ff feat(apicore-sync): field-level ownership between ApiCore and the shop
ApiCore stays the source of truth for beekeeping facts, Wabenhain for
everything sales-related; both remain separate packages talking only over
ApiCore's HTTP API.

- Batches: lot number, fill date, best-before date and filled jar count
  now follow ApiCore on every run (before: created once, corrections never
  arrived). remainingGlasses - the shop's own stock - is never written; if
  it exceeds a corrected fill quantity, the run reports it instead of
  silently adjusting.
- Batches are matched by the new Batch.apicoreBottlingId instead of the lot
  number, so correcting a lot number in ApiCore no longer creates a second
  batch. Existing synced batches are adopted once via their lot number.
- Harvests: date and year always follow ApiCore, also when published or
  edited; type, location and text stay as maintained in the admin.
- Products stay entirely shop-owned after creation (weight drives the
  legally required unit price, so it is never changed automatically).
- Admin panel: batch update count and stock-conflict notice; intro text
  states which system owns which fields.

Migration is additive (nullable unique column).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:14:35 +02:00
Nick SzaunigandClaude Opus 5.5 45ca0ea745 test(session): tamper inside the signature, not its last character
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
The last base64url character of a 32-byte HMAC signature partly carries
padding bits that decoders ignore, so the tampered token was sometimes
still valid and the test failed at random. Changing a character in the
middle of the signature always alters the signature bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 0482fd490e fix(apicore-sync): keep internal notes out of the shop and protect edited drafts
- Harvest descriptions no longer include ApiCore's internal harvest notes.
  The description becomes public on "Veröffentlichen", so a draft published
  without a careful read could have carried operational notes into the shop.
- A repeated sync overwrote unpublished harvest drafts, undoing the admin's
  corrections (season instead of honey type, location, sales text). Drafts
  with an admin audit entry are now left alone and reported as protected.
- Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted
  the connection but never answered left the admin action hanging.
- The sync panel shows the time of the last complete run.
- Client tests: timeout, failed fetch is never treated as an empty source,
  malformed responses are rejected as a whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 e0b47faf45 feat(orders): reopen cancelled orders only after confirmation; accurate sales
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Cancelled orders:
- Moving an order out of STORNIERT is possible again, but only after a
  warning is confirmed in the status form (OrderStatusForm); without
  the confirmation the server changes nothing.
- Reopening books the counter-entry to the cancellation: goods that the
  cancellation restocked are deducted again (atomically, only if enough
  stock is left, otherwise the order stays cancelled). A later second
  cancellation therefore restocks +1 again instead of leaving +2.
- The warning states that no new payment is collected.

Dashboard figures:
- One definition of a counted sale (lib/sales.ts): not unpaid (NEU),
  not cancelled, no refunded return. Used for revenue today/month, sold
  glasses, average cart, top products/categories, revenue trend and
  repeat customers.
- "Verkaufte Gläser" previously summed every order item, including
  cancelled orders and refunded returns.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 01:14:12 +02:00
Nick SzaunigandClaude Opus 5.5 dc1523aafd fix(orders): cancelled orders cannot be reopened (stock restocked twice)
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).

STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
  cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
  message and moves non-cancel statuses with an atomic
  "status <> STORNIERT" update, so a cancellation from a second tab
  cannot be undone in between
- the order list and detail page no longer offer the status select for
  cancelled orders

Existing stock that was restocked twice has to be corrected by hand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:45:31 +02:00
Nick SzaunigandClaude Opus 5.5 de82ad0233 feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.

System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
  Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
  whether each is set up and the names of missing variables, never a
  value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
  smtp.office365.com:587 and login.microsoftonline.com:443

/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:07:34 +02:00
Nick SzaunigandClaude Opus 5.5 b5311fae83 fix(mail): connect to Microsoft 365 over IPv4 and check network paths
The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.

- The shop now resolves smtp.office365.com via IPv4 only and connects
  to that address; the TLS certificate is still verified against
  smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
  sticks.
- The email test first probes smtp.office365.com:587 and
  login.microsoftonline.com:443 over IPv4 and reports each result, so a
  blocked port (hoster firewall, NetworkPolicy) is distinguishable from
  a configuration error.

Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 22:41:49 +02:00
Nick SzaunigandClaude Opus 5.5 2943021fe9 fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:

- The admin accepts the entry as written (plain or obfuscated with
  [at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
  only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
  mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
  contact page and every email in legal texts (Impressum, Datenschutz,
  Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
  with a fresh random key; the browser builds the clickable mailto link
  after load (components/protected-email.tsx).

Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:35:50 +02:00
Nick SzaunigandClaude Opus 5.5 12675f381a docs: step-by-step setup guide for secrets, SSO and Microsoft 365 mail
docs/EINRICHTUNG.md walks from nothing to a running shop: tools and
access, generating database/session/admin values, the Entra ID app
registration used for both sign-in and mail (redirect URI, client
secret, SMTP.SendAsApp with admin consent, enterprise app object id),
unlocking the shared mailbox in Exchange Online, Stripe keys and webhook
events, optional services, a complete secret/config map table, rollout,
verification, maintenance (secret expiry) and troubleshooting keyed to
the messages of the admin email test. Linked from README and k8s/README.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:21:01 +02:00
Nick SzaunigandClaude Opus 5.5 11910dd6dd refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00
Nick SzaunigandClaude Opus 5.5 0620e9d79a feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:17:33 +02:00
Nick SzaunigandClaude Opus 5.5 302a18baa6 feat(mail): SMTP diagnosis and admin email test for Microsoft 365
Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:

- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
  instructions: security defaults, SMTP AUTH disabled for tenant or
  mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
  the mailbox), wrong credentials, basic-auth retirement, network, TLS.
  The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
  signed-in admin only (no free recipient, rate limited, audit logged)
  and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
  never sent unencrypted, strips a trailing newline from secrets (a
  common kubectl/base64 mistake) and logs the diagnosed cause.

Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:48:40 +02:00
Nick SzaunigandClaude Opus 5.5 cef2248a45 fix(security): escape untrusted values in transactional email HTML
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.

All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:42:49 +02:00
Nick SzaunigandClaude Opus 5.5 dcfad4e011 feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 4ab1a7edae feat(legal): readable legal pages and corrected legal texts
Legal pages rendered admin text as bare paragraphs. LegalPage now has a
honeycomb header, a sticky table of contents with scroll tracking (a
collapsible one on mobile), anchor links, a reading progress bar and a
revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives
lists, cookie/provider entries with labelled facts and safe links from
plain text, so the pages stay editable in the admin without markup.

Legal corrections (German law as of 2026):
- Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu
  Art. 246a EGBGB): recipient name/address/e-mail filled from company
  data, deadline rule, return period, refund details, diminished value
  and the model withdrawal form. The incomplete notice extended the
  withdrawal period to up to 12 months and 14 days. Honey is no longer
  declared perishable.
- Impressum: TMG references replaced by DDG; editorial contact gains an
  address.
- Datenschutz: removes the self-hosted vs. hosting-provider
  contradiction, adds PayPal/Klarna, reviews, back-in-stock
  notifications, storage period, no automated decisions, withdrawal of
  consent and the Art. 21 objection notice; newsletter double opt-in.
- AGB: payment methods and delivery area point to the live shipping
  page; adds contract-text and input-correction information.
- Cookie policy: storage period and provider for every entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 07:02:12 +02:00
Nick SzaunigandClaude Opus 5.5 86cc0ad310 feat(ui): Seite "Fuer Unternehmen" mit Leben - Glaeser, Biene, Zielgruppen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m30s
- Hero: Titel gross auf dem Bienenfoto, darunter "Fuer ..." mit
  wechselnder Zielgruppe (Unternehmen, Kanzleien, Praxen, Hotels,
  Agenturen, Handwerksbetriebe - die Woerter aus dem Untertitel), eine
  Biene fliegt durchs Bild, Knopf springt zur Anfrage.
- Die drei Pakete wieder als Karten, jetzt mit gezeichnetem Honigglas:
  Klassik mit Banderole, Geschenk im Karton mit Schleife, Individuell mit
  Stift und Funkeln. Beim Hover hebt sich das Glas, die Schleife wippt.
  Ein Klick springt zum Formular und waehlt das Paket dort vor
  (?paket=, nur bekannte Schluessel).
- Anfrage: links Ueberschrift und das Glaeser-Foto (mitlaufend, leichte
  Parallaxe), rechts das Formular.

Neu im Woerterbuch nur Ueberschrift, Knopftext und die Zielgruppenliste;
keine neuen Aussagen. Dunkelmodus und reduzierte Bewegung beruecksichtigt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:35:29 +02:00
Nick SzaunigandClaude Opus 5.5 1ff5f8c722 docs: list every cookie and storage entry in the cookie policy
The policy only named wabenhain_session, although the site sets ten
cookies and three localStorage entries. § 25 TDDDG requires the
information for every access to the user's device, so each entry now
states purpose, provider, storage period, flags and legal basis.

Also adds the legal-basis section (§ 25 Abs. 1/2 TDDDG, Art. 6 DSGVO),
third-party cookies (Stripe __stripe_mid/__stripe_sid, Microsoft,
Cloudflare Turnstile), browser controls, data subject rights, withdrawal
under Art. 7(3) DSGVO and a revision date. Corrects the claim that the
cart never leaves the device: it is sent when an order is submitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:34:55 +02:00
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +02:00
Nick SzaunigandClaude Opus 5.5 b77516b9f5 feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
  "Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
  Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
  die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
  umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
  Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
  leicht mit (Zoom und Parallax, nur transform; components/parallax-media).

Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:57:45 +02:00
Nick SzaunigandClaude Opus 5.5 d52a762e8a fix: Produkt-Formular ueberschreibt keinen zwischenzeitlich gebuchten Bestand
Das Speichern schrieb den Formularwert immer fest in den Lagerbestand.
Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch
wieder in den Bestand, ohne dass es je zurueckkam.

- Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded)
- Feld unveraendert: Bestand wird nicht angefasst
- Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand
  entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert
  und der aktuelle Wert angezeigt
- Fehlt quantityLoaded (alter Tab), muss neu geladen werden

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:55:36 +02:00
Nick SzaunigandClaude Opus 5.5 5848eb6ad5 fix: Bestand bei Storno nach erstatteter Retoure nicht doppelt zurueckbuchen
Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.

- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
  (needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
  erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 03:12:37 +02:00
Nick SzaunigandClaude Opus 5.5 fdb6456207 feat(ui): Leerer Warenkorb zeigt verfuegbare Honige
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Bisher stand dort nur ein Satz und ein Knopf zum Shop. Jetzt steht der
Hinweis als Spalte links, daneben bis zu zwei aktuell verfuegbare
Produkte (Honig zuerst) - dasselbe Muster wie "Aktuelle Ernte" auf der
Startseite. Nutzt die bereits geladenen availableProducts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:14:52 +02:00
Nick SzaunigandClaude Opus 5.5 0fb176add8 feat(ui): Microsoft-Anmeldung nur wenn eingerichtet, in der Anmelde-Spalte
Der Knopf stand unter beiden Spalten (Anmelden und Konto erstellen),
obwohl er zur Anmeldung gehoert, und erschien auch ohne eingerichtete
Entra-Konfiguration - der Klick endete dann in der Meldung "SSO ist noch
nicht konfiguriert". Jetzt steht er unter dem Anmeldeformular und nur,
wenn isEntraConfigured() zutrifft. Die serverseitige Pruefung in der
Login-Route bleibt als zweite Absicherung.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:13:19 +02:00
Nick SzaunigandClaude Opus 5.5 774bc9cf00 feat(ui): Pakete fuer Firmenkunden als Auswahl-Karten im Formular
Die drei Pakete standen doppelt: als Deko-Kacheln (mit grossem
Waben-Wasserzeichen) ueber dem Formular und darin noch einmal als
Auswahlliste. Jetzt waehlt man sie direkt im Formular als Karten mit
sichtbarem Auswahl- und Fokuszustand; die Kacheln entfallen. Feldname
und Werte (package = KLASSIK/GESCHENK/INDIVIDUELL) sind unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:11:29 +02:00
Nick SzaunigandClaude Opus 5.5 b588975317 feat(ui): Seitenabschluesse, Wissens-Verzeichnis, Kopfzeile ohne Ueberlauf
- Imkerei, Herkunft und Ernten endeten mit dem letzten Absatz direkt vor
  der Fusszeile. Neuer PageOutro: zwei naechste Wege als grosse
  Serif-Links mit Kurztext (Ernten/Shop bzw. Herkunft/Shop).
- Kopfzeile: bei 1280 px lief sie 54 px ueber (horizontale Scrollleiste),
  weil die nicht umbrechbare Slogan-Zeile unter dem Logo schon ab xl
  erschien. Jetzt erst ab 2xl; geprueft 1024 bis 1920 px ohne Ueberlauf.
- Wissen: Inhaltsverzeichnis statt zehn gleicher Bloecke mit je einem
  "Weiterlesen" - ganze Zeile ist der Link, Titel und Kurztext
  nebeneinander. Ungenutzter Schluessel readMore entfernt.
- Produktseite: bei ein, zwei Empfehlungen steht die Ueberschrift als
  erste Rasterspalte statt ueber einer einzelnen verlorenen Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:05:25 +02:00
Nick SzaunigandClaude Opus 5.5 908afebacb feat(ui): Produktbild ab Tablet im Hochformat
Die Galerie lief im Querformat 4:3 neben einer langen Detailspalte und
wirkte daneben schmaechtig. Ab md jetzt 4:5, das Glas traegt die Seite;
auf dem Handy bleibt 4:3, damit der Kaufen-Knopf oben bleibt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:47:47 +02:00
Nick SzaunigandClaude Opus 5.5 7c305a3fce feat(ui): Bild-Platzhalter im Stil des Glas-Etiketts
Fehlt ein Foto, zeigte die Seite einen Farbverlauf mit Diagonallinien -
ein generisches Muster, das auf Produkt- und Inhaltskacheln wie
unfertige Vorlage wirkte. Jetzt: ruhige Markenflaeche mit leisem
Wabenraster, das gezeichnete Waben-Zeichen und darunter der Name in
gesperrten Serif-Versalien, wie auf dem Etikett. In kleinen Groessen
(Warenkorb, Galerie-Vorschau) bleibt per Container Query nur das Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:46:59 +02:00
Nick SzaunigandClaude Opus 5.5 7e467a9ddf feat(ui): Startseite mit eigenstaendigen Abschnitten statt Schablonen-Zickzack
- Aktuelle Ernte: bei ein, zwei Produkten steht die Ueberschrift als
  eigene Spalte daneben, statt ueber einem halb leeren Dreierraster.
- Herkunft: Foto randlos ueber die halbe Seitenbreite, Text daneben,
  ohne Rahmen.
- Philosophie: typografische Reihe mit Trennlinien und grossen
  Serif-Titeln statt vier Icon-Karten.
- Region: breites Bild, der Text liegt als Tafel ueberlappend darueber.
- Geschaeftskunden: kompakte Tafel mit randlosem Bild statt eines dritten
  grossen Bild/Text-Wechsels.

Inhalte, Texte und Content-Schluessel bleiben unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:45:06 +02:00
Nick SzaunigandClaude Opus 5.5 1fa8ca246a refactor(ui): Kicker-Zeilen ueber Ueberschriften entfernt
Jede zweite Ueberschrift trug eine kleine Versalien-Zeile mit Wabenpunkt
("• STIMMEN", "• AKTUELL VERFUEGBAR", "• KUNDENKONTO" ...). Auf 29 Seiten
und Abschnitten wiederholt las sich das wie eine Vorlage. SectionHeading
rendert sie nicht mehr, die Prop ist entfernt; die Ueberschrift traegt den
Abschnitt allein.

Die eyebrow-Texte, die weiterhin als Seitentitel, Bildbeschriftung oder
Breadcrumb dienen, bleiben; 18 danach ungenutzte Eintraege sind aus Typ und
beiden Woerterbuechern entfernt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:41:48 +02:00
Nick SzaunigandClaude Opus 5.5 a4581d3467 fix(ui): Weg vom Admin zur Website, Anfuehrungszeichen nicht mehr ueber den Sternen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Admin-Leiste (Desktop und Mobilmenue): Link "Zur Website" oberhalb von
  "Angemeldet als". Der Shop-Kopf ist im Admin ausgeblendet, bisher fuehrte
  von dort kein sichtbarer Weg zurueck.
- Startseite, Kundenstimmen: das grosse Schriftzeichen "„" sass auf der
  Grundlinie und ragte mit leading-[0.6] in die Sternebewertung. Ersetzt
  durch ein gezeichnetes SVG mit fester Hoehe und Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:22:24 +02:00
Nick SzaunigandClaude Opus 5.5 52ce37e216 fix: Weiterleitungen innerhalb der Seite relativ statt auf die Produktionsdomain
Sechs Route-Handler (Hinweis-Weiterleitung, E-Mail-Bestaetigung,
Newsletter bestaetigen/abmelden, Entra-Login und -Callback) bauten ihr
Weiterleitungsziel mit getBaseUrl() absolut zusammen. Ohne gesetztes
NEXT_PUBLIC_SITE_URL (lokal, Staging, Vorschau-Host) sprang man damit auf
https://wabenhain.com, und die gerade gesetzte Flash-Meldung ging auf dem
fremden Host verloren - aufgefallen, als die Admin-Pruefung nach
/api/hinweis umleitete.

Neuer Helfer lib/redirect.ts (redirectWithinSite) mit relativem
Location-Header, analog zu redirect() in Server Actions. Er lehnt alles
ausser Pfaden dieser Seite ab ("//host", "/\host", absolute URLs), damit
daraus keine offene Weiterleitung werden kann. getSiteUrl() bleibt fuer
Adressen, die die Seite verlassen (E-Mails, Stripe, Entra-redirect_uri,
Sitemap, Canonicals).

proxy.ts bleibt bei new URL("/konto", request.url) (eigener Host): der
Proxy verlangt einen absoluten Location-Header, ein relativer endet dort
in "Invalid URL" - als Kommentar festgehalten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:11:44 +02:00
Nick SzaunigandClaude Opus 5.5 4f442ec06d feat(admin): Logo und Favicon per Bild-Upload austauschbar
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).

- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
  Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
  app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
  lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
  Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
  neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
  mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.

Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:02:18 +02:00
Nick SzaunigandClaude Opus 5.5 ae2726b0ee refactor(admin): ApiCore-Synchronisierung nur noch einmal auf dem Dashboard
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Derselbe "Alles synchronisieren"-Knopf stand auf /admin/produkte und
/admin/ernten und sah dadurch wie zwei verschiedene Abgleiche aus. Er
sitzt jetzt einmal auf dem Dashboard, das Ergebnis des Laufs erscheint
dort oben. Die Action springt standardmaessig nach /admin zurueck; die
alten Ziele bleiben in der Whitelist erlaubt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:34:18 +02:00
Nick SzaunigandClaude Opus 5.5 11eccec01b feat(ui): Polish-Durchgang Admin und Mobil, Erstattung nur mit Rueckfrage
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.

Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.

Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:24:55 +02:00
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 95b4b5953d fix(admin): Kategorie und Slug beim Produkt-Anlegen serverseitig pruefen
Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:43:54 +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 15ddceaa39 feat(ui): Fehlerseite mit gerissener Wabe
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:30:29 +02:00
Nick SzaunigandClaude Opus 5.5 e6455b05e6 feat(ui): Admin-Seitenleiste mit Wabenmarkierung statt Seitenbalken
Aktive Seite mit Honig-Wabe statt farbigem border-left, feines Wabenraster
in der Seitenleiste (liegt per isolate/-z-10 unter dem Menue).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:28:40 +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 d4bb6ec1cf feat(ui): Adressen, Retoure, Suche und Cookie-Hinweis
- Adressen im Zweierraster, Standardadresse mit Honigrahmen und Wabenmarke,
  neues Adressformular in der Formular-Karte
- Retoure: Positionen als Liste mit Waben, Formular in der Karte
- Suche: Vorschlaege gleiten gestaffelt herein, Suchbegriff hervorgehoben,
  Lupe wird zum Schliessen-Symbol; das Feld legt sich ueber die Navigation
  statt sie zu verschieben (vorher: waagerechter Scrollbalken)
- Cookie-Hinweis als schwebende Karte; "Nur notwendige" und "Alle
  akzeptieren" gleich gewichtet (vorher war Akzeptieren Waldgruen auf
  Waldgruen und kaum als Knopf erkennbar)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:24:07 +02:00
Nick SzaunigandClaude Opus 5.5 baebf78ee4 feat(ui): Sendungsverfolgung im Wabenstil
- DHL-Verlauf: neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
  unterwegs), aeltere als leere Waben, Eintraege erscheinen gestaffelt
- Bestellung verfolgen: Suchformular in der Formular-Karte, Ergebnis als
  abgehobene Karte

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:19:42 +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 76be1292ed feat(ui): Markenseiten Herkunft, Imkerei, Unternehmen, Kontakt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
- Herkunft: atmendes Titelbild mit Wabenraster, Ueberschrift als Karte,
  die ins Bild ragt; Aspekte als Liste mit Waben
- Imkerei: Bild mit Honigrahmen, erster Absatz als Einstieg
- Fuer Unternehmen: Pakete als Karten mit Waben-Wasserzeichen, das beim
  Hover einrueckt; Anfrage mit mitlaufender Ueberschrift neben dem Formular
- Kontakt: E-Mail gross auf Wald-Karte mit Wabenraster, Abholung daneben

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:14:19 +02:00
Nick SzaunigandClaude Opus 5.5 dfda94ddfc feat(ui): Leseerlebnis fuer Wissen und rechtliche Seiten
- Artikel: Lesefortschritt als feine Honiglinie am oberen Rand, Initiale
  in Serifenschrift und Honig, groesserer Titel, angenehme Zeilenlaenge
- Wissen-Uebersicht und "Weitere Artikel" als ruhige Liste mit grossen
  Serifen-Titeln, Unterstrich und gezeichnetem Pfeil statt "→"
- Rechtliche Seiten mit klarerer Ueberschriften-Hierarchie

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:09:22 +02:00
Nick SzaunigandClaude Opus 5.5 e75392c75b feat(ui): Warenkorb-Seite mit Fortschritt zum Mengenrabatt
- Weg zum Mengenrabatt als sich fuellender Balken (gleiche Schwelle wie
  bisher der Texthinweis), erreichter Rabatt als Bestaetigung mit Wabe
- Positionen gleiten herein und beim Entfernen hinaus, Entfernen-Knopf
  als gezeichnetes Symbol statt Textzeichen
- Artikelnamen und Preise in Serifenschrift, Gesamtsumme tickt bei jeder
  Aenderung, Zusammenfassung laeuft beim Scrollen mit

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:06:53 +02:00
Nick SzaunigandClaude Opus 5.5 467984d027 feat(ui): Ernten, Chargen-Nachweis und aktive Navigation
- Ernten: Jahrgang als grosse Serifenzahl mit auslaufender Linie, Karten
  heben sich mit Honigschimmer, Pfeil und Unterstrich
- Ernte-Detail: grosser Titel, mitlaufendes Bild, Fuellstandsbalken je
  Charge (verfuegbare von abgefuellten Glaesern)
- Chargen-Seite (QR-Code auf dem Glas): Echtheitssiegel stempelt sich ein,
  Zeitleiste mit Waben und sich fuellender Linie, Werte in Serifenschrift
- Produktverweise heben sich, gezeichneter Pfeil statt Textzeichen
- Fix: aktive Seite in der Kopfzeile zeigte den Unterstrich in Textfarbe
  statt in Honig (unlayered CSS ueberstimmte die Utility-Klasse)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:04:18 +02:00
Nick SzaunigandClaude Opus 5.5 1c5282e84f fix: fehlenden STRIPE_PUBLISHABLE_KEY im Server-Log melden
Bisher fiel der fehlende Schluessel nur in der Kasse auf ("Zahlung gerade
nicht verfuegbar"), nie im Log des Pods.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:01:00 +02:00
Nick SzaunigandClaude Opus 5.5 064ca3ff6d feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue),
  einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten;
  weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck
  (steht still, solange Warenkorb oder Menue offen sind)
- Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen
- Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl
- Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein
  Haekchen in eine Wabe, Fehler werden als Alert angesagt
- 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:05:01 +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 6ac7cc5d1f feat(ui): Kundenkonto im Wabenstil
- Konto-Kacheln: Icon in einer Wabe, die sich beim Hover mit Honig fuellt,
  einlaufender Pfeil, Serifen-Titel
- Bestell-Statusleiste: Waben statt Punkte, erledigte Abschnitte fliessen
  nacheinander voll, aktueller Schritt pulsiert sanft
- Status-Badge mit pulsierendem Punkt bei laufenden Bestellungen
- Leere Zustaende mit gestrichelter Wabe und Serifen-Titel
- Formularfelder: einheitlicher Honig-Schein beim Fokus
- Groessere Abschnittstitel im Konto, Bestellsummen in Serifen-Preisschrift
- Nur CSS-Animationen (Komponenten rendern auch auf Admin-Serverseiten);
  reduzierte Bewegung schaltet Puls und Fuellen ab

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 07:53:02 +02:00
Nick SzaunigandClaude Opus 5.5 3de17ca070 feat(ui): Startseite mutiger gestaltet
- Hero: deutlich groessere Headline, langsam atmendes Bild, Wabenraster,
  Honigtropfen als Scroll-Hinweis, Hauptaktion in Honig
- "Sechs Schritte" als Weg: eine Honiglinie fuellt sich beim Scrollen und
  laesst die Waben der Schritte nacheinander aufleuchten (waagerecht ab lg,
  senkrecht auf dem Handy; reduzierte Bewegung: sofort gefuellt)
- Philosophie mit Waben statt Nummern, Verantwortung als geteiltes Layout
  mit mitlaufender Ueberschrift
- Bilder mit versetztem Honigrahmen, Wabenraster auf dunklen Flaechen,
  grosse Anfuehrungszeichen bei den Kundenstimmen
- Knopf-Varianten "inverted"/"accent" fuer dunkle Flaechen ersetzen
  !-Overrides, die den Fuell-Effekt ausgehebelt haetten
- Fix: Ueberschrift der Kundenstimmen war Waldgruen auf Waldgruen

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 07:48:08 +02:00
Nick SzaunigandClaude Opus 5.5 3cb639c154 feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet
- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
  Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
  geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
  wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
  gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
  Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
  Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
  Summe, gestalteter leerer Zustand

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:56:03 +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 2659db64bd docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s
Haelt die freigegebene Loesung fest: Versandstaffeln haengen kuenftig an der
Produktkategorie statt an einer einzigen globalen Mengenstaffel, weil ein
Honigglas deutlich mehr Versandkosten verursacht als eine Kerze. Bei
gemischten Warenkoerben gilt die teuerste Kategorie, nicht die Summe.

Ausserdem wird die Hoechstbestellmenge je Position im Adminportal pflegbar
(globaler Standardwert plus optionale Uebersteuerung je Produkt) statt als
widerspruechliche Konstanten im Stepper und im Checkout-Schema zu stehen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:16:48 +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 256e823c4f fix: Erntepost-Eingabefeld war auf 34 px zusammengedrueckt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m13s
Regression aus dem vorigen Commit (Turnstile per Prop statt process.env):
sobald das CAPTCHA auf der Newsletter-Anmeldung tatsaechlich rendert, sprengt
es die Eingabezeile.

Das Formular war "flex flex-col sm:flex-row gap-3 max-w-lg" ohne flex-wrap,
und das Widget lag als dritter Eintrag in derselben Zeile. Ab 640 px teilten
sich damit Eingabefeld, Knopf und ein rund 300 px breites CAPTCHA eine Zeile
von 512 px. Auf wabenhain.com bei 1440 px nachgemessen: Eingabefeld 34x71,
Knopf 174x71, CAPTCHA 300x71.

Solange kein Sitekey ankam, fiel das nicht auf — das Widget erschien nie, die
Zeile hatte nur zwei Eintraege. Der Fehler lag also schon laenger im Markup
und wurde erst sichtbar, als das CAPTCHA wie vorgesehen erschien.

Jetzt bilden Eingabefeld und Knopf eine eigene Zeile, das CAPTCHA steht
darunter. Die Firmenanfrage ist nicht betroffen, sie stapelt ueber space-y-5.

Dazu zwei Einstellungen am Widget selbst, die von Anfang an gefehlt haben:

- language: Turnstile folgte der BROWSER-Sprache statt der Seitensprache. Auf
  der deutschen Seite stand englisch "Verify you are human".
- theme: "light": Turnstile folgte dem Farbschema des Betriebssystems. Wer es
  auf "dunkel" stehen hat, bekam einen schwarzen Kasten mitten in einen
  Abschnitt, den diese Seite ausschliesslich in Creme und Waldgruen zeichnet.
  Die Seite hat keinen Dunkelmodus, "auto" ist hier also immer falsch.

Beides betrifft alle Formulare, nicht nur den Newsletter.

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:44:09 +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 3393de2286 fix: Hero-Text war ueber hellem Bildmaterial nicht lesbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Ortszeile ueber der Ueberschrift stand in honey-light auf einem Foto mit
25 % Grundabdunklung plus einem to-tr-Verlauf, der die Textspalte bei rund
44 % Gesamtdeckung liegen liess. Gegen ein helles Motiv (Holz, Himmel)
gerechnet ergab das:

    Ortszeile    1,34:1   (WCAG AA verlangt 4,5:1 bei 12 px)
    Ueberschrift 2,73:1   (verlangt 3:1 bei grosser Schrift)
    Fussnote     1,90:1

Lighthouse meldet davon nichts und gibt der Seite weiterhin 100 fuer
Accessibility — Kontrast von Text ueber Bildern laesst sich nicht statisch
pruefen, weil der Hintergrund vom hochgeladenen Motiv abhaengt. Genau deshalb
muss die Abdunklung den schlechtesten Fall abdecken und nicht das Bild, das
gerade hinterlegt ist.

Drei Aenderungen, alle nachgerechnet:

- Verlauf ab md waagerecht (85 % -> 70 % -> 0) statt diagonal. Die Textspalte
  endet bei etwa 45 % Breite und liegt damit ganz im dunklen Teil, waehrend
  die rechte Bildhaelfte unangetastet bleibt.
- Auf schmalen Schirmen senkrecht, weil der Text dort ueber die volle Breite
  laeuft und rechts sonst in den durchsichtigen Teil geriete.
- Neuer Farbton honey-pale (#e4cfa4) fuer die Ortszeile; honey-light haelt
  4,5:1 selbst bei 89 % Abdunklung nicht (4,14:1). Fussnote von cream/60 auf
  cream/80.

Ergebnis in der Textspalte: Ortszeile 4,81:1 bis 6,97:1, Ueberschrift 6,41:1
bis 9,28:1, Fussnote 4,80:1 bis 6,59:1 — AA auf allen Breiten.

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:04:36 +02:00
Nick SzaunigandClaude Opus 5 368985c0cf fix: Newsletter und Firmenanfrage waeren mit aktivem CAPTCHA unabsendbar gewesen
Beide Formulare lasen NEXT_PUBLIC_TURNSTILE_SITE_KEY direkt aus process.env,
und zwar im Modulrumpf einer Client Component. NEXT_PUBLIC_-Variablen werden
dort zur BUILD-Zeit ins Bundle eingebacken. Gesetzt wird der Wert hier aber
erst zur Laufzeit (k8s/01-configmap.yaml) — im Docker-Build existiert er nicht
und wird auch nicht als Build-Arg hereingereicht.

Der Wert war damit dauerhaft undefined, das Widget erschien nie. Solange
TURNSTILE_SECRET_KEY ebenfalls leer ist, faellt das nicht auf:
verifyTurnstileToken gibt ohne Secret bewusst true zurueck. Sobald das Secret
aber gesetzt wird — also genau beim Scharfschalten des Schutzes —, verlangt
der Server einen Token, den der Browser nirgends erzeugen kann. Newsletter-
Anmeldung und Firmenanfrage haetten dann jede Eingabe abgewiesen, ohne dass
irgendwo ein Fehler sichtbar wird.

Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im Server
Component und reichen ihn als Prop weiter — dort wirkt die Laufzeit-Variable.
Diese beiden folgen jetzt demselben Muster.

Bewusst so und nicht ueber ein Docker-Build-Arg geloest: ein Build-Arg wuerde
den Schluessel ins Image einbacken, womit ein Schluesselwechsel einen Neubau
braeuchte und die ConfigMap fuer diese beiden Stellen wirkungslos bliebe. Ein
einziger Weg fuer alle Formulare ist weniger fehleranfaellig als zwei.

Hinweis fuer den Betrieb: der Schutz ist damit noch nicht aktiv. Dafuer
muessen NEXT_PUBLIC_TURNSTILE_SITE_KEY (ConfigMap) UND TURNSTILE_SECRET_KEY
(Secret) gesetzt sein — beide oder keiner.

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:04:36 +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 cc267ad8d4 fix: ohne JavaScript blieb der gesamte Seiteninhalt unter dem Hero unsichtbar
Hero, ScrollReveal und ProductCard starten mit opacity 0 und werden per
motion/react eingeblendet. Diese Startwerte stehen als inline-style bereits im
server-gerenderten HTML — auf wabenhain.com an 38 Elementen nachgezaehlt. Laeuft
kein JavaScript, blendet sie niemand wieder ein: der Besucher sieht das
Hero-Bild und darunter eine leere Seite. Kein Teilausfall, sondern der
komplette Inhalt.

Fuer Suchmaschinen war das unkritisch, der Text steht im HTML. Fuer Menschen
mit deaktiviertem JavaScript war es ein Totalausfall.

Die drei Bausteine tragen jetzt data-reveal, und ein noscript-Block im
Root-Layout hebt darueber die Startwerte auf. Das Attribut statt eines
Treffers auf [style*="opacity:0"] ist der Kern der Sache: Warenkorb-Schublade,
Menue und Cookie-Banner blenden ueber AnimatePresence ebenfalls mit opacity 0
ein, sollen ohne JavaScript aber verborgen BLEIBEN. Sie tragen das Attribut
nicht.

!important ist hier notwendig und nicht nachlaessig — einen inline-style
ueberschreibt sonst nichts.

Grenze der Loesung: noscript deckt "JavaScript deaktiviert" ab. Laedt das
Bundle dagegen und schlaegt erst spaeter fehl, greift es nicht; dafuer waere
ein Zeitgeber noetig, der die Animation im Normalfall stoeren wuerde.

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:51 +02:00
Nick SzaunigandClaude Opus 5 c5e4978fad fix: Produkt ohne Geschmacksnoten bekam "Waldhonig — . 500 g" als Beschreibung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m5s
Die Beschreibung der Produktseite setzt Name, Geschmacksnoten und Fuellmenge
zusammen und rechnet dabei fest damit, dass Noten gepflegt sind. Sind sie es
nicht — ein freiwilliges Admin-Feld —, blieb zwischen Gedankenstrich und
Fuellmenge ein einzelner Punkt ohne Inhalt stehen.

Auf wabenhain.com an "Waldhonig" beobachtet: description und og:description
lauteten "Waldhonig — . 500 g aus eigener Imkerei in Altenmünster." Das ist
genau der Text, den Google als Snippet anzeigt und den jede geteilte Vorschau
uebernimmt.

Derselbe Fall liess unter der Ueberschrift einen leeren Absatz stehen, der
nur Abstand zwischen Produktnamen und Bewertung erzeugte.

Beides faellt jetzt weg, wenn keine Noten gepflegt sind; mit Noten bleibt die
Ausgabe unveraendert.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:31:35 +02:00
Nick SzaunigandClaude Opus 5 7dd42e35bc perf: Produktbilder werden als AVIF ausgeliefert
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m43s
next/image lieferte bisher nur WebP (die Next.js-Vorgabe). Am Hero-Bild der
Startseite (wabenhain.com, 1920x1280) nachgemessen, mit genau den
Einstellungen, die Next.js selbst verwendet — WebP q75 gegen AVIF q47
(image-optimizer.js skaliert die Qualitaet mit 50/80) bei effort 3:

    Breite   WebP        AVIF
      640     21,2 kB     12,0 kB   -43 %
      828     30,2 kB     17,4 kB   -42 %
     1080     44,4 kB     26,5 kB   -40 %
     1920    105,0 kB     65,7 kB   -37 %

Auf einer Shop-Uebersicht mit einem Dutzend Kacheln ist das der groesste
einzelne Posten der Seite. Browser ohne AVIF-Unterstuetzung bekommen
unveraendert WebP, ganz alte das Original; die Auswahl trifft Next.js anhand
des Accept-Headers.

Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in derselben
Messung durchgehend gleichauf oder schneller als WebP (1920 px: 60 ms gegen
122 ms). minimumCacheTTL steigt trotzdem von den voreingestellten vier Wochen
auf ein Jahr — nicht wegen der Kodierdauer, sondern weil hochgeladene Bilder
unveraenderlich sind: lib/uploads.ts legt jede Datei unter einem eigenen
Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL. Ein
kurzes TTL wirft hier nur brauchbare Ergebnisse weg.

Dazu zwei ausdrueckliche Festschreibungen, die das bisherige Verhalten
dokumentieren statt es zu aendern:

- qualities: [75] entspricht der Vorgabe von Next.js 16, die andere Werte
  bereits ablehnt (gegen die laufende Seite geprueft: ?q=42 antwortet mit
  400). Ausgeschrieben, damit eine spaetere Erweiterung der Liste eine
  bewusste Entscheidung ist und nicht unbemerkt Kodier-Last je Wert oeffnet.
- dangerouslyAllowSVG: false entspricht ebenfalls der Vorgabe. Ausgeschrieben,
  weil ein SVG ein Dokument ist, das Skripte enthalten kann, und hier
  same-origin ausgeliefert wuerde.

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:12:44 +02:00
Nick SzaunigandClaude Opus 5 73c070f11e feat: Produktseiten liefern Google Versandkosten, Ruecknahme und Verkaeufer
Das Product-Schema der Produktseite enthielt Preis, Verfuegbarkeit und
Bewertung, aber keine der Angaben, die Google fuer ein vollstaendiges
Produkt-Suchergebnis erwartet. Praktische Folge: der Preis wurde nach einiger
Zeit als moeglicherweise veraltet eingestuft und ausgeblendet, und
Versandkosten schaetzte Google selbst — bei einem Shop, dessen Versandstaffel
ohnehin auf der Seite steht.

Neu im Offer-Block:

- shippingDetails mit der tatsaechlichen Staffel fuer ein Glas, also demselben
  "ab"-Preis, der auf der Seite ausgewiesen ist. Das Liefergebiet kommt aus
  der Admin-Einstellung (Deutschland oder EU), nicht aus einer festen Liste.
- hasMerchantReturnPolicy mit dem gesetzlichen Widerrufsrecht: vierzehn Tage
  ab Erhalt, Rueckgabe per Versand. returnFees fehlt bewusst — wer die Kosten
  der Ruecksendung traegt, sagt die Widerrufsbelehrung nicht, und eine
  Annahme an dieser Stelle waere eine Zusage gegenueber Google, die der Shop
  nirgends macht.
- priceValidUntil, itemCondition sowie seller als Verweis auf das
  Store-Schema aus dem Root-Layout, statt Name und Anschrift ein zweites Mal
  auszuschreiben.
- weight als Fuellmenge in Gramm — der Wert, ueber den Kaeufer
  Lebensmittelangebote vergleichen.

Ausserdem ein eigener openGraph-Block. Ohne ihn erbte jede Produktseite den
Block aus dem Root-Layout unveraendert: ein geteilter Honig-Link zeigte in
WhatsApp, Facebook oder Slack Titel und Text der STARTSEITE, egal welches
Produkt verlinkt war. Das Feld "images" bleibt bewusst leer, damit die
gestaltete Vorschaukarte aus opengraph-image.tsx erhalten bleibt — ein
Eintrag dort haette sie durch das rohe Produktfoto ersetzt.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:06:02 +02:00
Nick SzaunigandClaude Opus 5 a8990a872a fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex
robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler.
Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo
ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als
inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine
Informationen verfuegbar".

Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per
robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag
nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus
dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein
noindex.

Jetzt greifen beide Mechanismen zusammen statt gegeneinander:

- lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links
  weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre
  Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie,
  Bildindexierung und Textausschnitt.
- Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate,
  der Warenkorb noIndex.
- Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues
  konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt
  feldweise, ein eigener title in einer Unterseite loescht den Wert also
  nicht.
- robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler
  wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das
  noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen
  die Produktbilder, die in der Bildersuche erwuenscht sind.

Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot
und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie
die host-Angabe in robots.txt.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:05:54 +02:00
Nick SzaunigandClaude Opus 5 bffcce127d fix: "Passwort vergessen" schob sich ueber das CAPTCHA
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m41s
Im Anmeldeformular standen CAPTCHA, Absenden-Knopf und der
"Passwort vergessen"-Link in EINER Flex-Zeile ohne Umbruch:

    <div className="flex items-center justify-between gap-4">

Das Turnstile-Widget ist ein iframe mit FESTER Breite von rund 300 px und
schrumpft nicht mit. Die Zeile steht zudem in einer der beiden Spalten
der Kontoseite, hat also nur die halbe Breite zur Verfuegung. Der Platz
reichte daher nie — die Elemente schoben sich uebereinander, und zwar
nicht nur auf schmalen Bildschirmen.

Jetzt steht das Widget auf einer eigenen Zeile, Knopf und Link darunter
in einer Zeile mit "flex-wrap". Das Registrierungsformular daneben hat es
schon immer so gemacht; die beiden sind damit endlich gleich aufgebaut.

Damit das an keiner Stelle wiederkommen kann, traegt das Widget selbst
jetzt "w-fit max-w-full overflow-x-auto". Das ist kein Schoenheitsmittel,
sondern eine Zusicherung: der iframe bleibt in jedem Fall innerhalb
seines Kastens. Ist der zu schmal, laesst sich der Inhalt seitlich
schieben, statt das Layout zu zerlegen — unabhaengig davon, wie eine
kuenftige Seite ihn einbettet.

Die uebrigen sieben Einbindungen (Registrierung, Passwort vergessen,
Passwort zuruecksetzen, Zwei-Faktor-Code, Produktseite, Firmenanfrage,
Newsletter, Checkout) habe ich geprueft: dort steht das Widget bereits
auf einer eigenen Zeile.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 02:52:15 +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 29f59adc6b feat: Kommando zum Nachschlagen der Google-Place-ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m37s
Die Place-ID steht nicht in der Google Cloud Console — dort gibt es nur
den API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss
ueber die Places API nachgeschlagen werden.

  pnpm places:find "Wabenhain Altenmünster"

gibt Name, Anschrift und GOOGLE_PLACE_ID je Treffer aus.

Das Kommando ist zugleich die schnellste Probe auf die Einrichtung:
kommt eine ID heraus, sind "Places API (New)", Abrechnung und Schluessel
richtig gesetzt und die Startseite kann die Rezensionen abrufen. Kommt
eine Ablehnung, nennt sie die drei tatsaechlich moeglichen Ursachen
(API nicht aktiviert, keine Abrechnung, OAuth-Client statt
API-Schluessel bzw. Referrer-Beschraenkung) — statt wie Google pauschal
auf den Schluessel zu zeigen.

Die Feldmaske fordert nur id, displayName und formattedAddress an: die
API rechnet nach angeforderten Feldern ab.

tsc: 0 Fehler. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:32:05 +02:00
Nick SzaunigandClaude Opus 5 6639da130e fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.

Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.

Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.

Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.

Zwei Verhaltensaenderungen, beide unvermeidbar:
  - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
    Rezensionen in Googles eigener Reihenfolge.
  - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
    Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
    Zuordnung zur Quelle.

README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.

Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:16:19 +02:00
Nick SzaunigandClaude Opus 5 9b10a9f0c5 perf: Checkout haelt gleichzeitigem Andrang stand
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.

1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft

Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.

Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.

2. Zu enger Verbindungspool, zu knappe Transaktionsfristen

Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.

Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.

Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.

3. Drei Regressionstests

Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:10:41 +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 e5cae9b49d perf: Firmendaten und Social-Links je Request nur noch einmal laden
getCompanyInfo() wurde bei JEDEM Seitenaufruf mehrfach ausgefuehrt: das
Root-Layout braucht es zweimal (einmal in generateMetadata, einmal fuer
das LocalBusiness-Schema im Rumpf), dazu kommt fast jede Seite mit einem
eigenen Aufruf. Jeder davon war eine eigene Datenbankabfrage ueber
dieselben zehn Schluessel — dreimal dasselbe Ergebnis pro gerenderter
Seite, auf jeder Seite des Shops.

Beide Funktionen sind jetzt in React-cache() gehuellt. Die
Deduplizierung gilt je Request und ist KEIN serverweiter
Zwischenspeicher: eine Aenderung im Admin-Bereich wirkt unveraendert
sofort, es gibt nichts zu invalidieren. Beide Funktionen nehmen keine
Argumente, damit greift die Deduplizierung zuverlaessig — cache()
unterscheidet Aufrufe anhand ihrer Argumente.

Geprueft: "next build" laeuft durch, tsc 0 Fehler, vitest 328/328 gruen,
eslint 0 Warnungen.

Nicht geaendert, aber geprueft: die Admin-Listen ohne "take" (Chargen,
Ernten, Gutscheine, Inhalte, Produkte, Versandstaffeln) betreffen
ausnahmslos kleine, langsam wachsende Tabellen — eine Begrenzung waere
dort Vorratsarbeit. Die Listen auf den wachsenden Tabellen (Bestellungen,
Bewertungen, Audit-Log, Retouren, Anfragen, Benutzer) sind bereits
begrenzt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:47:43 +02:00
Nick SzaunigandClaude Opus 5 2bbb38c31a fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes
SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei
Admin-Konten.

requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu,
wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter
jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein
zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine
vollstaendige Session an und lieferte damit ein Flag, das "geprueft"
behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die
Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des
Entra-Mandanten, die dieser Code nicht nachpruefen kann.

Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben
TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand:
twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer
KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen.

PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard.

Die Startseite des Admin-Bereichs lud bei jedem Aufruf

  1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus
     fuenf Zeilen "Top-Produkte" zu rechnen, und
  2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl
     ("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma
     koenne kein HAVING. Es kann.

Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem
Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je
Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht
mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die
Primaerschluessel der tatsaechlich verkauften Produkte.

PERFORMANCE — fehlende Indizes auf order_items.

PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als
MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten
dadurch zwei:

  - productId: hasCustomerPurchased (lib/reviews.ts) prueft den
    "verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine
    angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan.
  - batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict
    gegen dieselbe Tabelle.

Migration 20260919010000_order_item_fk_indexes.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:22:53 +02:00
Nick SzaunigandClaude Opus 5 3e81896194 fix: Session-Pruefung akzeptierte Token anderer Zwecke
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".

Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:

  signTwoFactorChallenge("customer_123")
  -> verifySessionToken(...) ergab
     { customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }

Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.

Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.

verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.

Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.

Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.

tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 02:30:47 +02:00
Nick SzaunigandClaude Opus 5 5170e477bc fix: leere Telefonnummer wird nirgends mehr angezeigt
Ohne im Admin-Bereich gepflegte Nummer griff bisher der Rueckfallwert aus
lib/site-config.ts — und der war eine Platzhalternummer
("+49 (0) 000 000000"). Sie stand damit tatsaechlich auf der Kontaktseite
und im Impressum. Eine Nummer, unter der niemand erreichbar ist, ist
schlimmer als gar keine Angabe.

Der Rueckfallwert ist jetzt leer, und beide Stellen lassen die Zeile bei
leerem Feld weg:

- kontakt/page.tsx rendert das Absatz-Element gar nicht erst.
- impressum/page.tsx nimmt die Telefonzeile aus dem Kontakt-Abschnitt.
  § 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
  nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.

Der DHL-Versand war bereits darauf vorbereitet: buildDhlContact setzt
"phone: address.phone || undefined" und laesst das Feld im Aufruf
entsprechend weg (lib/dhl.ts).

Die Go-Live-Pruefung warnt weiterhin bei einer EINGETRAGENEN
Platzhalternummer, aber nicht mehr bei einem leeren Feld — leer ist ab
sofort eine zulaessige Entscheidung, und der Hinweistext sagt das.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 20:54:54 +02:00
Nick SzaunigandClaude Opus 5 dbf146c19f test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten
bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene
Haken fuer Werte, die gar nicht gesetzt waren.

Tests fuer api/checkout/route.ts (10 Faelle)
  Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes
  Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die
  beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung
  aufgeloest und OHNE orderNumber geantwortet; laesst sich die
  Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per
  Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird.

Tests fuer api/checkout/webhook/route.ts (12 Faelle)
  Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt
  Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und
  Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine
  stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung,
  fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung.

Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt.

Go-Live-Pruefung: falsch-positive Haken beseitigt
  Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert
  einfach — eine leere Zeichenkette enthaelt nun einmal kein
  "example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist
  die echte Domain" als bestanden, direkt neben dem Blocker
  "NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt
  sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste
  Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber
  genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen
  Folgeregeln bei fehlendem Wert entfallen.

  Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung
  prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der
  ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal
  erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf
  im Pod.

tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 17:50:38 +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 dda0a66c6c fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT"
stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein
echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche
nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen
Vorgänge.

Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein
Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch
nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen
sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf.

Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed.
Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert
(SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout
abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als
"STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim
bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man
selbst abgebrochen hat, verwirrt mehr als sie hilft.

releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter.
Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es
entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts.
Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt,
fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter
keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen,
sonst wäre der Bestand danach nicht mehr zurückzubuchen.

Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt
als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen
auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen,
indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer
Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter
den Händen wegzunehmen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:13:35 +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 e525db84ef feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.

Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.

Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.

Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.

Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 01:26:18 +02:00
Nick SzaunigandClaude Opus 5 7fe1a01550 fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s
Die Anfrage an die Places API setzte "fields=review". Richtig ist "reviews"
(Plural). Google antwortet auf den falschen Wert mit INVALID_REQUEST, die
Funktion gab daraufhin null zurueck, und die Startseite fiel auf die
statischen Beispiel-Stimmen zurueck.

Nach aussen sah das voellig normal aus — eine Bewertungen-Sektion mit
Zitaten und Sternen war ja da. Nur waren es nie die echten. Genau deshalb
blieb der Tippfehler unbemerkt.

Damit das nicht wieder passieren kann, meldet die Anbindung jetzt, WARUM sie
nichts liefert. Vorher fuehrten alle Fehlerfaelle stumm zum selben Bild:

  [google-reviews] Places API meldet "REQUEST_DENIED": ...

REQUEST_DENIED (Schluessel ungueltig oder Places API im Projekt nicht
aktiviert), NOT_FOUND (Place-ID stimmt nicht) und OVER_QUERY_LIMIT
(Kontingent erschoepft oder keine Abrechnung hinterlegt) sind damit von
aussen unterscheidbar.

Weitere Korrekturen:

- Zeitlimit von fuenf Sekunden. Ohne das haengt die Startseite an einer
  nicht antwortenden fremden API — dieselbe Absicherung wie beim
  Passwort-Leak-Abgleich.
- Rezensionen verlinken auf ihre Quelle bei Google. Die Nutzungsbedingungen
  der Places API verlangen die Zuordnung; bisher stand ein fremdes Zitat
  unbelegt auf der Seite. author_url und profile_photo_url wurden aus der
  Antwort schlicht verworfen.
- Rezensionen ohne Text werden uebersprungen. Reine Sternebewertungen sind
  in der Antwort enthalten und waeren als Zitat eine leere Sprechblase; "text"
  ist laut Google optional, war hier aber als Pflichtfeld typisiert.

README: Einrichtung, Bedeutung der Statuscodes und der Hinweis, dass Google
den genutzten place/details-Endpunkt als Legacy fuehrt — bleibt
REQUEST_DENIED trotz gueltigem Schluessel bestehen, ist die Umstellung auf
die Places API (New) faellig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:17:13 +02:00
Nick SzaunigandClaude Opus 5 33580b3290 feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem
CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt
offen, die am meisten hergeben:

- Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten
  bzw. des sechsstelligen Codes.
- Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein
  Weg herauszufinden, welche Adressen registriert sind.
- Checkout — automatisierte Bestellungen blockieren Lagerbestand und
  eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests
  brauchbar macht.
- Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse
  entgegen und loest spaeter Versand an sie aus.

Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte
Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der
Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld
— er schickt kein Formular ab.

Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine
Seite den Feldnamen anders geschrieben, waere das Token leer angekommen —
und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere
der Schutz dort lautlos ausgefallen.

Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein
BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung
nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen
Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein,
waehrend jedes Formular offensteht.

Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein
GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein
Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt
rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse.
Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben
ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches
Raetsel kostete nur Bedienbarkeit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:07:12 +02:00
Nick SzaunigandClaude Opus 5 6135c1f9c0 fix(security): Rechnungsablage kann nicht mehr ins oeffentliche Upload-Verzeichnis geraten
app/uploads/[...path] liefert JEDE Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus — das ist fuer Produktfotos gewollt. Dass Rechnungen nicht
darunter liegen, war bisher aber allein eine Frage der richtigen
Konfiguration: ein INVOICE_DIR="/app/uploads/rechnungen" haette genuegt,
damit fremde Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar
sind, der den Pfad erraet.

Das waere im Betrieb nicht aufgefallen, denn funktioniert haette weiterhin
alles: Ablegen, Versenden und Abrufen ueber /rechnung/<nummer>/pdf haetten
sich genauso verhalten wie vorher. Nur haette daneben eine zweite,
ungeschuetzte Tuer zu denselben Dateien gestanden.

Drei Sicherungen statt einer Anmerkung in der Dokumentation:

- lib/invoice-storage.ts wirft beim Laden des Moduls, wenn INVOICE_DIR
  innerhalb von UPLOAD_DIR liegt. Lieber startet die Anwendung nicht, als
  dass sie Belege oeffentlich ausliefert.
- "pnpm preflight" meldet denselben Fall als Blocker, also bereits vor dem
  Deploy statt erst beim Start.
- Ein Test haelt die Trennung fest, damit sie nicht beim naechsten Umbau
  unbemerkt verloren geht.

Der uebrige Sicherheitsdurchgang ueber die Aenderungen dieser Sitzung ergab
keine ausnutzbaren Befunde: der Admin-Bereich ist dreifach abgesichert
(Proxy, Layout, jede Server Action einzeln), jeweils autoritativ gegen die
Datenbank und mit erzwungener Zwei-Faktor-Authentifizierung; das returnTo
des Sync-Knopfes wird gegen eine feste Liste geprueft; Pfade aus der
Datenbank werden weiterhin gegen ihr Wurzelverzeichnis validiert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:29:54 +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