Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a4170a1e6 | |||
| 51e4860fe3 | |||
| 8c2220ae45 | |||
| ee39a5d7dc | |||
| 2b2ee4d23c | |||
| f5b898a69a | |||
| fc8a1d4201 | |||
| bce5a9f82c | |||
| bc40e22910 | |||
| 2eda211a29 | |||
| 8c5e284b6c | |||
| 72c7b0454a | |||
| 11aa9689c1 | |||
| 1d91a3f21c | |||
| 475ee3fa16 | |||
| c8cf6ae10d | |||
| 2f8c184dc6 | |||
| e7d5dd8b1e | |||
| 79d3f836a2 | |||
| 113d25eef9 | |||
| 785c1e6eba | |||
| 9024112d88 | |||
| 7ce8d02afe | |||
| 11c8d0b7b6 | |||
| 6b8ba44d46 | |||
| ea7f4a4b3b | |||
| 6262a8ab01 | |||
| 7baf8f9e24 | |||
| a036c89291 | |||
| 32f4068272 | |||
| 275d5abd95 | |||
| 0f50ab334c | |||
| 54725f7c5f | |||
| 7c9bfc3897 | |||
| 014f1edcdc | |||
| cca555c0f4 | |||
| c6fe65ad99 | |||
| 73b4b06eae | |||
| 3704acab41 | |||
| 53c452583d | |||
| 80843cc08b | |||
| c6051442df | |||
| 1fe1a260e4 | |||
| 5d588c55b4 | |||
| 44f98a3d64 | |||
| 8ddde89143 | |||
| 84e21589c3 | |||
| 9502cf81b9 | |||
| 18f5156fd8 | |||
| f0b2d21989 | |||
| 2e537bc78d | |||
| 4adb844f8d | |||
| beea592706 | |||
| 6448c8736a | |||
| 0e995884a7 | |||
| 8c843c0ac1 | |||
| e3352d7e32 | |||
| 1467750db6 | |||
| c83bbfbc35 | |||
| c852752006 | |||
| a3f843ad15 | |||
| bffc7d39a2 | |||
| 70abe20250 | |||
| 22fef4fe49 | |||
| fb1bedcacd | |||
| 27d383b559 | |||
| 4524005f0e | |||
| 14b47fd049 | |||
| 47789e94db | |||
| a6e1734799 | |||
| 0a467aed59 | |||
| 14094ab1d4 | |||
| 96fddf4cd9 | |||
| 3fc98b8480 | |||
| 5f219c23f9 | |||
| 908f46c962 | |||
| d389790c57 | |||
| 3aa8720daf | |||
| 077986affb | |||
| 5d61dce12b | |||
| 17679cc81e | |||
| 19440b9bcf | |||
| 56fc8d970d | |||
| 61afa0dcae | |||
| 724530b073 | |||
| 5c43a49ba9 | |||
| e0299713f5 | |||
| a9c8344472 | |||
| cbb423f8ca | |||
| dfb6eb5e23 | |||
| 5e1d88d13e | |||
| c3a6c84d8a | |||
| 932cf60c5c | |||
| cc935420ec | |||
| 94cba756c8 | |||
| b835e8622f | |||
| 7286c76787 | |||
| e175d2b4c3 | |||
| 1306dce6e4 | |||
| 8fa4a20e63 | |||
| 1d875a65c0 | |||
| 0436e87147 | |||
| 7feda76b3c | |||
| c4fb6586e7 | |||
| 3dccc229af | |||
| 6c43171b11 | |||
| 8d4ee75cfb | |||
| 8a910f7ff8 | |||
| a7fa0ec027 | |||
| 0ac2e45077 | |||
| fd98f68ba6 | |||
| bccfc28012 | |||
| 55df2e104c | |||
| 1706da8598 | |||
| 1dbc0c31ff | |||
| 06a00d67e4 | |||
| 952b902702 | |||
| f959ebb998 | |||
| bae23775f2 | |||
| 0e12ab1f1e | |||
| cb4c9d77f2 |
@@ -120,7 +120,7 @@ explains what does have access instead.
|
||||
| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer |
|
||||
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
|
||||
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
|
||||
| `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
|
||||
| `email-templates` | Editable subject/heading/body/footer for all 9 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
|
||||
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
|
||||
|
||||
All of the above except `company-settings`, `media`, `users`, `tenants`
|
||||
@@ -374,7 +374,12 @@ exactly as before: no gateway involved, order goes straight to `received`.
|
||||
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
|
||||
`alreadyProcessed: true` — a repeat webhook delivery must never resend
|
||||
it), mirroring exactly what the checkout route already does inline for
|
||||
a manual/Überweisung order.
|
||||
a manual/Überweisung order. Product photos in that snapshot's
|
||||
`items[].imageUrl` need no frontend change to work —
|
||||
`ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
|
||||
field; the backend just wasn't populating it (fixed there, see its own
|
||||
README — needed `depth: 2` so `item.product.image` resolves to a real
|
||||
`Media` doc).
|
||||
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
|
||||
`return_url` target. Neither a client-side `confirmPayment()` success nor
|
||||
landing back from a PayPal redirect is trusted as proof of payment on its
|
||||
@@ -407,6 +412,68 @@ Stripe API call itself that's faked. That test-confirm route hard-404s
|
||||
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
|
||||
a reachable "mark any order paid" endpoint in production.
|
||||
|
||||
**Activating real Stripe payments — checklist.** Everything code-side is
|
||||
already live (both `main` branches deployed); this is purely
|
||||
provisioning. Nothing here is required to *test* the flow today —
|
||||
`PAYMENT_TEST_MODE` already works end to end with zero Stripe account.
|
||||
|
||||
Already done, as of 2026-07-25:
|
||||
- [x] `PAYMENT_WEBHOOK_SECRET` set in Coolify, matches the backend's copy
|
||||
in `docker/.env` — needed even in test mode (the test-confirm route
|
||||
sends it as a header the backend checks).
|
||||
- [x] `payment-methods` rows configured: Kreditkarte/PayPal →
|
||||
`provider: 'stripe'`; "Überweisung (Vorkasse)" → `provider: 'manual'`
|
||||
(untouched by anything below); "Sofortüberweisung" prepared as
|
||||
`provider: 'stripe'` but `active: false` (needs a logo before switching
|
||||
on — see the backend's own README).
|
||||
- [x] Database migration applied to production, confirmed live.
|
||||
|
||||
Still needed, in order:
|
||||
1. **Create a Stripe account** (free). Stay in **test mode** first (toggle
|
||||
top of the Stripe dashboard) — nothing below moves real money until
|
||||
step 5.
|
||||
2. **Copy the test API keys** — *Entwicklerbereich → API-Schlüssel*:
|
||||
`sk_test_...` and `pk_test_...`.
|
||||
3. **Create a webhook endpoint** — *Entwicklerbereich → Webhooks → Endpoint
|
||||
hinzufügen*, URL `https://einfach-produktiv.mk360.de/api/webhooks/stripe`,
|
||||
events: at minimum `payment_intent.succeeded` and
|
||||
`payment_intent.payment_failed`. Copy the signing secret, `whsec_...`.
|
||||
4. **Set these three in Coolify** (`einfach-produktiv` app → Environment
|
||||
Variables):
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` |
|
||||
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_test_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` |
|
||||
|
||||
The moment `STRIPE_SECRET_KEY` is set, `PAYMENT_TEST_MODE` switches off
|
||||
automatically (see above) — the real Payment Element replaces the mock
|
||||
buttons. Still 100% safe: Stripe's own test mode only accepts test card
|
||||
numbers (e.g. `4242 4242 4242 4242`), no real charge is possible.
|
||||
5. **Set the same `sk_test_...` in the backend** too —
|
||||
`/home/marco/dev/docker/.env`'s `STRIPE_SECRET_KEY` (used only for
|
||||
`stripeRefund.ts`, Storno/Gutschrift refunds) — then
|
||||
`cd ~/dev/docker && docker compose build payload && docker compose up -d payload`
|
||||
to pick it up.
|
||||
6. **Redeploy the frontend** so it picks up the new Coolify env vars —
|
||||
either wait for the next git push (Coolify redeploys on push) or
|
||||
trigger one directly: `curl -X POST https://coolify.mk360.de/deploy/einfach-produktiv`.
|
||||
7. **Test end to end**: place a real order with a Stripe test card,
|
||||
confirm the order flips to `received`, invoice/confirmation email
|
||||
arrive, product images show, `Orders.paymentStatus` reads `paid`. Try a
|
||||
declined test card too (e.g. `4000 0000 0000 0002`) and confirm the
|
||||
order shows "Zahlung fehlgeschlagen" in the admin, not in the
|
||||
customer's own order history.
|
||||
8. **Trigger a Storno on a paid test order** in the admin, confirm the
|
||||
refund job actually calls Stripe (check the PaymentIntent in the Stripe
|
||||
dashboard) and `Orders.refundStatus` updates.
|
||||
9. **Go live**: only once ready for real charges — verify the Stripe
|
||||
account for live payments (business details), switch the dashboard to
|
||||
**live mode**, repeat steps 2–6 with the live-mode keys (`sk_live_...`/
|
||||
`pk_live_...`, a *new* webhook endpoint registered in live mode → new
|
||||
`whsec_...`) — these replace the test values in both Coolify and
|
||||
`docker/.env`, not additional variables.
|
||||
|
||||
### VAT display
|
||||
|
||||
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
|
||||
@@ -444,6 +511,24 @@ catalog, since an order only ever snapshots a numeric product id).
|
||||
touched spots and why some read a live setting and others a persisted
|
||||
per-order snapshot.
|
||||
|
||||
**Vorkasse instruction in the confirmation email (2026-07-25)** — the
|
||||
invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own
|
||||
`unpaidNoticeText`), but a customer often only glances at the email body
|
||||
itself, not the attached PDF. `OrderConfirmationData` gained a required
|
||||
`isManualPayment: boolean` field — **explicitly set by each caller**
|
||||
(checkout route's manual branch: `true`; the Stripe webhook path:
|
||||
always `false`, since only a *paid* Stripe order ever reaches that send at
|
||||
all), deliberately **not** derived from `paymentMethodTitle` inside
|
||||
`emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte",
|
||||
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
|
||||
payment-methods rename already broke once this session (see
|
||||
`isPaidImmediately()` in the invoicing package). When `isManualPayment` is
|
||||
true, `vorkasseNotice()` renders a full-width block (own row, `margin-top:
|
||||
20px` — not squeezed into the Gesamtsumme table) naming the bank details
|
||||
(`CompanySettings.bankName`/`iban`/`bic`, now also exposed on the
|
||||
frontend's own `CompanySettings` type — previously only `iban`/`bic` were)
|
||||
and the "processed within 1–2 business days after payment received" note.
|
||||
|
||||
### Checkout state persistence
|
||||
|
||||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||||
@@ -1081,12 +1166,44 @@ unsynced.
|
||||
heading, thin brand divider). No query params to read — Brevo's
|
||||
redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
|
||||
which polls actual payment status.
|
||||
- **Already-subscribed detection (2026-07-25)** — `doubleOptinConfirmation`
|
||||
itself gives no way to tell a brand-new signup apart from an
|
||||
already-confirmed contact re-submitting the form (verified directly:
|
||||
calling it twice for the same confirmed contact returns the identical
|
||||
`201` both times, just silently resends the confirmation mail). So
|
||||
`upsertNewsletterContact()` checks first via `GET /v3/contacts/{email}`
|
||||
— a contact's `listIds` on Brevo is only populated once double opt-in
|
||||
actually confirms, never for a merely-requested one, so its presence is
|
||||
a reliable signal. If already subscribed: skips the resend entirely and
|
||||
returns `{ ok: true, alreadySubscribed: true }` instead. The check fails
|
||||
open (any error → proceed to the normal signup flow) — it's a UX
|
||||
nicety, never a reason to block a real signup. **Routed through the
|
||||
existing error state, not a success variant** — per explicit feedback,
|
||||
swapping the whole form out for a bare message (the real-success
|
||||
treatment) felt wrong for "you're already signed up, nothing to do"; the
|
||||
form stays visible with a small red note below it instead, exactly like
|
||||
every other inline validation error (`useNewsletterSignup.ts` sets
|
||||
`status: "error"`, `error: "Diese E-Mail-Adresse ist schon für unseren
|
||||
Newsletter angemeldet."` — no new UI needed in any of the 4 forms, they
|
||||
already render `{status === "error" && <p className="text-red-600
|
||||
...">{error}</p>}`).
|
||||
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
|
||||
state + on-blur validation + refocus-on-invalid-submit behind all four
|
||||
forms (same "state of the art, simple" input-quality bar as checkout's
|
||||
own fields). Each form keeps its own markup/visual style (`Newsletter`'s
|
||||
panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
|
||||
the logic is shared, not a one-size-fits-all component.
|
||||
the logic is shared, not a one-size-fits-all component. The real-success
|
||||
message text also now lives here (`successMessage`) instead of
|
||||
hardcoded 4 times per form.
|
||||
- **A misconfigured `BREVO_LIST_ID` in Coolify (`2` instead of `5`) broke
|
||||
every newsletter signup in production for a stretch of time** —
|
||||
discovered and fixed 2026-07-25 while testing the already-subscribed
|
||||
feature above. The generic customer-facing error message ("Anmeldung ist
|
||||
fehlgeschlagen...") gave no hint why; `upsertNewsletterContact()`'s real
|
||||
`reason` was silently discarded by `/api/newsletter/subscribe/route.ts`
|
||||
before this, now `console.error`'d server-side (message still stays
|
||||
generic to the customer — never leak Brevo's internal error text, just
|
||||
no longer *undiagnosable*).
|
||||
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
|
||||
the single plain-email-format check shared by every newsletter form
|
||||
*and* checkout's own email field (previously duplicated between
|
||||
@@ -1110,6 +1227,21 @@ unsynced.
|
||||
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
|
||||
signup silently no-ops without it) and optionally
|
||||
`BREVO_DOI_REDIRECT_URL`.
|
||||
- **The already-subscribed error now clears on the next interaction**
|
||||
(2026-07-25), matching standard form-validation behavior instead of
|
||||
sitting there until the next submit — `useNewsletterSignup.ts`'s
|
||||
`handleEmailChange`/new `handleConsentChange` both call a shared
|
||||
`clearSubmitError()` that resets `status`/`error` back to idle. This
|
||||
replaced the hook's previously-exposed raw `setConsent` with
|
||||
`handleConsentChange` in its return value — all 4 consuming forms
|
||||
updated to match.
|
||||
- **`NewsletterModal.tsx`'s photo no longer resizes when the error
|
||||
appears** (2026-07-25) — the modal's left photo stretches
|
||||
(`items-stretch`, `md:aspect-auto`) to match the right column's height,
|
||||
so the emailError/already-subscribed messages growing that column used
|
||||
to visibly grow the photo along with it. Both messages are now always
|
||||
rendered with a reserved `min-h-[1.05rem]` instead of conditionally
|
||||
mounted, so toggling them no longer changes the column's height at all.
|
||||
|
||||
## Orders & customer accounts
|
||||
|
||||
@@ -1172,6 +1304,24 @@ ref isn't attached to anything yet at that exact synchronous point.
|
||||
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
|
||||
Payload admin for the shipping states — no shipping-carrier API
|
||||
integration.
|
||||
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
|
||||
here** — that's a `pending_payment` order whose payment failed or timed
|
||||
out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
|
||||
a real Storno (which is always of an already-`received`, already-
|
||||
invoiced order, so it always has an `invoiceNumber`). From the
|
||||
customer's point of view a payment that never went through was never
|
||||
really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
|
||||
(`app/lib/customerAuth.ts`) filter these out by default — the row still
|
||||
exists in Payload for admin/audit purposes (shown there as "Zahlung
|
||||
fehlgeschlagen", see the backend's own README), just not surfaced to
|
||||
the customer. `getCustomerOrderDetail` takes this as an **optional**
|
||||
4th param, defaulting `false` — `/api/checkout/status/route.ts`'s
|
||||
post-payment polling deliberately calls it unfiltered, since that flow
|
||||
needs to keep seeing exactly this order (to show "Zahlung
|
||||
fehlgeschlagen, bitte erneut versuchen") for the one case this filter
|
||||
would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
|
||||
opts out (`false`) — a legal completeness export can't silently drop
|
||||
rows.
|
||||
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
|
||||
header itself — not duplicated inside the mobile fullscreen menu, see
|
||||
"Mobile navigation" below) is the only *always*-reachable way into
|
||||
@@ -1315,13 +1465,33 @@ links to `/konto/passwort-vergessen`.
|
||||
|
||||
### Email templates & Live Preview
|
||||
|
||||
All 6 transactional emails (order confirmation, password reset, and the 4
|
||||
status-change types below) read their subject/heading/body/footer wording
|
||||
from Payload's `email-templates` collection — editable in the admin
|
||||
without a deploy, with a Live Preview button using the exact same
|
||||
mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
|
||||
`@payloadcms/live-preview-react`, already a dependency here for
|
||||
`LivePostContent.tsx`).
|
||||
All 9 transactional emails (order confirmation, password reset, and the 7
|
||||
status-change types below — the original 4 plus `order-tracking-added`/
|
||||
`order-tracking-corrected`/`order-delivered`, added 2026-07-25) read their
|
||||
subject/heading/body/footer wording from Payload's `email-templates`
|
||||
collection — editable in the admin without a deploy, with a Live Preview
|
||||
button using the exact same mechanism as Posts/LegalPages/Testimonials
|
||||
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
|
||||
dependency here for `LivePostContent.tsx`).
|
||||
|
||||
**Live Preview only reliably updates when popped out into its own
|
||||
browser tab/window, not in the embedded admin panel.** Root cause: the
|
||||
preview goes through `buildPreviewUrl()` on the Payload side, which hits
|
||||
`/api/preview` on this frontend's own origin to enable Next.js Draft
|
||||
Mode via a cookie — but admin (`payload.mk360.de`) and frontend
|
||||
(`einfach-produktiv.mk360.de`) are different origins, so from inside the
|
||||
admin's embedded `<iframe>` that's a cross-site/third-party context.
|
||||
Modern browsers (Safari by default, Chrome/Firefox increasingly)
|
||||
block or partition cookies set inside a cross-site iframe regardless of
|
||||
which domain actually issued them, so the Draft Mode cookie doesn't
|
||||
reliably persist there. Once popped into its own window it's a top-level
|
||||
navigation, not a third-party context, so the cookie sets normally and
|
||||
everything works. Affects every Live-Preview-enabled collection in this
|
||||
system (Posts/LegalPages/Testimonials too), not just email templates —
|
||||
a structural consequence of running admin and frontend on separate
|
||||
domains, not something fixable at the collection-config level. A real
|
||||
fix would mean serving both under one domain (reverse proxy path) rather
|
||||
than two subdomains.
|
||||
|
||||
- **`app/lib/emailTemplates.ts`** — pure string-building functions
|
||||
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
|
||||
@@ -1346,8 +1516,16 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
|
||||
- The *real* send always reads the **published** template
|
||||
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
|
||||
Preview edit never affects a live customer email until actually saved.
|
||||
- **`active` toggle (2026-07-25)** — each row now has an `active`
|
||||
checkbox (backend `EmailTemplates.ts`); off suppresses the send
|
||||
entirely, checked here for `order-confirmation`
|
||||
(`orderEmail.ts`'s `sendOrderConfirmationEmail()`) before the hardcoded
|
||||
default-wording fallback ever applies. See the backend repo's own
|
||||
README ("Email templates: active/inactive toggle") for the full
|
||||
picture, including the `password-reset` exception (Payload's core
|
||||
`forgotPassword` operation has no hook to actually cancel that send).
|
||||
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
|
||||
defaults for all 6 rows — deliberately on-brand and a little playful
|
||||
defaults for all 9 rows — deliberately on-brand and a little playful
|
||||
("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles
|
||||
klar." / "Alles erledigt.", not generic transactional-email
|
||||
boilerplate), matching this site's voice elsewhere (see e.g. the
|
||||
@@ -1639,6 +1817,161 @@ breakpoint no longer needed to be as conservative as originally set:
|
||||
768px. Both pushed from `md:flex-row` to `lg:flex-row` — stacked through
|
||||
the whole Tablet range, side by side again once there's real room.
|
||||
|
||||
## Structured data (schema.org, 2026-07-25)
|
||||
|
||||
JSON-LD (`<script type="application/ld+json">`) on the pages Google
|
||||
actually gives rich results for — built via pure functions in
|
||||
`app/lib/structuredData.ts`, no new Payload fields needed, everything
|
||||
derived from data that already exists.
|
||||
|
||||
- **`Organization`** — rendered once, site-wide, in `app/layout.tsx` (via
|
||||
`getCompanySettings()`, the same established pattern `/impressum`
|
||||
already uses for a public page needing seller data server-side). Only
|
||||
non-sensitive fields make it into the schema (name, address, email,
|
||||
`vatID`) — `iban`/`bic` never do, even though `getCompanySettings()`
|
||||
itself returns them. Has a stable `@id`
|
||||
(`https://einfach-produktiv.mk360.de/#organization`) that `Product`/
|
||||
`BlogPosting` schemas elsewhere link back to via `{ "@id": ... }`
|
||||
instead of repeating the full object on every page (schema.org's own
|
||||
recommended pattern for a single canonical entity). Falls back to a
|
||||
minimal `{name, url}`-only Organization if `getCompanySettings()`
|
||||
can't reach the backend, rather than emitting nothing.
|
||||
- **`Product`** — only on `/todo-cards`, the one page with its own
|
||||
dedicated URL for a single, purchasable product (`getProductBySlug`).
|
||||
Deliberately not added to `/shop`'s grid — most products there have no
|
||||
individual detail page to point a `Product`'s `url` at, and Google's
|
||||
own guidance is that Product markup belongs on the page where that
|
||||
product can actually be viewed/bought, not a generic listing.
|
||||
`aggregateRating` is omitted — no reviews/ratings system exists yet
|
||||
(see the SOTA-gaps discussion this same session); add it once real
|
||||
reviews exist, don't fake it before then.
|
||||
- **`BlogPosting`** — on every `/blog/[slug]` page (`buildArticleSchema`).
|
||||
`author` is hardcoded `{ "@type": "Person", name: "Björn" }`, matching
|
||||
the page's own hardcoded author-bio block — this is a single-author
|
||||
blog with no `author` field on `Posts.ts` to read from instead.
|
||||
|
||||
Verified locally by curling each page and grepping the rendered
|
||||
`application/ld+json` script for the expected `@type` — not run through
|
||||
Google's Rich Results Test (no live Stripe-style external validation
|
||||
step for this), so worth a manual check there once deployed.
|
||||
|
||||
## Structural breakpoint moved to 640px (2026-07-29)
|
||||
|
||||
The Tablet layout (768-1023px) was still not landing right after the
|
||||
2026-07-24 fixes above: real tablets (768px+) hit the structural `md:`
|
||||
grid switch at the exact viewport width where the fluid `clamp()` tokens
|
||||
(`app/lib/fluid.ts`) were already at their smallest (floor) value, leaving
|
||||
no room to shrink — cramped images/text in 2-column sections. Fixed by
|
||||
moving both the fluid floor and the structural switch down together, from
|
||||
768px to Tailwind's `sm:` (640px):
|
||||
|
||||
- **`app/lib/fluid.ts`**: `MIN_VW` 768 → 640. All `clamp()` values in
|
||||
`app/globals.css`'s `@theme`/`:root` blocks are hand-authored (no build
|
||||
step calls `fluid()`), so they were individually regenerated for the new
|
||||
640→1440 range, not just re-anchored by editing the constant.
|
||||
- **Hero/About/Newsletter/Footer/Tools** — the `md:`+`lg:` two-tier
|
||||
patchwork from the 2026-07-24 fixes above collapsed back onto a single
|
||||
`sm:` structural line (grid/flex switches now happen at 640px, aligned
|
||||
with the new fluid floor). Content-sizing exceptions that still don't
|
||||
fit at 640px (Hero/Tools' fixed-size CTA/text/icon swaps, Newsletter's
|
||||
input+button row, About's inner quote/bio row) stay gated behind `lg:`,
|
||||
documented in each file as a deliberate exception, not a leftover.
|
||||
- Every other component with a plain `md:` structural switch (TrustRow,
|
||||
Blog, TestimonialsGrid, ProductSpotlight, ProductGrid, RelatedProducts,
|
||||
NewsletterModal, `/blog`, `/not-found`, both `HowItWorks` components)
|
||||
renamed mechanically to `sm:`.
|
||||
- Components with a genuine fixed-width constraint (Cart/Checkout's
|
||||
two-column split, `SectionTOC.tsx` + the 5 legal pages, the newsletter/
|
||||
todo-cards hero/benefits sections, `/challenge`) stay on `lg:` — the
|
||||
math still doesn't fit at 640px either, only their code comments'
|
||||
"768px floor" language was updated.
|
||||
- **`Navbar.tsx`** is a deliberate, documented exception to the whole
|
||||
migration — its `md:`/`lg:` 3-tier scheme (hamburger-only, hamburger+
|
||||
inline-CTAs, full-inline) is about horizontal nav-link overflow, a
|
||||
different failure mode than the vertical grid/flex reflows everywhere
|
||||
else, with no fluid token that would make a full inline nav fit at
|
||||
640px.
|
||||
|
||||
No browser/screenshot tool is available in this environment, so this
|
||||
migration is verified by typecheck/build/lint/`test:unit` only — actual
|
||||
visual confirmation at 640/768/1024/1440px is still owed by the user.
|
||||
**Update, same day:** a real headless-browser check turned out to be
|
||||
possible after all — `npx playwright install chromium` in a scratch
|
||||
directory, then a plain Node script driving `playwright`'s `chromium.launch()`
|
||||
against the *live* site at a specific viewport, reading
|
||||
`document.documentElement.scrollWidth` vs. `window.innerWidth` (a real
|
||||
horizontal overflow, not just "looks cramped") and `getBoundingClientRect()`
|
||||
on specific elements. This is a real, repeatable verification tool for
|
||||
future Tablet-layout work in this environment — see the two regressions
|
||||
below, both caught this way, not by guessing from a screenshot.
|
||||
|
||||
Same session: **`Hero.tsx`'s heading** shortened from two sentences
|
||||
("Verliere dich nicht im Mehr. Finde heraus, was wichtig ist.") to one
|
||||
("Finde heraus, was wichtig ist") with no trailing period, since the
|
||||
brand's orange dot (`PopIn`) already renders one, animated, right after
|
||||
it. The homepage's **"3x3-System" werkzeuge-card** description was also
|
||||
lengthened to match the other two cards' length (see the Payload repo's
|
||||
own README for the `fix-3x3-copy-length.ts` one-off that changed the
|
||||
live content).
|
||||
|
||||
**Correction, 2026-07-29 (later the same day): `Footer.tsx`'s legal-links
|
||||
row and `TrustRow.tsx` were wrongly reclassified as plain `sm:` renames
|
||||
above.** Both had originally used `lg:flex-row` for a genuine
|
||||
fixed-content-width reason (logo + handle + 5 `whitespace-nowrap` legal
|
||||
links; multiple `whitespace-nowrap` title+description trust badges) — not
|
||||
the "grid arrives before the fluid floor" bug the `sm:` migration
|
||||
targets. Renaming them to `sm:` during the mechanical mass-rename pass
|
||||
caused a **real horizontal page overflow** in the 640-1023px band on
|
||||
every page that renders them (Footer: every page; TrustRow: shop, cart,
|
||||
checkout, order-confirmation, `/agb`, `/widerruf`, `/not-found`) —
|
||||
confirmed via the Playwright method above: `scrollWidth` exceeded the
|
||||
viewport by ~80-100px at 666-768px viewports. Both reverted to `lg:`,
|
||||
matching their original (correct) behavior. **Lesson: "this looks
|
||||
like the same md:→sm: pattern as everything else" isn't sufficient
|
||||
justification on its own — check whether a component was *already* on
|
||||
`lg:` for a real fixed-width-content reason (not just inherited from an
|
||||
earlier, unrelated Tablet fix) before reclassifying it as a mechanical
|
||||
rename.** See the `figma-to-nextjs` skill's Gotcha 21 for the fuller
|
||||
writeup.
|
||||
|
||||
**Follow-up, same day: `TrustRow.tsx`'s alignment went through three more
|
||||
rounds after the `lg:` revert above, all centered on the same
|
||||
underlying flexbox lesson (skill Gotcha 22).** First, centering its
|
||||
badges when stacked (per feedback) was implemented as plain `items-center`
|
||||
directly on the flex-col container — but `align-items:center` centers
|
||||
*each item independently* within the container's own width, so the 3
|
||||
badges (different title/description lengths) ended up with 3 different
|
||||
left edges instead of lining up with each other (confirmed via a real
|
||||
screenshot: icons at different x-positions). Fixed by wrapping all
|
||||
badges in one inner shrink-to-fit group with `items-start` internally
|
||||
(so they share one left edge), then centering/left-aligning that single
|
||||
group as a unit. **Then tried replacing that whole approach** with `flex
|
||||
flex-wrap justify-center` directly on the badges (no inner wrapper, no
|
||||
breakpoint at all): flexbox does correctly center each *wrapped line* as
|
||||
a group (no misalignment bug), and it removes the overflow risk without
|
||||
a `lg:`-only breakpoint, so badges flowed as many-per-row as fit (1 per
|
||||
row on a narrow phone, 2-with-the-3rd-below once there's room, all 3 in
|
||||
a row at Desktop). **Reverted the same day** once actually screenshotted:
|
||||
with exactly 3 badges, the 2-per-row-plus-1-wrapped layout put the lone
|
||||
third badge off to one side, aligned under neither badge above it —
|
||||
read as "durcheinander"/disorganized rather than clean. Back to the
|
||||
inner-shrink-wrap single-column approach (strict 1-per-row below `lg:`,
|
||||
which stays visually tidy regardless of badge count). **Lesson:**
|
||||
`flex-wrap` is a real, correct fix for the alignment/overflow problem,
|
||||
but an odd item count wrapping into a partial last row is its own
|
||||
separate aesthetic risk, worth a real screenshot at the exact width
|
||||
where the wrap count changes before trusting it as final. `Footer.tsx`'s
|
||||
legal-links row was left on its plain `lg:` fix throughout — never
|
||||
asked to change, and its content (nowrap links, not title/description
|
||||
pairs) doesn't have the same "which items share a line" concern.
|
||||
|
||||
**Also same day: `NewsletterModal.tsx`'s close button** was `position:
|
||||
absolute` inside the dialog's own `overflow-y-auto` scroll container, so
|
||||
scrolling the modal's content scrolled the close button away with it.
|
||||
Switched to `position: sticky` (`sticky top-6 ml-auto mr-6 -mb-6`) so it
|
||||
stays pinned to the top-right corner of the visible (scrolled) area —
|
||||
see the skill's Gotcha 23.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||||
@@ -1666,6 +1999,215 @@ field-lock security logic is still tested in the **Payload backend's**
|
||||
own `test:unit` — see that repo's README's own "Tests" section, since
|
||||
that's where that logic actually lives.
|
||||
|
||||
## 2026-07-30 changes
|
||||
|
||||
**Blog posts can have multiple categories.** `BlogPost.categories` is now
|
||||
`string[]` (was a single `category: string`), joined with `", "` wherever
|
||||
a single category used to render (`/blog`, `/blog/[slug]`,
|
||||
`LivePostContent.tsx`, `components/Blog.tsx`). Backend field is
|
||||
`Posts.categories`, a `hasMany` relationship — see the Payload backend's
|
||||
own README.
|
||||
|
||||
**Checkout's "Abweichende Lieferadresse" gained Firma + Kontakt-E-Mail/
|
||||
Telefon** (all optional) — handed to the shipping carrier, not used for
|
||||
any customer communication (that stays the account email above). Backed
|
||||
by new `Orders.shipping*` fields with no shipping-side `vatId` (billing-
|
||||
only concept, deliberately not mirrored).
|
||||
|
||||
**The customer profile (`/konto/profil`) can now store its own shipping
|
||||
address**, mirroring the checkout's own "Abweichende Lieferadresse"
|
||||
section field-for-field (same checkbox pattern, same fields incl. the new
|
||||
Firma/Kontakt fields above) — prefills the checkout section for a
|
||||
returning customer instead of always starting blank. Backed by
|
||||
`Customers`' new "Lieferadresse" tab in the Payload backend.
|
||||
|
||||
**Products can have an optional SKU** even without variants
|
||||
(`Products.sku` — variants already had their own). Snapshotted onto each
|
||||
order item at checkout (`app/api/checkout/route.ts` — variant SKU takes
|
||||
precedence over the product's own) and shown as "Art.-Nr." on the invoice
|
||||
PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`.
|
||||
|
||||
**`@einfach-produktiv/invoicing` bumped to 0.2.7** for the SKU +
|
||||
shipping-contact rendering above — re-run `npm install
|
||||
@einfach-produktiv/invoicing` after pulling if your local `node_modules`
|
||||
predates this.
|
||||
|
||||
Full plan for an n8n-driven blog-post content-automation pipeline (not yet
|
||||
built) lives in the Payload backend repo's `docs/blog-automation-plan.md`,
|
||||
not duplicated here since it changes independently of this frontend.
|
||||
|
||||
**Switching an unpaid Überweisung order to Stripe.** New "Zahlungsart
|
||||
ändern" button on `/konto/bestellungen/[orderNumber]`, shown when an order
|
||||
is still `paymentProvider: 'manual'`, `status: 'received'`,
|
||||
`paymentStatus` not yet `'paid'`, and an active Stripe payment method
|
||||
exists. `POST /api/account/orders/[orderNumber]/switch-to-stripe` creates
|
||||
a real PaymentIntent (`app/lib/payments`, same code checkout itself uses)
|
||||
and hands it to the backend's `switchPaymentToStripeEndpoint`. Reuses
|
||||
`PaymentStep` (checkout's own Stripe collection UI) and
|
||||
`/checkout/verarbeitung`'s polling page — both now take a
|
||||
`returnContext`/`context` prop/param so payment confirmation lands back
|
||||
on the order page instead of clearing the cart and redirecting to
|
||||
`/bestellbestaetigung`, which would be wrong for an order that was
|
||||
already placed and confirmed.
|
||||
|
||||
Also: a payment status badge (Offen/Bezahlt/…) next to the existing
|
||||
fulfillment status badge, on both the order list (`/konto/bestellungen`)
|
||||
and the detail page — "Offen" renders in the same red/subtle-background
|
||||
style as a failed status, not the neutral grey the fulfillment status
|
||||
badge uses for its own "in progress" states, since an unpaid order is
|
||||
worth visually flagging. A one-line mention of the switch option was
|
||||
added to the Vorkasse unpaid notice in the order-confirmation email,
|
||||
shown only when `hasOnlinePaymentOption` is true (an active Stripe method
|
||||
actually exists).
|
||||
|
||||
**Fixed the email-preview page** (`/email-preview/[type]`) — it rendered
|
||||
the email HTML (a full `<body>...</body>` fragment) via
|
||||
`dangerouslySetInnerHTML` into a plain `<div>`, nesting it inside the
|
||||
page's own already-existing `<body>` — invalid HTML the browser silently
|
||||
mangled, which is why the preview looked broken (wrong background/
|
||||
padding/font). Now renders via `<iframe srcDoc>`, giving the email its
|
||||
own real document context. Also added a second sample fixture
|
||||
(`SAMPLE_ORDER_MANUAL`) with a toggle, since `SAMPLE_ORDER` alone always
|
||||
had `isManualPayment: false` — the Vorkasse/Überweisung branch (and its
|
||||
new switch-option mention) was never previewable in the admin at all
|
||||
before this.
|
||||
|
||||
**Follow-up same day: switch-to-stripe eligibility narrowed.**
|
||||
`canSwitchPayment` (page.tsx) and the `switch-to-stripe` route's own
|
||||
re-check both changed from `paymentStatus !== "paid"` to an explicit
|
||||
allowlist (`"pending"` or `"not_applicable"`) — see the Payload backend's
|
||||
own README for why the blocklist form was too permissive. No visible
|
||||
behavior change for the normal case, just closes an edge case
|
||||
(`"failed"`/`"refunded"`/`"partially_refunded"` on a manual order — not
|
||||
realistic today, but not meaningful states to switch *from* either).
|
||||
|
||||
**Two more same-day fixes:**
|
||||
- `customerOrderAction()` now only offers "Rücksendung anfragen" once an
|
||||
order is actually `delivered`, not already at `shipped` — a (partial)
|
||||
return before the package has even arrived doesn't make sense yet. The
|
||||
backend's own `CUSTOMER_ALLOWED_TRANSITIONS` still technically permits
|
||||
`shipped` → `return_requested` too (an extensive existing test suite is
|
||||
built around that as its base fixture, see the Payload backend's
|
||||
README) — this only narrows what the UI itself offers, a stricter
|
||||
subset of what the backend already allows, not a security boundary
|
||||
being loosened.
|
||||
- `/impressum`'s "Angaben zum Anbieter" email (`AnbieterAngaben.tsx`) was
|
||||
plain text, not a `mailto:` link — the only email address on the whole
|
||||
site that wasn't clickable, since this one section is rendered
|
||||
straight from `company-settings` fields rather than through the CMS
|
||||
richText renderer (which already links emails/URLs correctly).
|
||||
|
||||
**New `sendPaymentSwitchedEmail()`** (`lib/orderEmail.ts`) — sent instead
|
||||
of `sendOrderConfirmationEmail()` when `confirmPaymentEmail.ts` sees
|
||||
`order.paymentSwitchedAt` set (a payment confirmation that came from
|
||||
switching an existing Überweisung order to Stripe, not a fresh gated
|
||||
checkout — see the Payload backend's own README on
|
||||
`switchPaymentToStripeEndpoint`). Same shape as the order-status emails
|
||||
(icon + admin-editable heading/bodyText + "Bestellung ansehen" button via
|
||||
`renderOrderStatusHtml`), no item table, but **does** re-attach a freshly
|
||||
generated invoice PDF — same `invoiceNumber` as always (never re-issued
|
||||
for a switch), but `paymentMethodTitle` now reflects the actually-
|
||||
confirmed instrument, which changes `@einfach-produktiv/invoicing`'s own
|
||||
`isPaidImmediately()` check from the Vorkasse notice to "✓ Bereits
|
||||
beglichen", so the customer's copy of "their invoice" should reflect
|
||||
that. `buildInvoiceAttachment()` was extracted out of
|
||||
`sendOrderConfirmationEmail()` so both senders share the exact same PDF-
|
||||
generation call instead of duplicating that ~40-line object literal.
|
||||
Default fallback copy (used whenever no admin template is saved/active)
|
||||
matches the existing brand voice (`sendOrderConfirmationEmail`'s own
|
||||
"Bestellt! Deine Ruhe kann kommen 🎉" fallback) rather than a flat
|
||||
system-notice tone: *"Erledigt! Deine Zahlung ist da 🎉" / "Deine Zahlung
|
||||
ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch
|
||||
weiter, du musst dich um nichts mehr kümmern."* New
|
||||
`payment-method-switched` `EmailTemplateType`, added to
|
||||
`/email-preview`'s valid types too.
|
||||
|
||||
**Bumped `@einfach-produktiv/invoicing` to 0.2.8** — fixes a font-path
|
||||
bug that broke every invoice/correction-invoice PDF render *inside the
|
||||
Payload backend* specifically. See that package's own README for the
|
||||
Turbopack asset-hashing root cause. (This turned out to be an
|
||||
incomplete picture — see "Cart bugs" below: 0.2.8 itself broke this
|
||||
frontend's own client bundle, just not in a way anyone noticed yet at
|
||||
the time this was written.)
|
||||
|
||||
## Order list mobile grid (2026-07-30)
|
||||
|
||||
`/konto/bestellungen`'s order cards used plain `flex flex-wrap` for their
|
||||
6 fields (Bestellnummer, Datum, Artikel, Status, Zahlungsstatus,
|
||||
Gesamtbetrag), which sized each field to its own content width — so e.g.
|
||||
"Status"/"Zahlungsstatus" started at a different x position from one
|
||||
order card to the next depending on how wide that particular badge label
|
||||
happened to be. Replaced with a single CSS Grid per card
|
||||
(`grid grid-cols-2 min-[500px]:grid-cols-4`), since a grid's column
|
||||
tracks are fixed by the container width (identical on every card
|
||||
regardless of content) rather than by content width.
|
||||
|
||||
Went through several rounds of visual feedback:
|
||||
- Below 500px there just isn't room for 4 tracks without badge labels
|
||||
like "In Bearbeitung" overflowing their column, so it drops to 2
|
||||
columns there. The 6 fields in DOM order already tile cleanly into 3
|
||||
rows of 2 (Bestellnummer/Datum, Artikel/Status,
|
||||
Zahlungsstatus/Gesamtbetrag) without any reordering.
|
||||
- Bestellnummer/Datum deliberately stay at `col-span-1` in both the
|
||||
2-column and 4-column layouts, side by side even below 500px — a
|
||||
brief `col-span-2` experiment (own full-width row each, to avoid the
|
||||
order number wrapping on very narrow phones) was reverted since it
|
||||
read worse than the occasional wrap.
|
||||
- Artikel forces `col-start-1` so it wraps to its own row in the
|
||||
4-column layout instead of Grid's auto-placement flowing it into the 2
|
||||
cells left empty in row 1 after Bestellnummer/Datum (a harmless no-op
|
||||
in the 2-column layout, where it's already first in row 2 anyway).
|
||||
- The breakpoint moved twice during tuning — first sm (640px) → md
|
||||
(768px) → lg (1024px) while the intent was still "keep 2 columns as
|
||||
long as possible," then flipped entirely to "stay at 4 columns as long
|
||||
as possible" once the actual goal was clarified, then settled on a
|
||||
content-driven `min-[500px]` (started at 400px, moved to 500px) once a
|
||||
real device screenshot showed 4 columns overflowing on a narrow phone.
|
||||
- `OrderStatusBadge`/`PaymentStatusBadge` gained `self-start` — as flex
|
||||
children of a `flex flex-col` parent (default `align-items: stretch`),
|
||||
the badge `<span>` was stretching to the parent's full width despite
|
||||
being `inline-flex` itself (flex items are blockified, so `stretch`
|
||||
still applies); `self-start` keeps the badge background only as wide
|
||||
as its label.
|
||||
|
||||
## Cart bugs (2026-07-30)
|
||||
|
||||
Two independent bugs, both reported together as "cart count keeps going up
|
||||
after every logout/login, and `/cart` won't open":
|
||||
|
||||
**1. Quantities doubled on every logout/login cycle.** `LogoutButton.tsx`
|
||||
never cleared the local cart (`ep_cart` in `localStorage`), and
|
||||
`mergeServerCartIntoLocal()` (`lib/cart.ts`, called after every login) adds
|
||||
the server-side cart's quantities into the existing local ones
|
||||
(`existing.qty += qty`) rather than replacing them — correct behavior for
|
||||
folding in genuine guest-cart additions, wrong once `CartSync.tsx` has
|
||||
already mirrored the local cart to the server. Since both sides held the
|
||||
same quantities at logout time, every login added them together, doubling
|
||||
the total each cycle. Fixed by clearing the local cart on logout — the next
|
||||
login's merge then starts from empty (or only genuinely new guest-session
|
||||
items) instead of re-adding already-synced quantities. `readCart()`/
|
||||
`getCart()` also gained an `Array.isArray()` check after `JSON.parse`
|
||||
(previously only guarded against parse *syntax* errors, not the parsed
|
||||
value being some other shape) as a general defensive hardening, though this
|
||||
turned out not to be the `/cart` crash's actual cause — see below.
|
||||
|
||||
**2. `/cart` couldn't open: `@einfach-produktiv/invoicing`'s `fonts.ts`
|
||||
crashed the client bundle.** Unrelated to bug 1 — this frontend's own
|
||||
`CartContent.tsx` imports `computeTaxBreakdown` from
|
||||
`@einfach-produktiv/invoicing`'s barrel `index.ts`, which also re-exports
|
||||
`invoicePdf.tsx`, which imports `fonts.ts`. The same-day v0.2.8 fix in that
|
||||
package (see the entry above) made `fonts.ts` call `fileURLToPath` at
|
||||
module scope unconditionally — fine in Node.js, but `node:url`'s
|
||||
`fileURLToPath` isn't a real function in a browser bundle's polyfilled
|
||||
shim, so **every** client bundle that transitively reached this module
|
||||
crashed on module evaluation (`Uncaught TypeError: fileURLToPath is not a
|
||||
function`), including `/cart`'s entire client-rendered page. Fixed
|
||||
upstream in `@einfach-produktiv/invoicing` 0.2.9 (see that package's own
|
||||
README) by branching on `typeof window === "undefined"` — browser keeps
|
||||
the original `new URL(..., import.meta.url)` idiom, Node.js keeps the
|
||||
`fileURLToPath` resolution. Re-ran `npm install @einfach-produktiv/invoicing`
|
||||
here to pick it up.
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Dockerfile**: 3-stage build (`deps` → `builder` → `runner`) with
|
||||
@@ -1676,6 +2218,133 @@ that's where that logic actually lives.
|
||||
bridge → Coolify API → rebuild + restart. Full mechanics in
|
||||
`~/dev/README.md`.
|
||||
|
||||
## Wishlist, search, filters, feature toggles (2026-07-30 evening)
|
||||
|
||||
A cluster of new features, all gated behind new `CompanySettings`
|
||||
booleans (Payload admin → Company Settings → **Features** tab, all off
|
||||
by default — each feature stays entirely invisible in the frontend
|
||||
until explicitly switched on): `wishlistEnabled`, `searchEnabled`,
|
||||
`shopFilterEnabled`, `blogFilterEnabled`, `orderFilterEnabled`.
|
||||
|
||||
**Wishlist.** Backend `wishlist-items` collection (customer + product +
|
||||
optional variant, unique per combination, customer-scoped access — see
|
||||
the Payload backend's own README). Frontend: `useWishlist.ts` (a
|
||||
fetch-based hook with an optimistic toggle + a `window` event so every
|
||||
`WishlistButton`/the Navbar badge on a page stay in sync — not
|
||||
`localStorage`-backed like the cart, since a wishlist needs a logged-in
|
||||
customer to mean anything), `WishlistButton.tsx` (heart icon, redirects
|
||||
to login with `?redirect=` if logged out), the Navbar's heart icon +
|
||||
count badge (hidden below `sm:` — Account+Cart are the only
|
||||
always-visible icons on true mobile, a 3rd icon there risks the nav-
|
||||
overflow bug class documented in the `figma-to-nextjs` skill), and
|
||||
`/konto/merkliste` (a Client Component grid — `MerklisteGrid.tsx` — so
|
||||
removing an item disappears immediately, unlike a plain Server
|
||||
Component render which wouldn't react to the client-side toggle at all).
|
||||
`Product` gained a `numericId` field (the raw Payload id) alongside its
|
||||
existing slug `id`, since `wishlist-items.product` is a real numeric
|
||||
relationship, unlike the cart/checkout's slug-keyed "commerce id".
|
||||
|
||||
Found and fixed a real race condition in `useWishlist.ts`: the "refetch
|
||||
me" event used to fire immediately after the optimistic local update,
|
||||
before the POST request was even sent — another instance's resulting
|
||||
refetch could beat the actual server write, cache the pre-toggle list,
|
||||
and never get told to refetch again (the Navbar count stayed one
|
||||
behind). The event now only fires after the request settles.
|
||||
|
||||
**Standalone registration.** Until now an account only ever got created
|
||||
inline during checkout. That stopped making sense the moment a
|
||||
wishlist gave accounts a reason to exist independent of a purchase —
|
||||
new `/konto/registrieren` (+ `RegisterForm.tsx`, reusing the existing
|
||||
`/api/account/register` route). `LoginForm.tsx` now also honors a
|
||||
`?redirect=` param (previously ignored, always landing on
|
||||
`/konto/bestellungen`) and links to the new page instead of implying
|
||||
"only via checkout".
|
||||
|
||||
**Instant search.** New `/api/search` route — plain Payload
|
||||
`where[...][contains]` queries (Postgres `ILIKE`) against Products +
|
||||
Posts, not a real search index (Meilisearch/Algolia); fine at this
|
||||
catalog's current size, worth upgrading once it grows past ~20 products
|
||||
(see the e-commerce SOTA gaps memory note). `SearchOverlay.tsx`
|
||||
(debounced 250ms, grouped results) + a Navbar search icon (same
|
||||
`hidden sm:` reasoning as the wishlist icon).
|
||||
|
||||
**Filters, everywhere URL-search-param-driven** (shareable/bookmarkable,
|
||||
no client-side-only state):
|
||||
- `/konto/bestellungen` — status/paymentStatus/year, via 3
|
||||
`CustomSelect` dropdowns (`OrderFilters.tsx`). Went through two
|
||||
earlier UI iterations first: a wall of filter chips (too cluttered,
|
||||
too much vertical space) and then plain native `<select>`s (a native
|
||||
select's open options popup can't be styled at all — looked
|
||||
completely off-brand) before landing on `CustomSelect.tsx`.
|
||||
- `/blog` — category toggle chips (`buildCategoryHref()`), plain
|
||||
server-rendered `<Link>`s, no client component needed since the page
|
||||
already fetches every published post in one request.
|
||||
- `/shop` — a price min/max range (`PriceRangeFilter.tsx`) — preset
|
||||
toggle buckets were tried first and reverted ("keine toggle badges").
|
||||
A fuller sidebar-with-drag-slider layout was requested as a follow-up,
|
||||
not yet built as of this writing.
|
||||
|
||||
**`CustomSelect.tsx`** (`app/components/`) — a fully custom-styled
|
||||
dropdown (own trigger + own `role="listbox"` options panel, keyboard
|
||||
nav, click-outside-to-close), built to replace native `<select>`s
|
||||
wherever their un-stylable options popup matters. Also used for
|
||||
checkout's two country pickers now (`includeAllOption={false}` — a
|
||||
country is always genuinely selected, no "clear" state like the
|
||||
filter-dropdown use case; `fullWidth` — matches the surrounding w-full
|
||||
form fields instead of the filter bar's shrink-to-fit sizing).
|
||||
|
||||
**Assorted same-evening bugfixes:**
|
||||
- Blog category filter bar was invisible (wrapped in `<Reveal>`, which
|
||||
sits right at the hero's bottom edge — exactly the
|
||||
`whileInView(margin:"-80px")` trap `Reveal.tsx`'s own comment
|
||||
documents: an element already visible without scrolling can
|
||||
permanently never register as "entered view"). Now a plain `div`.
|
||||
- Then found rendering *behind* the featured-post card instead (that
|
||||
card pulls itself up `-mt-8`/`z-10` to overlap the *hero's* bottom
|
||||
edge — its original design — but the filter bar now sat where that
|
||||
pull landed). Given `relative z-20` to stay above it.
|
||||
- `AddToCartInlineButton`'s `className` prop *replaces* its whole
|
||||
default styling (`?? default`, not a merge) — `/konto/merkliste`
|
||||
passed `className="w-full"` and lost all the button's actual styling
|
||||
as a result. Removed the override (the default is already `w-full`).
|
||||
|
||||
**How to apply if you're reading this cold:** every one of these
|
||||
features requires the matching `CompanySettings` toggle to actually be
|
||||
switched on before it shows up anywhere in the frontend — if a feature
|
||||
"isn't showing," check that first.
|
||||
|
||||
## DHL checkout integrations (2026-07-31)
|
||||
|
||||
Three checkout-facing pieces added, each invisible unless the tenant's
|
||||
backend `dhl-settings` has the matching toggle on (checked indirectly —
|
||||
the calls below 404 gracefully when a tenant hasn't activated a given DHL
|
||||
sub-integration, same "no half-built UI" convention as the feature
|
||||
toggles above). Full backend-side documentation:
|
||||
`docker/payload`'s `src/lib/shipping/README.md`.
|
||||
|
||||
- **`app/lib/shippingDhl.ts`** — thin client for the backend's 2 public
|
||||
DHL endpoints (Postnummer validation, DataFactory address autocomplete).
|
||||
Own copy, no shared package — same convention as `app/lib/tracking.ts`.
|
||||
- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and
|
||||
**`app/api/checkout/autocomplete-address/route.ts`** — Next.js API
|
||||
routes proxying to the backend. Required because `CheckoutContent.tsx`
|
||||
is a Client Component and DHL credentials are tenant-specific (live in
|
||||
Payload) — unlike VIES (`validate-vat/route.ts`), the browser can never
|
||||
call Payload's DHL endpoint directly.
|
||||
- **`app/checkout/components/AddressAutocomplete.tsx`** — wraps the
|
||||
billing and shipping street inputs with a debounced DHL DataFactory
|
||||
suggestion dropdown; selecting a suggestion also fills zip/city.
|
||||
- **Postnummer live validation** — `CheckoutContent.tsx`'s
|
||||
`handlePostNumberBlur` (mirrors `handleVatIdBlur`'s shape/status-state
|
||||
pattern) checks the Packstation Postnummer against DHL on blur, once the
|
||||
existing format check (6–10 digits) already passes.
|
||||
- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows
|
||||
a "Retourenschein herunterladen" link once the backend has generated a
|
||||
DHL return label for that order (`order.dhlReturnLabelMedia`, resolved
|
||||
via the new `getMediaUrlById()` in `app/lib/payload.ts` — the order
|
||||
fetch itself stays `depth=0` for unrelated reasons, see
|
||||
`getCustomerOrderDetail`'s own comment).
|
||||
|
||||
## Related design source
|
||||
|
||||
- `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs
|
||||
|
||||
@@ -10,9 +10,9 @@ export async function GET() {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Lets a logged-in customer move an existing, still-unpaid Überweisung
|
||||
// order onto a Stripe PaymentIntent instead of waiting on their own bank
|
||||
// transfer — see the backend's switchPaymentToStripe.ts for the matching
|
||||
// endpoint and why this needs a dedicated backend route rather than the
|
||||
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
|
||||
// are system fields a customer JWT can never touch).
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Same eligibility the backend endpoint re-checks authoritatively —
|
||||
// checked here too for a friendly error instead of a bare 409. An
|
||||
// explicit allowlist ('pending'/'not_applicable'), not a
|
||||
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
|
||||
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
|
||||
// meaningful states to switch from either.
|
||||
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
|
||||
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only offer this when a real Stripe payment method is actually active
|
||||
// — same "Online-Zahlung" grouping the checkout itself uses, so this
|
||||
// never presents an option that checkout wouldn't currently accept either.
|
||||
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
|
||||
const hasStripeOption = methods.some((m) => m.provider === "stripe");
|
||||
if (!hasStripeOption) {
|
||||
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
|
||||
}
|
||||
|
||||
let intent: { clientSecret: string; providerReference: string };
|
||||
try {
|
||||
intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(order.total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: order.customerEmail,
|
||||
description: `Bestellung ${order.orderNumber}`,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
|
||||
body: JSON.stringify({ providerReference: intent.providerReference }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Best-effort, same as checkout's own call — a failure here doesn't
|
||||
// block the payment itself, only the confirm-payment webhook's metadata
|
||||
// lookup, which the frontend's own webhook route already alerts on.
|
||||
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
clientSecret: intent.clientSecret,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
testMode: isPaymentTestMode,
|
||||
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
|
||||
});
|
||||
}
|
||||
@@ -13,13 +13,36 @@ export async function PATCH(request: Request) {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName,
|
||||
vatId,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName,
|
||||
shippingLastName,
|
||||
shippingCompanyName,
|
||||
shippingDeliveryMethod,
|
||||
shippingStreet,
|
||||
shippingPackstationNumber,
|
||||
shippingPostNumber,
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
} = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
typeof lastName !== "string" ||
|
||||
!lastName ||
|
||||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
|
||||
typeof street !== "string" ||
|
||||
!street ||
|
||||
typeof zip !== "string" ||
|
||||
!zip ||
|
||||
typeof city !== "string" ||
|
||||
@@ -29,12 +52,6 @@ export async function PATCH(request: Request) {
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "address" && !street) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Both independently optional (see Customers.ts's own comment) — only
|
||||
// format-checked when actually provided, same as the backend field itself.
|
||||
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
|
||||
@@ -42,18 +59,56 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Same shape as the checkout's own shipping-address-override validation
|
||||
// (api/checkout/route.ts) — required fields only apply when the toggle
|
||||
// is actually on, since this whole block is optional otherwise.
|
||||
if (hasDifferentShippingAddress) {
|
||||
if (
|
||||
typeof shippingFirstName !== "string" ||
|
||||
!shippingFirstName ||
|
||||
typeof shippingLastName !== "string" ||
|
||||
!shippingLastName ||
|
||||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
|
||||
typeof shippingZip !== "string" ||
|
||||
!shippingZip ||
|
||||
typeof shippingCity !== "string" ||
|
||||
!shippingCity ||
|
||||
typeof shippingCountry !== "string" ||
|
||||
!shippingCountry
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "address" && !shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
deliveryMethod,
|
||||
deliveryMethod: "address",
|
||||
street,
|
||||
packstationNumber,
|
||||
postNumber,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
|
||||
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
|
||||
const items = await getWishlist(session.token, session.customer.id);
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const productId = Number(body?.productId);
|
||||
const variant = typeof body?.variant === "string" ? body.variant : "";
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, productId, variant);
|
||||
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
|
||||
|
||||
// Proxies the checkout's address-autocomplete input through to Payload's
|
||||
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
|
||||
// own route: tenant DHL credentials must never reach the browser.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("query") ?? "";
|
||||
const suggestions = await autocompleteDhlAddress(query);
|
||||
return NextResponse.json({ ok: true, suggestions });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
@@ -54,6 +54,9 @@ type CheckoutBody = {
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
shippingCompanyName?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
newsletterOptIn: boolean;
|
||||
};
|
||||
|
||||
@@ -126,7 +129,7 @@ export async function POST(request: Request) {
|
||||
customer = session.customer;
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
|
||||
}
|
||||
const result = await registerCustomer({
|
||||
firstName: body.firstName,
|
||||
@@ -160,6 +163,7 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
sku: string | null;
|
||||
}[] = [];
|
||||
for (const line of body.cart) {
|
||||
const product = productsBySlug.get(line.id);
|
||||
@@ -168,7 +172,7 @@ export async function POST(request: Request) {
|
||||
// requested variant that no longer exists on this product (removed,
|
||||
// or never existed — a tampered request) fails the whole checkout
|
||||
// rather than silently falling back to the base product/price.
|
||||
let variant: { name: string; priceOverride: number | null } | null = null;
|
||||
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
|
||||
if (line.variant) {
|
||||
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
|
||||
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
|
||||
@@ -195,15 +199,25 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
// Variant sku takes precedence over the product's own — same
|
||||
// "variant overrides product" precedence unitPrice already uses.
|
||||
sku: variant?.sku ?? product.sku ?? null,
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
// Products.noShippingCost — a cart made up entirely of items that opt
|
||||
// out of shipping costs (e.g. purely digital downloads) never gets
|
||||
// charged shipping at all, regardless of the free-shipping threshold.
|
||||
// A single item WITHOUT the flag still triggers normal shipping for the
|
||||
// whole order — this only exempts a product, never the whole cart just
|
||||
// because it contains an exempt item.
|
||||
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
|
||||
|
||||
const shippingMethods = await getShippingMethods();
|
||||
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
|
||||
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
|
||||
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
|
||||
const shippingCost = freeShipping ? 0 : shippingMethod.price;
|
||||
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
|
||||
|
||||
const paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
@@ -338,11 +352,15 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
shippingMethod: shippingMethod.id,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
@@ -422,6 +440,7 @@ export async function POST(request: Request) {
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
@@ -429,6 +448,8 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
|
||||
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
@@ -438,12 +459,17 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
sku: i.sku,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
// Only meaningful for the Vorkasse notice above — whether a
|
||||
// switch to Kreditkarte/PayPal is even worth mentioning right now.
|
||||
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
|
||||
|
||||
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
|
||||
// delivery). Proxied through this Next.js route rather than fetched
|
||||
// directly from the client the way VIES is (see validate-vat/route.ts) —
|
||||
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
|
||||
// public EU endpoint, so the browser must never call Payload's DHL
|
||||
// endpoint (or hold its own copy of tenant credentials) directly.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
|
||||
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
|
||||
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
|
||||
if (!postNumber || !firstName || !lastName) {
|
||||
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getCompanySettings } from "../../lib/payload";
|
||||
|
||||
// react-pdf's default hyphenation would otherwise auto-split long words at
|
||||
// syllable boundaries to fit the line — including inside the seller's own
|
||||
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
|
||||
// "einfach-produk-tiv.com"), which must never be broken mid-word.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
// Generated on-the-fly from live company-settings instead of a static
|
||||
// uploaded PDF (see /widerruf's own download link) — the "An:"
|
||||
// address used to be baked into a hand-crafted PDF and silently went
|
||||
// stale the moment an admin updated the Impressum's Anbieterdaten without
|
||||
// remembering to also re-export/re-upload this file by hand (exactly what
|
||||
// happened 2026-07-29: the address changed in company-settings, the PDF
|
||||
// still had the old one). Same source data as AnbieterAngaben.tsx's
|
||||
// Impressum block, so the two can never drift apart again.
|
||||
const BRAND = "#f6a701";
|
||||
const DARK = "#1b1b1a";
|
||||
const GREY = "#737371";
|
||||
const RULE = "#d1cec4";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 10.5,
|
||||
color: DARK,
|
||||
paddingTop: 78,
|
||||
paddingBottom: 64,
|
||||
paddingHorizontal: 64,
|
||||
},
|
||||
topBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
backgroundColor: BRAND,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
accent: {
|
||||
width: 32,
|
||||
height: 2.2,
|
||||
backgroundColor: BRAND,
|
||||
marginBottom: 14,
|
||||
},
|
||||
note: {
|
||||
fontSize: 9.5,
|
||||
color: GREY,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraph: {
|
||||
lineHeight: 1.45,
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraphSpaced: {
|
||||
lineHeight: 1.45,
|
||||
marginTop: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8.5,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: GREY,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionRule: {
|
||||
borderBottomWidth: 0.75,
|
||||
borderBottomColor: RULE,
|
||||
},
|
||||
footerNote: {
|
||||
fontSize: 8.5,
|
||||
color: GREY,
|
||||
marginTop: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const SECTION_HEADERS = [
|
||||
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
|
||||
"BESTELLT AM (*)",
|
||||
"ERHALTEN AM (*)",
|
||||
"NAME DES/DER VERBRAUCHER(S)",
|
||||
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
|
||||
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
|
||||
"DATUM",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const seller = await getCompanySettings();
|
||||
|
||||
const addressLine = seller
|
||||
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
|
||||
: "einfach produktiv.";
|
||||
|
||||
const doc = React.createElement(
|
||||
Document,
|
||||
{ title: "Muster-Widerrufsformular - einfach produktiv." },
|
||||
React.createElement(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
React.createElement(View, { style: styles.topBar }),
|
||||
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
|
||||
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
|
||||
React.createElement(View, { style: styles.accent }),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.note },
|
||||
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
|
||||
),
|
||||
React.createElement(Text, { style: styles.label }, "An:"),
|
||||
React.createElement(Text, { style: styles.paragraph }, addressLine),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.paragraphSpaced },
|
||||
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
|
||||
),
|
||||
...SECTION_HEADERS.map((header) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ key: header, style: styles.section },
|
||||
React.createElement(Text, { style: styles.sectionLabel }, header),
|
||||
React.createElement(View, { style: styles.sectionRule }),
|
||||
),
|
||||
),
|
||||
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
|
||||
),
|
||||
);
|
||||
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,15 @@ export async function POST(req: Request) {
|
||||
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
|
||||
const result = await upsertNewsletterContact(email, source);
|
||||
if (!result.ok) {
|
||||
// The customer-facing message stays generic on purpose (never leak
|
||||
// Brevo's internal error text to a customer) — but the real reason
|
||||
// was previously discarded entirely, which cost real debugging time
|
||||
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
|
||||
// every failure looked identical from the outside, whether it was a
|
||||
// bad env var, a Brevo outage, or something else. Logged here so it's
|
||||
// at least diagnosable from the container's own logs going forward.
|
||||
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type SearchResult = {
|
||||
type: "product" | "post";
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// Lightweight instant search — plain `where[...][contains]` queries
|
||||
// against Payload (Postgres ILIKE under the hood) rather than a real
|
||||
// search index (Meilisearch/Algolia). Fine at this catalog size (a
|
||||
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
|
||||
// own "search becomes necessary past ~20 products" note) — worth
|
||||
// upgrading only once the catalog actually grows into that range, this
|
||||
// route can be swapped out later without touching the frontend overlay.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
if (q.length < 2) return NextResponse.json({ results: [] });
|
||||
|
||||
const productParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
"where[name][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
const postParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
"where[title][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
|
||||
const [productsRes, postsRes] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
|
||||
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
|
||||
]);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (productsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
|
||||
await productsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "product",
|
||||
id: `product-${doc.id}`,
|
||||
title: doc.name,
|
||||
href: doc.detailHref || "/shop",
|
||||
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (postsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "post",
|
||||
id: `post-${doc.id}`,
|
||||
title: doc.title,
|
||||
href: `/blog/${doc.slug}`,
|
||||
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -183,9 +183,23 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
</Reveal>
|
||||
|
||||
{!productsLoading && (
|
||||
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
|
||||
// sidebar, L297 below — same fixed-width-block category as Tier C
|
||||
// exceptions elsewhere), while the inner order-meta/items row
|
||||
// already switches at sm: — intentional, not an inconsistent
|
||||
// leftover: the inner row has no fixed-width sidebar to fight for
|
||||
// room against, just two flexible columns.
|
||||
//
|
||||
// max-w-[36rem] between sm and lg (same fix/value as
|
||||
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
|
||||
// doesn't actually constrain anything below that viewport width,
|
||||
// so at real Tablet widths (640-1023px) this card stretched to
|
||||
// fill the full page width instead of reading as a compact,
|
||||
// centered card. Widens to the real 56rem cap only once lg:'s
|
||||
// sidebar split kicks in and needs the room.
|
||||
<Reveal
|
||||
delay={0.05}
|
||||
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
>
|
||||
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
|
||||
{/* Order meta */}
|
||||
@@ -328,8 +342,8 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
identical height regardless of how many lines the quote wraps
|
||||
to (a min-height alone lets whichever column has more content
|
||||
stretch the row past what the other side visually fills). */}
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full md:w-[45%] shrink-0">
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full sm:w-[45%] shrink-0">
|
||||
{/* -inset-1, not inset-0 — this section fades in via Reveal's
|
||||
y:28→0 transform; a plain inset-0 image can leave a
|
||||
hairline gap at the top edge while that's still settling
|
||||
@@ -343,10 +357,10 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
src="/bestellbestaetigung-testimonial-photo.jpg"
|
||||
width={366}
|
||||
height={126}
|
||||
sizes="(min-width: 768px) 45vw, 100vw"
|
||||
sizes="(min-width: 640px) 45vw, 100vw"
|
||||
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
|
||||
/>
|
||||
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
|
||||
<p
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../.
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of the blog detail page: title/category/readTime/
|
||||
// Live-previewable subset of the blog detail page: title/categories/readTime/
|
||||
// excerpt/byline, the thumbnail, and the RichText body — the fields an
|
||||
// editor actually watches update while typing. The author bio card,
|
||||
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
|
||||
@@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { buildArticleSchema } from "../../lib/structuredData";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -61,9 +62,12 @@ export default async function BlogDetailPage({
|
||||
// showing a fake/duplicate card when this is the only post.
|
||||
const otherPosts = await getBlogPosts(4);
|
||||
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
|
||||
const seller = await getCompanySettings();
|
||||
const articleSchema = buildArticleSchema(post, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
@@ -71,7 +75,7 @@ export default async function BlogDetailPage({
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
@@ -217,7 +221,7 @@ export default async function BlogDetailPage({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{nextPost.category}</span>
|
||||
<span>{nextPost.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -20,8 +20,33 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogOverviewPage() {
|
||||
const posts = await getBlogPosts(100);
|
||||
// Every distinct category name in DOM order — Payload's `categories` field
|
||||
// stores related docs, no separate slug on this side, but since this page
|
||||
// already fetches every published post (limit 100, no pagination), a
|
||||
// plain client-agnostic array filter is enough; no separate Payload query
|
||||
// per category needed.
|
||||
function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const post of posts) for (const c of post.categories) seen.add(c);
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
function buildCategoryHref(active: string[], category: string): string {
|
||||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||||
}
|
||||
|
||||
export default async function BlogOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categories?: string }>;
|
||||
}) {
|
||||
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
|
||||
const { categories: categoriesParam } = await searchParams;
|
||||
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
|
||||
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
|
||||
const posts =
|
||||
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
|
||||
const [featured, ...rest] = posts;
|
||||
|
||||
return (
|
||||
@@ -30,8 +55,8 @@ export default async function BlogOverviewPage() {
|
||||
{/* Header — copy left, photo bleeds to the viewport edge on the
|
||||
right with a left-edge fade into bg-base, matching Figma's
|
||||
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
|
||||
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
|
||||
<Reveal className="relative flex flex-col sm:flex-row items-center w-full min-h-[20rem] sm:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 sm:py-0 w-full sm:w-auto sm:max-w-[26rem]">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -42,13 +67,71 @@ export default async function BlogOverviewPage() {
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
|
||||
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="relative w-full sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
|
||||
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
|
||||
<div className="hidden sm:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Plain div, not <Reveal> — this bar sits right at/just past the
|
||||
hero's bottom edge, exactly the "already near the initial
|
||||
viewport top" position Reveal.tsx's own comment documents as a
|
||||
whileInView(margin:"-80px") trap: the shrunk viewport can
|
||||
permanently miss triggering "entered view" for an element
|
||||
that's already visible without any further scroll, since
|
||||
`once: true` never gets a second chance. The Hero brand dot
|
||||
hit the identical bug and switched to a plain animate — this
|
||||
filter bar doesn't need a scroll-reveal animation at all, so
|
||||
it's simplest to just not wrap it in Reveal in the first place. */}
|
||||
{/* relative z-20 — the featured-post card right below pulls itself
|
||||
up by -mt-8 with its own z-10 to overlap the *hero's* bottom
|
||||
edge (its original, intended design). Inserting this filter
|
||||
bar between the hero and that card meant the same -mt-8 pull
|
||||
now overlapped THIS bar instead, and the card's higher/equal
|
||||
stacking rendered on top of it, visually hiding the chips
|
||||
behind the card. z-20 keeps this bar above that overlap
|
||||
regardless. */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="relative z-20 flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<Link
|
||||
key={category}
|
||||
href={buildCategoryHref(activeCategories, category)}
|
||||
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
|
||||
// on bg-base (#f8f5f1) is a ~2% lightness difference,
|
||||
// nearly invisible as a pill outline; a filled muted
|
||||
// background makes the chip read as a discrete control
|
||||
// regardless of the border's own low contrast.
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{activeCategories.length > 0 && (
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
|
||||
>
|
||||
Zurücksetzen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 && (
|
||||
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
|
||||
Keine Beiträge in dieser Kategorie gefunden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{featured && (
|
||||
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
|
||||
// hero section's bottom edge instead of sitting flush below it.
|
||||
@@ -56,16 +139,16 @@ export default async function BlogOverviewPage() {
|
||||
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||||
<Link
|
||||
href={`/blog/${featured.slug}`}
|
||||
className="group grid grid-cols-1 md:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 40rem, 100vw" className="object-cover" />
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 md:p-12 min-w-0">
|
||||
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{featured.category}</span>
|
||||
<span>{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
<span>•</span>
|
||||
@@ -106,7 +189,7 @@ export default async function BlogOverviewPage() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
<span>•</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
@@ -77,7 +77,7 @@ export function CartContent({
|
||||
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
@@ -170,15 +170,21 @@ export function CartContent({
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
|
||||
{/* threshold forced to null (banner just doesn't render, see its
|
||||
own early return) whenever nothing in the cart actually
|
||||
triggers shipping — a "€X bis kostenlosem Versand" nudge
|
||||
makes no sense for a cart that was never going to be charged
|
||||
shipping in the first place. */}
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={cartHasShippableItem(items) ? freeShippingThreshold : null} />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Cart card — lg:-only split from the sidebar (same "wide content
|
||||
next to sidebar" shape as the Hero's image/text split, see
|
||||
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
|
||||
already exceeds the 768px Tablet floor, so md: would never
|
||||
have had room for a real 2-column layout at Tablet widths
|
||||
anyway). */}
|
||||
already exceeds even the site's 640px structural floor, so
|
||||
sm: would never have had room for a real 2-column layout at
|
||||
Tablet widths anyway — a deliberate exception to the site-wide
|
||||
sm: consolidation, not a leftover of it). */}
|
||||
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
|
||||
{items.map(({ entry, product }, i) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
@@ -391,31 +397,40 @@ export function CartContent({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in
|
||||
the cart actually triggers shipping at all — that's a
|
||||
different state from hitting the free-shipping
|
||||
threshold, which is still a real promotional message
|
||||
worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -456,10 +471,22 @@ export function CartContent({
|
||||
</div>
|
||||
|
||||
{/* Title only — /checkout renders the same CartTrustBadges
|
||||
docs with description too, see CheckoutContent.tsx. */}
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
docs with description too, see CheckoutContent.tsx.
|
||||
Side by side between sm (640px) and lg (1024px) — this
|
||||
sidebar is full page width through that whole range (it
|
||||
only becomes a narrow fixed-width column at lg:, see this
|
||||
file's own lg:w-[20.625rem] above), plenty of room for 3
|
||||
short titles in a row; stacked at true mobile and again
|
||||
once the sidebar narrows at lg:. sm:w-auto with no
|
||||
flex-grow (was sm:flex-1) so each badge only takes its own
|
||||
content width instead of stretching to fill the full card
|
||||
width; sm:justify-center centers that shrink-to-fit row so
|
||||
the leftover space splits evenly left/right instead of
|
||||
collecting on the right (the default with items packed
|
||||
from the start edge). */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap lg:flex-col gap-4 sm:gap-x-8 sm:gap-y-4 lg:gap-4 items-start sm:justify-center lg:justify-start w-full">
|
||||
{trustBadges.map((b) => (
|
||||
<div key={b.id} className="flex gap-3 items-center w-full">
|
||||
<div key={b.id} className="flex gap-3 items-center w-full sm:w-auto lg:w-full">
|
||||
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
(opacity: 0) — invisible. Not worth chasing a fix for a
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
@@ -134,7 +134,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
<div
|
||||
key={product.id}
|
||||
className={
|
||||
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
|
||||
"group sm:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
@@ -142,9 +142,9 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "md:col-start-5"
|
||||
? "sm:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "md:col-start-3"
|
||||
? "sm:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 320px, 100vw"
|
||||
sizes="(min-width: 640px) 320px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Same top-left pill pattern as ProductGrid.tsx/
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
|
||||
|
||||
// Wraps a plain street-address input with a DHL DataFactory suggestion
|
||||
// dropdown. Completely invisible/inert when the tenant doesn't have
|
||||
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
|
||||
// route just returns an empty suggestions array in that case (see
|
||||
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
|
||||
// plain text input with no dropdown ever appearing, same "no half-built UI
|
||||
// when a feature is off" convention as WishlistButton/searchEnabled.
|
||||
//
|
||||
// Styling duplicates FormField's classes (defined locally in
|
||||
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
|
||||
// component usable on its own.
|
||||
export function AddressAutocomplete({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onSelectSuggestion,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
}: {
|
||||
label: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
|
||||
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
|
||||
setSuggestions(data.ok ? data.suggestions : []);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Delayed so a click on a suggestion below (which itself fires a
|
||||
// blur first) still registers before the dropdown unmounts.
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
|
||||
onSelectSuggestion(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
{s.street}
|
||||
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { useRouter } from "next/navigation";
|
||||
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CustomSelect } from "../../components/CustomSelect";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { AddressAutocomplete } from "./AddressAutocomplete";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
@@ -243,19 +245,32 @@ export function CheckoutContent({
|
||||
const [zip, setZip] = useState(savedProfile?.zip ?? "");
|
||||
const [city, setCity] = useState(savedProfile?.city ?? "");
|
||||
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
|
||||
// Optional package destination distinct from the billing address above —
|
||||
// no savedProfile fallback (a customer's saved profile has only ever had
|
||||
// one address), just an empty draft-only section.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState("");
|
||||
const [shippingLastName, setShippingLastName] = useState("");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address");
|
||||
const [shippingStreet, setShippingStreet] = useState("");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState("");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState("");
|
||||
const [shippingZip, setShippingZip] = useState("");
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
const [shippingCountry, setShippingCountry] = useState("Deutschland");
|
||||
// Optional package destination distinct from the billing address above.
|
||||
// Seeded from savedProfile's own "Lieferadresse" tab (Customers.ts),
|
||||
// same precedence as Card 1's billing fields above: draft (if any)
|
||||
// overwrites this in the hydration effect below, savedProfile is only
|
||||
// the pre-hydration/SSR-safe fallback. Unlike Card 1, savedProfile *can*
|
||||
// supply these now (see Customers.ts's "Lieferadresse" tab) — this used
|
||||
// to always start empty since the saved profile had only one address.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(savedProfile?.hasDifferentShippingAddress ?? false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState(savedProfile?.shippingFirstName ?? "");
|
||||
const [shippingLastName, setShippingLastName] = useState(savedProfile?.shippingLastName ?? "");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
savedProfile?.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [shippingStreet, setShippingStreet] = useState(savedProfile?.shippingStreet ?? "");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState(savedProfile?.shippingPackstationNumber ?? "");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState(savedProfile?.shippingPostNumber ?? "");
|
||||
const [shippingZip, setShippingZip] = useState(savedProfile?.shippingZip ?? "");
|
||||
const [shippingCity, setShippingCity] = useState(savedProfile?.shippingCity ?? "");
|
||||
const [shippingCountry, setShippingCountry] = useState(savedProfile?.shippingCountry ?? "Deutschland");
|
||||
// Optional, mirrors companyName above — no shipping-side vatId though,
|
||||
// a VAT ID is a billing/invoice concept, not a shipping one. Contact
|
||||
// email/phone have no billing-side equivalent at all: they're handed to
|
||||
// the shipping carrier, not used for any customer communication.
|
||||
const [shippingCompanyName, setShippingCompanyName] = useState(savedProfile?.shippingCompanyName ?? "");
|
||||
const [shippingContactEmail, setShippingContactEmail] = useState(savedProfile?.shippingContactEmail ?? "");
|
||||
const [shippingContactPhone, setShippingContactPhone] = useState(savedProfile?.shippingContactPhone ?? "");
|
||||
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
|
||||
// Live VIES status for the USt-IdNr. field — only meaningful once the
|
||||
// goods' destination (shipping override country when set, billing
|
||||
@@ -265,6 +280,7 @@ export function CheckoutContent({
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -301,6 +317,9 @@ export function CheckoutContent({
|
||||
if (draft.shippingZip) setShippingZip(draft.shippingZip);
|
||||
if (draft.shippingCity) setShippingCity(draft.shippingCity);
|
||||
if (draft.shippingCountry) setShippingCountry(draft.shippingCountry);
|
||||
if (draft.shippingCompanyName) setShippingCompanyName(draft.shippingCompanyName);
|
||||
if (draft.shippingContactEmail) setShippingContactEmail(draft.shippingContactEmail);
|
||||
if (draft.shippingContactPhone) setShippingContactPhone(draft.shippingContactPhone);
|
||||
if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn);
|
||||
if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId);
|
||||
if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId);
|
||||
@@ -331,6 +350,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -356,6 +378,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -372,7 +397,7 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
@@ -449,6 +474,42 @@ export function CheckoutContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Live-checks the Packstation Postnummer against DHL, mirroring
|
||||
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
|
||||
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
|
||||
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
|
||||
// common case for shops without a DHL integration, so it silently falls
|
||||
// back to idle (format-only, already checked above) rather than showing
|
||||
// an alarming "currently unavailable" message for a feature that was
|
||||
// simply never turned on.
|
||||
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const input = e.target;
|
||||
const value = input.value;
|
||||
const formatError = validatePostNumber(value);
|
||||
setFieldError("shippingPostNumber", formatError, input);
|
||||
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
|
||||
if (!data.valid) input.focus();
|
||||
} catch {
|
||||
setDhlPostNumberStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -558,6 +619,9 @@ export function CheckoutContent({
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn,
|
||||
};
|
||||
|
||||
@@ -911,7 +975,7 @@ export function CheckoutContent({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
|
||||
<FormField
|
||||
label="Passwort (für dein neues Konto)"
|
||||
label="Passwort (für dein Konto)"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Mind. 8 Zeichen"
|
||||
@@ -951,13 +1015,16 @@ export function CheckoutContent({
|
||||
Rechnungsadresse (an invoice needs a real postal address).
|
||||
Packstation is only ever offered below, in the optional
|
||||
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onChange={setStreet}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setZip(s.zip);
|
||||
setCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
@@ -996,17 +1063,19 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
name="country"
|
||||
{/* CustomSelect, not a native <select> — consistent with the
|
||||
filter dropdowns elsewhere (native options popups can't be
|
||||
styled at all). includeAllOption={false}: every country is
|
||||
a real, selectable choice, there's no "clear" concept
|
||||
here — "country" is always genuinely set to something. */}
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setCountry}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -1048,6 +1117,15 @@ export function CheckoutContent({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Firma (optional)"
|
||||
type="text"
|
||||
value={shippingCompanyName}
|
||||
onChange={(e) => setShippingCompanyName(e.target.value)}
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="off"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full rounded-sm border border-border overflow-hidden">
|
||||
@@ -1078,12 +1156,15 @@ export function CheckoutContent({
|
||||
</div>
|
||||
</div>
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onChange={setShippingStreet}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setShippingZip(s.zip);
|
||||
setShippingCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
@@ -1107,21 +1188,34 @@ export function CheckoutContent({
|
||||
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1–999)."
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={handlePostNumberBlur}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Silent when idle — covers both "not yet checked" and
|
||||
"this shop has no DHL Postnummer-validation active",
|
||||
same reasoning as handlePostNumberBlur's own comment. */}
|
||||
<p className="text-label text-text-muted min-h-[1.05rem]">
|
||||
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
|
||||
{dhlPostNumberStatus === "valid" && <span className="text-success">✓ Postnummer bestätigt</span>}
|
||||
{dhlPostNumberStatus === "invalid" && (
|
||||
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
@@ -1154,17 +1248,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={shippingCountry}
|
||||
onChange={(e) => setShippingCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setShippingCountry}
|
||||
/>
|
||||
</label>
|
||||
{/* Both optional and independent — handed to the shipping
|
||||
carrier, not used for any customer communication (that
|
||||
stays the account email above). Useful when the
|
||||
recipient at this address isn't reachable via the
|
||||
account holder's own email/phone. */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
type="email"
|
||||
value={shippingContactEmail}
|
||||
onChange={(e) => setShippingContactEmail(e.target.value)}
|
||||
placeholder="empfang@beispiel.de"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormField
|
||||
label="Telefonnummer (optional)"
|
||||
type="tel"
|
||||
value={shippingContactPhone}
|
||||
onChange={(e) => setShippingContactPhone(e.target.value)}
|
||||
placeholder="+49 30 123456"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1337,33 +1453,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in the
|
||||
cart actually triggers shipping at all — a different state
|
||||
from hitting the free-shipping threshold, which is still a
|
||||
real promotional message worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
|
||||
@@ -24,25 +24,43 @@ type Props = {
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
/** Where /checkout/verarbeitung sends the customer once payment is
|
||||
* confirmed — "checkout" (default) clears the cart/draft and lands on
|
||||
* /bestellbestaetigung, exactly like today. "account" is used by the
|
||||
* account order detail page's "Zahlungsart ändern" flow (an existing,
|
||||
* already-confirmed order — nothing to clear, and /bestellbestaetigung
|
||||
* would be the wrong destination): lands back on that same order's
|
||||
* page instead. See VerarbeitungContent.tsx's own branching on this. */
|
||||
returnContext?: "checkout" | "account";
|
||||
};
|
||||
|
||||
// Rendered by CheckoutContent once /api/checkout returns
|
||||
// `requiresPayment: true` (Kreditkarte/PayPal) — see
|
||||
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
|
||||
// this point (status 'pending_payment'); this step only collects/confirms
|
||||
// the actual payment, it doesn't create anything.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
|
||||
// the actual payment, it doesn't create anything. Also reused as-is by
|
||||
// the account order detail page's payment-method-switch flow (see
|
||||
// returnContext above) — the Stripe collection UI itself is identical
|
||||
// either way, only the post-payment destination differs.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
|
||||
if (testMode) {
|
||||
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
|
||||
return (
|
||||
<TestPaymentButtons
|
||||
orderNumber={orderNumber}
|
||||
orderId={orderId}
|
||||
providerReference={providerReference ?? ""}
|
||||
returnContext={returnContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} />
|
||||
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -62,7 +80,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
@@ -80,7 +98,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
|
||||
className="rounded-full bg-brand px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
@@ -88,7 +106,17 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
|
||||
function TestPaymentButtons({
|
||||
orderNumber,
|
||||
orderId,
|
||||
providerReference,
|
||||
returnContext,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
providerReference: string;
|
||||
returnContext: "checkout" | "account";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -108,7 +136,7 @@ function TestPaymentButtons({ orderNumber, orderId, providerReference }: { order
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
|
||||
@@ -24,6 +24,11 @@ export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
// "account" — the payment-method-switch flow on an existing order's
|
||||
// account page (see PaymentStep.tsx's own returnContext comment).
|
||||
// Defaults to "checkout" for a bare/missing param, same as before this
|
||||
// branch existed.
|
||||
const isAccountContext = searchParams.get("context") === "account";
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
@@ -42,6 +47,15 @@ export function VerarbeitungContent() {
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
if (isAccountContext) {
|
||||
// Nothing to clear — this order was already placed (as
|
||||
// Überweisung) and confirmed long before this switch, there's
|
||||
// no cart/discount/draft snapshot involved. Land back on the
|
||||
// same order instead of /bestellbestaetigung, which would
|
||||
// read as a brand-new purchase.
|
||||
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
@@ -114,13 +128,15 @@ export function VerarbeitungContent() {
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
{isAccountContext
|
||||
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
|
||||
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -133,10 +149,10 @@ export function VerarbeitungContent() {
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,11 +21,14 @@ const FALLBACK: CompanySettings = {
|
||||
sellerCity: "",
|
||||
sellerCountry: "",
|
||||
sellerEmail: "",
|
||||
emailFromName: null,
|
||||
emailFromAddress: null,
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
|
||||
@@ -3,19 +3,20 @@ import { Reveal } from "./Reveal";
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col sm:flex-row sm:items-stretch w-full">
|
||||
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
md:flex-[1.4_0_0] lg:flex-[1_0_0] — at Tablet the text column got
|
||||
the narrower 1:1.4 share meant for Desktop's overlap layout,
|
||||
leaving it too cramped for the fixed-width statement + quote/bio
|
||||
row. Widened at Tablet (text gets the bigger share, image the
|
||||
smaller one, no overlap yet) and reverted to the original ratio
|
||||
from lg: up, where the overlap trick actually needs the image to
|
||||
have more room. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1.4_0_0] lg:flex-[1_0_0] min-w-0 relative z-10">
|
||||
The 1:1.4 text:image ratio (text gets the narrower share) applies
|
||||
from sm: up unchanged — the photo needs the room, the text
|
||||
column doesn't need to be wide (the inner quote/bio row stays
|
||||
stacked at Tablet regardless, see below, so a wider text column
|
||||
doesn't even help it go inline; it just steals space the photo
|
||||
should have — reported 2026-07-29). Only the -ml-48 overlap
|
||||
itself (see the photo below) is gated to lg:, since that trick
|
||||
needs more absolute room than Tablet has to not look cramped. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 sm:flex-[1_0_0] min-w-0 relative z-10">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
@@ -26,11 +27,13 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from lg: (was md:) — even with the text column's wider Tablet
|
||||
share above, quote + divider + the whitespace-nowrap bio ("Gründer
|
||||
von einfach-produktiv.") together still needed more room than
|
||||
Tablet's ~384px column has. Stacked with a horizontal divider
|
||||
through the whole Tablet range instead, side-by-side (vertical
|
||||
only from lg: (1024px), a deliberate exception to the site's
|
||||
sm: (640px) structural consolidation: quote + divider + the
|
||||
whitespace-nowrap bio ("Gründer von einfach produktiv.")
|
||||
together need more room than the sm:-anchored text column has
|
||||
through the whole 640-1023px range, independent of the outer
|
||||
text/image ratio above. Stacked with a horizontal divider
|
||||
through that whole range instead, side-by-side (vertical
|
||||
divider) only once there's real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
|
||||
|
||||
@@ -74,19 +77,20 @@ export function About() {
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
<p>Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from lg+ only
|
||||
(that overlap trick has nothing to blend into once stacked, and
|
||||
at Tablet it would eat back into the extra width the text column
|
||||
above just gained); plain full-width photo below the text on
|
||||
Mobile, plain side-by-side (no overlap) at Tablet. */}
|
||||
{/* Author photo — gets the wider 1.4 share from sm: up already (see
|
||||
the text column's own comment above); plain, no overlap at
|
||||
Tablet. Overlaps the text column via -ml-48 only from lg+, where
|
||||
there's real absolute room for the photo to bleed under it
|
||||
without looking cramped. Plain full-width photo below the text
|
||||
on true Mobile. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
|
||||
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
@@ -94,11 +98,16 @@ export function About() {
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — lg+ only */}
|
||||
<div className="hidden lg:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
{/* Left gradient — from sm: up, not just lg:. Even without the
|
||||
-ml-48 overlap at Tablet, the photo sits directly against the
|
||||
dark text column with no transition, reading as a hard vertical
|
||||
seam (reported 2026-07-29). A narrower fade (w-16) softens just
|
||||
that seam at Tablet; widens to w-72 at lg: to also cover the
|
||||
deeper -ml-48 overlap once that kicks in. */}
|
||||
<div className="hidden sm:block absolute inset-y-0 left-0 w-16 lg:w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -31,23 +31,23 @@ export async function Blog() {
|
||||
{/* Posts grid */}
|
||||
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
|
||||
{/* Featured post — image on top on Mobile, side-by-side from sm+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 sm:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:col-span-7 sm:h-[220px] bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm:col-span-5 min-w-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.category}</span>
|
||||
<span>{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ export async function Blog() {
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 sm:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,28 +75,32 @@ export async function Blog() {
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{/* Secondary posts — image on top through lg (1024px), side-by-side
|
||||
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
|
||||
before lg, so each card's own available width through the
|
||||
640-1023px tablet range is too narrow for image+text side by
|
||||
side — was sm:grid-cols-5, squeezing the text column. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
|
||||
{secondary.map((post) => (
|
||||
<RevealItem
|
||||
key={post.id}
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 29vw, 100vw"
|
||||
sizes="(min-width: 1024px) 29vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
@@ -107,7 +111,7 @@ export async function Blog() {
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// A fully custom-styled dropdown — a native <select>'s trigger box can be
|
||||
// styled, but its open options popup is rendered by the browser/OS itself
|
||||
// and can't be reached with CSS at all (wrong font size, wrong colors, no
|
||||
// brand styling whatsoever). This renders both the trigger and the
|
||||
// options panel as plain HTML we control end to end. Originally built for
|
||||
// /konto/bestellungen's filters, promoted to a shared component so
|
||||
// checkout's country selects can use the same look (moved here 2026-07-30).
|
||||
export function CustomSelect({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
includeAllOption = true,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
/** Screen-reader label — also the trigger's placeholder text when
|
||||
* `includeAllOption` is true and nothing is selected. */
|
||||
label: string;
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** true (default): prepends a `{value: "", label}` "clear/show all"
|
||||
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
|
||||
* filters), where "nothing selected" is a real, meaningful state.
|
||||
* false: no pseudo-option, every real option is selectable and one is
|
||||
* always genuinely selected — the plain-select-replacement use case
|
||||
* (e.g. checkout's country picker), where there's no "clear" concept. */
|
||||
includeAllOption?: boolean;
|
||||
/** false (default): trigger shrinks to its content width from sm: up —
|
||||
* right for a row of compact filter dropdowns. true: trigger always
|
||||
* stays full width of its container — right for a form-field
|
||||
* replacement (e.g. checkout's country picker, alongside other w-full
|
||||
* inputs). */
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
allOptions.findIndex((o) => o.value === value),
|
||||
);
|
||||
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setHighlighted(selectedIndex);
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [open, highlighted]);
|
||||
|
||||
// Keyboard/focus-driven close: Tabbing (or programmatically moving
|
||||
// focus) away from the trigger+list entirely used to leave the panel
|
||||
// open forever — the mousedown-outside listener above only ever reacts
|
||||
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
|
||||
// focus is headed; null on some browsers when it lands outside the
|
||||
// document/on a non-focusable element, which should also close.
|
||||
function onBlur(e: React.FocusEvent) {
|
||||
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}
|
||||
|
||||
function select(index: number) {
|
||||
onChange(allOptions[index].value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
select(highlighted);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex items-center justify-between gap-2 w-full ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
|
||||
fullWidth ? "px-4 py-3" : "px-3 py-2"
|
||||
} text-body-sm transition-colors outline-none ${
|
||||
value ? "border-brand text-text-primary" : "border-border text-text-muted"
|
||||
} hover:border-brand focus-visible:border-brand`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
|
||||
>
|
||||
{allOptions.map((o, i) => (
|
||||
<li
|
||||
key={o.value || "__all__"}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => select(i)}
|
||||
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
|
||||
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
|
||||
} ${i === highlighted ? "bg-bg-muted" : ""}`}
|
||||
>
|
||||
{o.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function Divider() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
<Word>Leichtigkeit</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
the text-h2 words next to it, previously hard rem values that
|
||||
|
||||
@@ -19,10 +19,15 @@ export function Footer() {
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg: (was md:). Logo + handle + 5 legal links all side by side
|
||||
with justify-between read too cramped on Tablet — stacked
|
||||
through that range instead, side by side again once there's
|
||||
real room at lg:. */}
|
||||
lg:. Reverted here from an earlier sm: rename (2026-07-29's
|
||||
breakpoint migration): this row isn't the "grid arrives before
|
||||
the fluid floor" bug the sm: consolidation targets — logo +
|
||||
handle + 5 nowrap legal links (all shrink-0) simply don't fit
|
||||
in one row below ~1024px regardless of fluid scaling (measured:
|
||||
~746px combined natural width at a 666px viewport, causing a
|
||||
real horizontal page overflow, not just visual cramping).
|
||||
Same fixed-content-doesn't-fit category as Cart/Checkout/
|
||||
SectionTOC, kept at lg: for the same reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
|
||||
@@ -11,7 +11,7 @@ function HeroImage() {
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
@@ -28,44 +28,56 @@ function HeroImage() {
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is md: (768px) for the GRID only — the text
|
||||
column stays ~283-320px wide through the whole 768-1023px Tablet
|
||||
{/* Structural breakpoint is sm: (640px, moved down from the old md:
|
||||
768px so the grid arrives exactly where the fluid token floor
|
||||
also sits — see fluid.ts/globals.css) for the GRID only — the
|
||||
text column stays narrow through the whole 640-1023px Tablet
|
||||
range regardless. Below, every piece of *content* inside the text
|
||||
column (heading/subtitle/CTA/social-proof) keeps its smaller,
|
||||
fixed-below-lg: sizing all the way through Tablet too, not just
|
||||
true Mobile — reusing the full fluid-token sizes at md: (as a
|
||||
first pass 2026-07-24 briefly did) put the original ~19-44px
|
||||
fluid floors right back in that narrow column, recreating the
|
||||
exact 3-line-wrap problem the old `lg:` structural exception
|
||||
existed to avoid. Splitting "grid at md:" from "full-size content
|
||||
at lg:" gets both: Tablet shows the real 5/7 grid, but with
|
||||
content sized for its column's actual width, not the column
|
||||
width `lg:` was designed for. */}
|
||||
<div className="flex flex-col md:grid md:grid-cols-12 md:items-center gap-8 md:gap-[var(--layout-grid-gap)] pt-10 md:pt-0">
|
||||
column (heading/subtitle/CTA/social-proof) deliberately keeps its
|
||||
smaller, fixed-below-lg: sizing all the way through Tablet too,
|
||||
not just true Mobile — reusing the full fluid-token sizes that
|
||||
early put the original ~19-44px fluid floors right back in that
|
||||
narrow column, recreating the exact 3-line-wrap problem the old
|
||||
`lg:` structural exception existed to avoid. This is a narrower,
|
||||
intentional exception to the site-wide sm: consolidation — the
|
||||
column's real width at sm: hasn't been visually verified yet
|
||||
(no browser in this environment), so full-size content stays
|
||||
gated behind lg: as a fast-follow rather than a guess. Splitting
|
||||
"grid at sm:" from "full-size content at lg:" gets both: Tablet
|
||||
shows the real 5/7 grid, but with content sized for its column's
|
||||
actual width, not the column width `lg:` was designed for. */}
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-12 sm:items-center gap-8 sm:gap-[var(--layout-grid-gap)] pt-10 sm:pt-0">
|
||||
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 md:order-none md:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 md:pr-0">
|
||||
viewport — this doubles as the page's entrance animation.
|
||||
Centered below sm: (true Mobile) — the CTA/social-proof below
|
||||
were already centered there, left-aligned heading/subheading
|
||||
above them read inconsistent (reported 2026-07-29). Left-
|
||||
aligned again from sm: up, matching the rest of the column. */}
|
||||
<Reveal className="order-1 sm:order-none sm:col-span-5 flex flex-col gap-7 items-center sm:items-start pl-[var(--layout-padding-x)] pr-10 sm:pr-0">
|
||||
|
||||
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
|
||||
real paired font-size+line-height token, not an arbitrary
|
||||
value) — text-display's own 44px floor wraps very heavily in
|
||||
a ~283-320px Tablet column (even a single word can approach
|
||||
that width). Forced break after "darf" only in the sm-md
|
||||
tablet range (natural wrap there landed awkwardly); removed
|
||||
at true mobile widths (below sm:) 2026-07-24 — narrower
|
||||
still, natural wrap reads fine there, and the forced break
|
||||
made "darf" the whole first line. Full text-display only
|
||||
from lg: up, where the column has real room again. */}
|
||||
that width). The old forced break after "darf" only existed
|
||||
for a "wide single-column, not yet grid" band (640-767px
|
||||
under the old md: 768px grid switch) that no longer exists
|
||||
now the grid itself starts at sm: (640px) — below sm: the
|
||||
stack is narrower than that old band ever was, where natural
|
||||
wrap already reads fine (confirmed 2026-07-24), so the break
|
||||
is removed rather than re-anchored to a new range. Full
|
||||
text-display only from lg: up, where the column has real
|
||||
room again. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary text-center sm:text-left"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-h1 lg:text-display">
|
||||
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
|
||||
Verlier' dich nicht im mehr, verankere was zählt
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
@@ -83,14 +95,16 @@ export function Hero() {
|
||||
{/* Subheading — smaller fixed size below lg:, text-h-emphasis's
|
||||
own 20px floor read too large next to the now-smaller CTA
|
||||
text. leading shrinks to match, not just font-size. */}
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] text-center sm:text-left [word-break:break-word] not-italic">
|
||||
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
{/* CTA — centered below sm: (reads better against the centered
|
||||
image/banner above it on true Mobile), left-aligned with the
|
||||
rest of the text column again from sm: up. */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
href="/lebensuhr"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all self-center sm:self-auto"
|
||||
>
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
@@ -100,7 +114,7 @@ export function Hero() {
|
||||
too wide for that, both on a 375px phone AND in the
|
||||
~283-320px Tablet grid column. */}
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
Jetzt starten
|
||||
</span>
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
@@ -112,8 +126,14 @@ export function Hero() {
|
||||
|
||||
{/* Social proof — always avatars-then-text on two lines below
|
||||
lg: (not just when it happens to overflow), single row again
|
||||
from lg: up where the real column width fits it fine. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center justify-center overflow-clip shrink-0 w-full">
|
||||
from lg: up where the real column width fits it fine.
|
||||
Centered on true Mobile (<640px, matches the CTA above it,
|
||||
see self-center there); left-aligned in the 640-1023px
|
||||
Tablet band instead, matching the left-aligned heading/
|
||||
subheading — plain centered there read as floating/
|
||||
disconnected from that column (reported 2026-07-29); back to
|
||||
a centered row at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center sm:items-start lg:items-center justify-center sm:justify-start lg:justify-center overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
@@ -126,36 +146,36 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center sm:text-left [word-break:break-word]">
|
||||
1.120+ Menschen vertrauen <span className="whitespace-nowrap">einfach produktiv.</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below md: (stacked layout) the full 887:583 aspect
|
||||
ratio at 100vw would make the image ~600-900px tall and
|
||||
dominate the page, so height is capped and object-cover crops
|
||||
it into a supporting banner instead; at md:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. No shadow: a plain box-shadow reads as a hard
|
||||
rectangular edge against the existing corner/right/bottom
|
||||
mask-gradient fade below, which looked worse than no shadow at
|
||||
all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below md: — whileInView's -80px
|
||||
padded). Below sm: (stacked layout, moved down from the old
|
||||
md: 768px) the full 887:583 aspect ratio at 100vw would make
|
||||
the image ~600-900px tall and dominate the page, so height is
|
||||
capped and object-cover crops it into a supporting banner
|
||||
instead; at sm:+ (grid, image only 58% width) the full aspect
|
||||
ratio looks right again, so the cap is lifted. No shadow: a
|
||||
plain box-shadow reads as a hard rectangular edge against the
|
||||
existing corner/right/bottom mask-gradient fade below, which
|
||||
looked worse than no shadow at all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below sm: — whileInView's -80px
|
||||
viewport margin means the image doesn't fade in until scrolled
|
||||
that much further into view; on a short mobile viewport this
|
||||
image sits right at the initial fold, so it stayed at
|
||||
opacity:0 (a white gap, matching the section's own bg-bg-base)
|
||||
above the fold until the user scrolled (reported 2026-07-24).
|
||||
Plain, always-visible image below md: instead; Reveal's fade
|
||||
kept from md: up, where the image is beside the text with
|
||||
Plain, always-visible image below sm: instead; Reveal's fade
|
||||
kept from sm: up, where the image is beside the text with
|
||||
plenty of room and this was never an issue. */}
|
||||
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<div className="order-2 sm:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
|
||||
className="hidden sm:block sm:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<HeroImage />
|
||||
|
||||
@@ -6,6 +6,8 @@ import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -58,7 +60,7 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/lebensuhr", "/newsletter"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -131,6 +133,40 @@ function AccountLink() {
|
||||
);
|
||||
}
|
||||
|
||||
// Wishlist icon + count badge — only rendered by the caller when
|
||||
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
|
||||
// Account+Cart are the only always-visible icons on true mobile (see the
|
||||
// trailing-controls group's own comment: gap-2 there was already tuned
|
||||
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
|
||||
// smallest phone widths risks the exact nav-overflow class of bug
|
||||
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
|
||||
// thresholds, not assumed ones). sm+ has real room to spare.
|
||||
function WishlistLink() {
|
||||
const { count } = useWishlist();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/konto/merkliste"
|
||||
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
|
||||
className="relative hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 20 18" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{count > 0 && (
|
||||
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Cart icon + count badge — traced from the Figma Navbar/Default component's
|
||||
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
|
||||
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
|
||||
@@ -210,7 +246,15 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
|
||||
export function Navbar({
|
||||
singleActiveProduct,
|
||||
wishlistEnabled,
|
||||
searchEnabled,
|
||||
}: {
|
||||
singleActiveProduct: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
searchEnabled: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
@@ -383,6 +427,16 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
// header itself to be a flexible column that could grow for it — it's
|
||||
// now a fixed-position sibling instead (see that panel's own comment
|
||||
// on why), so this is back to a plain fixed-height bar.
|
||||
//
|
||||
// onClick here closes the drawer on any click that lands on the bar
|
||||
// itself (logo/cart/account/CTAs already call closeMobile() from
|
||||
// their own onClick, so this mainly covers clicking empty space in
|
||||
// the bar) — safe as a catch-all specifically because the panel is
|
||||
// a sibling, not a descendant, so a click inside the open drawer
|
||||
// never bubbles up to this handler. The hamburger's own onClick
|
||||
// stops propagation so toggling it open doesn't immediately get
|
||||
// undone by this same handler.
|
||||
onClick={() => mobileOpen && setMobileOpen(false)}
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-bg-base/80 backdrop-blur-md"
|
||||
@@ -428,7 +482,17 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Figma's Navbar-Mobile component set exactly, see the plan.
|
||||
Matches the Figma NavLink component's hover: text never
|
||||
changes color, an underline (brand, 2px x 40px) fades in on
|
||||
hover and stays on for the active section. */}
|
||||
hover and stays on for the active section.
|
||||
|
||||
Deliberate exception to the site-wide sm: (640px) structural
|
||||
consolidation (see the 640px-breakpoint plan): this md:/lg:
|
||||
3-tier scheme (hamburger-only <768, hamburger+inline-CTAs
|
||||
768-1023, full-inline ≥1024) is untouched by that migration.
|
||||
Horizontal nav-link overflow is a different failure mode than
|
||||
the vertical grid/flex reflows the rest of the site has —
|
||||
there's no fluid token that shrinks link text to make a full
|
||||
inline nav fit at 640px, and nothing here was ever tied to
|
||||
the fluid token scale the way Hero/About/etc. were. */}
|
||||
<nav className="hidden lg:flex items-center gap-12">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
@@ -489,7 +553,9 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
built-in padding read as too much space on mobile, where
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center">
|
||||
{searchEnabled && <SearchButton />}
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
@@ -509,10 +575,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +591,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-panel"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileOpen((v) => !v);
|
||||
}}
|
||||
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="relative block h-4 w-6">
|
||||
@@ -571,7 +640,14 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
// Own (slower, gentler) transition for the exit — the shared
|
||||
// ease-out curve above is front-loaded (fast start, slow
|
||||
// finish), which reads great for the reveal but meant the
|
||||
// close shrank most of the way almost immediately, then
|
||||
// lingered on a barely-visible sliver — felt abrupt rather
|
||||
// than smooth. easeInOut plus a longer duration spreads the
|
||||
// shrink evenly instead.
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
@@ -658,11 +734,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
onClick={closeMobile}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -31,10 +31,10 @@ type NewsletterProps = {
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
title = "Kleine Impulse – große Wirkung",
|
||||
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
@@ -43,17 +43,35 @@ export function Newsletter({
|
||||
{/* Outer section padding — same fluid horizontal padding as all other sections */}
|
||||
<div className="px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
{/* max-w-[1280px] mx-auto — the consistent default width for this
|
||||
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
|
||||
section (same 1280px cap Footer.tsx already uses) and every
|
||||
other narrow-viewport-friendly section on the site. Previously
|
||||
unbounded here, so on wide desktop viewports this card stretched
|
||||
edge-to-edge while /lebensuhr's version stayed centered and
|
||||
noticeably narrower — reported 2026-07-29. */}
|
||||
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
|
||||
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
|
||||
relying purely on items-center + the taller column's own
|
||||
height to create top/bottom breathing room) — the form column
|
||||
(input, checkbox+label, privacy note) can be as tall as or
|
||||
taller than the copy column depending on viewport width, so
|
||||
centering alone left no slack to distribute and the input/
|
||||
"Keine Werbung" note sat flush against the card's top/bottom
|
||||
edge. */}
|
||||
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below md: — side by
|
||||
side they squeezed the text into a ~164px column on a 375px
|
||||
phone (icon width + gap eating most of the card's inner
|
||||
width), wrapping awkwardly. Row layout with the icon beside
|
||||
the text is fine again from md+, where the fixed copy-column
|
||||
width leaves real room. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full md:flex-row md:items-start md:gap-8 md:text-left md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from sm+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below sm: — side by
|
||||
side they squeezed the text into a narrow column on a phone
|
||||
(icon width + gap eating most of the card's inner width),
|
||||
wrapping awkwardly, and the same squeeze reappeared through
|
||||
the whole tablet range (640-1023px) once the outer card's own
|
||||
sm:flex-row already put this copy column next to the form —
|
||||
row layout for icon+text is only comfortable once there's
|
||||
real room, i.e. lg+. Left-aligned (not centered) from sm up —
|
||||
only true mobile keeps the centered treatment. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
@@ -89,22 +107,24 @@ export function Newsletter({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
{/* Right: form — takes remaining space, centered vertically from sm+ */}
|
||||
<div className="flex w-full sm:flex-1 items-center sm:self-stretch min-w-0">
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below lg: (was md:).
|
||||
The card above already goes side-by-side at md: with a
|
||||
fixed-width copy column (--newsletter-copy-width), which
|
||||
only leaves ~200px for this form column at 768px — not
|
||||
enough room for input+button side by side. Stacked
|
||||
through the whole Tablet range instead, side by side
|
||||
again once the form column has real room at lg:. */}
|
||||
{/* Input + submit button — stacked below lg:, a deliberate
|
||||
exception to the site's sm: (640px) structural
|
||||
consolidation, not a leftover of it. The card above
|
||||
already goes side-by-side at sm: with a fixed-width
|
||||
copy column (--newsletter-copy-width), which only
|
||||
leaves ~128px for this form column at 640px and
|
||||
~197px at 768px — not enough room for input+button
|
||||
side by side even at the new, lower floor. Stacked
|
||||
through the whole 640-1023px range instead, side by
|
||||
side again once the form column has real room (≈333px)
|
||||
at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
@@ -140,7 +160,7 @@ export function Newsletter({
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
|
||||
@@ -10,7 +10,7 @@ const features = [
|
||||
{
|
||||
icon: "/icon-sparkle-wrapper.svg",
|
||||
title: "7 Tage. Ein Fokus.",
|
||||
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-checklist.png",
|
||||
@@ -35,7 +35,7 @@ const features = [
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
@@ -144,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
// Capped narrower than the Desktop 75rem through the whole
|
||||
// 640-1023px Tablet band — this dialog is a modal, not a page
|
||||
// section, so at Tablet it should read as a compact centered
|
||||
// card, not stretch to near-full-viewport-width once stacked
|
||||
// single-column (see the flex-col/flex-row split below):
|
||||
// full-bleed-width + a single stacked photo/text column made
|
||||
// the photo huge and the text below it look lost/disconnected
|
||||
// (reported 2026-07-29, after first trying a lg:-gated
|
||||
// structural stack with no width cap). max-w-[75rem] only
|
||||
// takes over once the 2-column split itself starts at lg:.
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* sticky, not absolute — the dialog itself is the scrolling
|
||||
container (overflow-y-auto above), so an absolute-positioned
|
||||
child scrolls away with the rest of the content instead of
|
||||
staying pinned to the visible top-right corner (reported
|
||||
2026-07-29). sticky top-6 keeps it fixed to the scrolled
|
||||
viewport's top edge; ml-auto pushes it to the right within
|
||||
the dialog's normal block flow (sticky positioning doesn't
|
||||
use right-* the way absolute does); -mb-6 cancels its own
|
||||
height (size-6 = 1.5rem) so it doesn't push the modal-top
|
||||
content below it down — same visual overlap as the old
|
||||
absolute positioning, just still visible after scrolling. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
>
|
||||
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||||
{/* modal-top: photo + copy/form, stacked below lg: — paired with
|
||||
the dialog's own narrower max-w-[36rem] cap through Tablet
|
||||
(see above), so the stacked photo stays a reasonably-sized
|
||||
4:3 banner instead of blowing up to near-full-viewport-width. */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
|
||||
<Image
|
||||
src="/newsletter-modal-photo.jpg"
|
||||
alt="Notizbuch mit Kaffee und Stift"
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
sizes="(min-width: 1024px) 50vw, 36rem"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -172,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden md:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
flipped. Hidden below lg: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden lg:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
@@ -182,20 +206,18 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||||
Kleine Impulse – große Wirkung
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-primary">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
|
||||
</p>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
@@ -209,9 +231,13 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
{/* Always rendered (min-h reserves one line's worth of
|
||||
space) rather than conditionally mounted — this sits
|
||||
inside the same row the photo on the left stretches
|
||||
to match (items-stretch, md:aspect-auto), so an error
|
||||
popping in and out used to grow/shrink the whole
|
||||
modal, visibly resizing the photo along with it. */}
|
||||
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
@@ -225,7 +251,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
@@ -241,9 +267,12 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
{/* Same reserved-space fix as emailError above — this is
|
||||
the "already subscribed" message, the one that actually
|
||||
prompted it. */}
|
||||
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
|
||||
{status === "error" ? error : ""}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
@@ -258,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
items-start + a fixed icon bounding box (icons have different
|
||||
native proportions, e.g. the sparkle glyph isn't square) fixes
|
||||
it without needing the flip trick. */}
|
||||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
|
||||
{/* Same lg: exception as modal-top above, for the same narrower-
|
||||
dialog-width-through-Tablet reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
|
||||
@@ -2,7 +2,8 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
@@ -23,11 +24,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
@@ -42,14 +44,22 @@ export async function ProductSpotlight() {
|
||||
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
|
||||
// section instead of navigating to /shop whenever exactly 1 product is
|
||||
// active (see Navbar.tsx/layout.tsx).
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
|
||||
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
|
||||
at 640px, but a 380px-wide image + text squeezed into the rest of
|
||||
a tablet-width viewport (768-1023px) read as cramped/too wide.
|
||||
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
|
||||
and lg keeps that stacked card centered and narrower than the
|
||||
tablet viewport instead of stretching to fill it — true mobile
|
||||
(below sm) stays unconstrained since the viewport itself is
|
||||
already narrow there. */}
|
||||
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
|
||||
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
@@ -63,6 +73,9 @@ export async function ProductSpotlight() {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && product.spotlightShowWishlist && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
|
||||
@@ -86,10 +99,18 @@ export async function ProductSpotlight() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { SearchResult } from "../api/search/route";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
export function SearchButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* hidden sm: — same reasoning as Navbar's WishlistLink: Account+Cart
|
||||
are the only always-visible icons on true mobile, a 3rd icon
|
||||
there risks the same computed nav-overflow class of bug the
|
||||
figma-to-nextjs skill documents. sm+ has real room to spare. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Suche öffnen"
|
||||
className="hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && <SearchOverlay onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
|
||||
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
|
||||
setResults(data.results ?? []);
|
||||
setLoading(false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const products = results.filter((r) => r.type === "product");
|
||||
const posts = results.filter((r) => r.type === "post");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
|
||||
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Produkte, Blogbeiträge…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
|
||||
{loading && <p className="text-body-sm text-text-muted">Suche…</p>}
|
||||
{!loading && query.trim().length >= 2 && results.length === 0 && (
|
||||
<p className="text-body-sm text-text-muted">Keine Treffer für „{query}“.</p>
|
||||
)}
|
||||
|
||||
{products.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
|
||||
{products.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
|
||||
{posts.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
href={result.href}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
|
||||
</div>
|
||||
<span className="text-body text-text-primary">{result.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +61,9 @@ function useActiveSection(sections: TOCSection[]) {
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
// even the site's 640px structural floor, so sm: wouldn't leave room for
|
||||
// a real 2-column split at Tablet widths — a deliberate exception to the
|
||||
// site-wide sm: consolidation, not a leftover of it.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
|
||||
@@ -15,7 +15,7 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
if (testimonials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
@@ -24,11 +24,11 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.id}
|
||||
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group relative sm:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
@@ -20,33 +20,45 @@ export async function Tools() {
|
||||
{/* Section header */}
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Meine Werkzeuge
|
||||
Werkzeuge
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-text-primary text-h-section"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Werkzeuge für einen leichteren Alltag.
|
||||
Für mehr Orientierung im Alltag
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
|
||||
stays horizontal at every size, only the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
|
||||
arrives where the fluid floor also sits), stacked below sm;
|
||||
internal icon+text layout stays horizontal at every size, only
|
||||
the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-10 sm:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{tools.map((tool) => (
|
||||
<RevealItem
|
||||
key={tool.id}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
// Icon above text below lg (1024px) — a 3-up grid from sm
|
||||
// (640px) leaves each card too narrow for icon+text side by
|
||||
// side through the whole 640-1023px range. Centered on true
|
||||
// mobile (matches the rest of this stacked-card pattern
|
||||
// site-wide), left-aligned from sm up once there's a real
|
||||
// single-column card width to left-align within.
|
||||
className="sm:col-span-4 flex flex-col items-center text-center gap-4 sm:items-start sm:text-left lg:flex-row lg:gap-8 rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset.
|
||||
size-14 (56px) is a fixed value at every width (14 isn't
|
||||
one of this project's fluid spacing-scale steps) — smaller
|
||||
below md: so it doesn't dwarf the title/description text,
|
||||
below lg: so it doesn't dwarf the title/description text,
|
||||
which does shrink toward its own fluid floor there. Full
|
||||
56px only from lg: up — at md: (Tablet, where this grid
|
||||
already switches to 3-up) the title/description are still
|
||||
fairly close to their own fluid floor, so the full-size
|
||||
icon read as too big next to them too. */}
|
||||
56px only from lg: up — a deliberate exception to the
|
||||
site's sm: (640px) structural consolidation: the grid now
|
||||
switches to 3-up at sm:, but title/description are still
|
||||
fairly close to their own fluid floor through the whole
|
||||
640-1023px range, so the full-size icon still reads too big
|
||||
next to them there. Not re-verified visually — narrower
|
||||
exception kept as-is, same reasoning as Hero's content
|
||||
sizing. */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
|
||||
</div>
|
||||
@@ -63,8 +75,8 @@ export async function Tools() {
|
||||
minimum text→CTA gap even for the tallest card (the one
|
||||
that defines the row height, and so has ~zero leftover
|
||||
space for justify-between to distribute on its own). */}
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-center text-center sm:items-start sm:text-left gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-center sm:items-start w-full">
|
||||
<p
|
||||
className="font-semibold leading-normal text-h-section w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
|
||||
@@ -11,21 +11,58 @@ export async function TrustRow() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-start md:items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
|
||||
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
|
||||
not a fixed min-height guess — so every badge's "icon+title" box
|
||||
shares the SAME row track height (auto-sized to whichever
|
||||
badge's title actually needs 2 lines), and every description
|
||||
starts exactly at that row's bottom edge regardless of how many
|
||||
lines its own title happens to wrap to. Icon beside text (the
|
||||
lg: layout below) is what made a full row of 3 badges too wide
|
||||
below ~1024px in the first place (see git history: originally
|
||||
lg:-gated for exactly this, then briefly tried flex-wrap, which
|
||||
put an odd 3rd item alone on its own wrapped line and read as
|
||||
disorganized) — icon above text instead shrinks each badge down
|
||||
to just its text column's width, letting a real row of 3 fit
|
||||
without wrapping at all. Two structurally different layouts
|
||||
(icon-above-title here vs. icon-beside-a-title/description-
|
||||
stack at lg:) don't share one flexible markup shape cleanly, so
|
||||
this renders as two separate blocks (lg:hidden / hidden lg:flex)
|
||||
rather than fighting one shape across both breakpoints. */}
|
||||
<div
|
||||
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
|
||||
<div className="hidden lg:flex justify-center gap-12 items-center">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-12">
|
||||
{i > 0 && <div className="h-10 w-px bg-border" />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
|
||||
// Heart-toggle for a product card/detail page. Login-gated (unlike the
|
||||
// cart, which works for guests) — a logged-out click redirects to
|
||||
// /konto/login?redirect=<back-here> instead of silently failing, since
|
||||
// there's no local-storage fallback that would make sense for a wishlist
|
||||
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
|
||||
// guest-friendly pattern).
|
||||
export function WishlistButton({
|
||||
productId,
|
||||
variant = "",
|
||||
className = "",
|
||||
revealOnHover = false,
|
||||
}: {
|
||||
productId: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
/** false (default): always visible — right for single-product contexts
|
||||
* (ProductSpotlight, /konto/merkliste, the product detail page) where
|
||||
* there's no "wall of hearts" to thin out. true: invisible until the
|
||||
* card is hovered/focused, unless the product is already wishlisted (a
|
||||
* filled heart stays as a permanent status indicator) — right for a
|
||||
* multi-card grid (ProductGrid.tsx), where a heart on every single card
|
||||
* reads as visual noise (Marco: "sieht man überall Herzen", 2026-07-31).
|
||||
* Relies on the parent card already carrying `group`/`focus-within`
|
||||
* (see ProductGrid.tsx) — Tailwind's plain `:hover`, so tapping a card
|
||||
* on touch devices reveals it the same way the existing
|
||||
* `group-hover:-translate-y-1` card-lift already does, no separate
|
||||
* touch handling needed. */
|
||||
revealOnHover?: boolean;
|
||||
}) {
|
||||
const { isWishlisted, toggle } = useWishlist();
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const wishlisted = isWishlisted(productId, variant);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pending) return;
|
||||
|
||||
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
|
||||
if (res.status === 401) {
|
||||
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
await toggle(productId, variant);
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
|
||||
aria-pressed={wishlisted}
|
||||
disabled={pending}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 ${
|
||||
revealOnHover && !wishlisted
|
||||
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
|
||||
: ""
|
||||
} ${className}`}
|
||||
>
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import {
|
||||
renderOrderConfirmationHtml,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
renderOrderStatusHtml,
|
||||
ORDER_STATUS_EMAIL_ICON,
|
||||
SAMPLE_ORDER,
|
||||
SAMPLE_ORDER_MANUAL,
|
||||
type EmailTemplateContent,
|
||||
} from "../../../lib/emailTemplates";
|
||||
import type { EmailTemplateType } from "../../../lib/payload";
|
||||
@@ -34,13 +36,19 @@ export function LiveEmailPreviewClient({
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
// Only order-confirmation has two meaningfully different rendered
|
||||
// states (isManualPayment true/false change which blocks show at all,
|
||||
// not just text) — every other type has one sample and no toggle.
|
||||
const [sampleVariant, setSampleVariant] = useState<"paid" | "manual">("paid");
|
||||
const orderSample = sampleVariant === "paid" ? SAMPLE_ORDER : SAMPLE_ORDER_MANUAL;
|
||||
|
||||
// No real company-settings fetch in this preview context — passing null
|
||||
// falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
|
||||
// inside buildLegalFooterLines(), same shape as the real send just with
|
||||
// placeholder business data.
|
||||
const html =
|
||||
type === "order-confirmation"
|
||||
? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
|
||||
? renderOrderConfirmationHtml(data, orderSample, null)
|
||||
: type === "password-reset"
|
||||
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
|
||||
: renderOrderStatusHtml(
|
||||
@@ -53,7 +61,54 @@ export function LiveEmailPreviewClient({
|
||||
|
||||
return (
|
||||
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{type === "order-confirmation" && (
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("paid")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "paid" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Online bezahlt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("manual")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "manual" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Vorkasse (Überweisung)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* An iframe, not dangerouslySetInnerHTML into a plain div — `html`
|
||||
here is a full `<body>...</body>` fragment (see emailTemplates.ts's
|
||||
emailShell()), meant to become an actual email document. Dropped
|
||||
directly into this page's own already-existing <body> via
|
||||
dangerouslySetInnerHTML, that's a nested <body> tag — invalid
|
||||
HTML the browser "fixes" unpredictably, which is why this preview
|
||||
used to render broken (wrong background/padding/font, inline
|
||||
styles not applying). An iframe gives the email HTML its own
|
||||
real document, exactly like an actual email client would. */}
|
||||
<iframe
|
||||
srcDoc={`<!DOCTYPE html><html>${html}</html>`}
|
||||
title="E-Mail-Vorschau"
|
||||
style={{ width: "100%", height: "100vh", border: "none", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-cancelled",
|
||||
"order-return-requested",
|
||||
"order-returned",
|
||||
"order-tracking-added",
|
||||
"order-tracking-corrected",
|
||||
"order-delivered",
|
||||
"payment-method-switched",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
@@ -23,6 +27,10 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
"order-cancelled": "Deine Bestellung wurde storniert",
|
||||
"order-return-requested": "Deine Rücksendung wurde angefragt",
|
||||
"order-returned": "Deine Retoure wurde bearbeitet",
|
||||
"order-tracking-added": "Hier ist deine Sendungsnummer",
|
||||
"order-tracking-corrected": "Korrigierte Sendungsnummer",
|
||||
"order-delivered": "Dein Paket ist angekommen",
|
||||
"payment-method-switched": "Erledigt!",
|
||||
};
|
||||
|
||||
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -2,10 +2,10 @@
|
||||
|
||||
/*
|
||||
* Fluid design tokens — every value below scales continuously via clamp()
|
||||
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
|
||||
* the clamp() floor freezes the value automatically (no separate mobile
|
||||
* media query needed for sizing — only for structural layout changes,
|
||||
* see the `md:` breakpoint usage in components).
|
||||
* between the structural breakpoint (640px) and Desktop (1440px). Below
|
||||
* 640px the clamp() floor freezes the value automatically (no separate
|
||||
* mobile media query needed for sizing — only for structural layout
|
||||
* changes, see the `sm:` breakpoint usage in components).
|
||||
*
|
||||
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
|
||||
* exact numbers the styleguide already used (n times 0.25rem equals the
|
||||
@@ -53,10 +53,10 @@
|
||||
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
|
||||
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
|
||||
|
||||
/* Type scale (fluid 768->1440) */
|
||||
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
|
||||
/* Type scale (fluid 640->1440) */
|
||||
--text-display: clamp(2.75rem, 1.75rem + 2.5vw, 4rem);
|
||||
--text-display--line-height: 1.1;
|
||||
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
|
||||
--text-h-feature: clamp(1.875rem, 1.375rem + 1.25vw, 2.5rem);
|
||||
--text-h-feature--line-height: 1.2;
|
||||
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
|
||||
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
|
||||
@@ -67,21 +67,21 @@
|
||||
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
|
||||
by page-weekly-impulses's hero heading — same 48px role, reused
|
||||
rather than minting a near-duplicate token. */
|
||||
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
|
||||
--text-h-page: clamp(2.5rem, 2.1rem + 1vw, 3rem);
|
||||
--text-h-page--line-height: 1.15;
|
||||
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
|
||||
--text-h1: clamp(1.75rem, 1.35rem + 1vw, 2.25rem);
|
||||
--text-h1--line-height: 1.15;
|
||||
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--text-h2: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
--text-h2--line-height: 1.25;
|
||||
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
|
||||
--text-h-section: clamp(1.4375rem, 1.1875rem + 0.625vw, 1.75rem);
|
||||
--text-h-section--line-height: 1.3;
|
||||
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--text-h-emphasis: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--text-h-emphasis--line-height: 1.4;
|
||||
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
|
||||
--text-h3: clamp(1.1875rem, 1.0375rem + 0.375vw, 1.375rem);
|
||||
--text-h3--line-height: 1.35;
|
||||
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
|
||||
--text-h-small: clamp(1.125rem, 1.025rem + 0.25vw, 1.25rem);
|
||||
--text-h-small--line-height: 1.4;
|
||||
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
|
||||
--text-h4: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
|
||||
--text-h4--line-height: 1.4;
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.6;
|
||||
@@ -90,21 +90,21 @@
|
||||
--text-label: 0.75rem;
|
||||
--text-label--line-height: 1.4;
|
||||
|
||||
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
|
||||
/* Spacing scale (fluid 640->1440) — overrides Tailwind's default n*0.25rem
|
||||
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
|
||||
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
|
||||
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.775rem + 0.25vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.8rem + 0.5vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 1.1rem + 1vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 1.15rem + 1.5vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 1.2rem + 2vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 1.3rem + 3vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 1.4rem + 4vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 1.5rem + 5vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 1.2rem + 7vw, 7.5rem);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
@@ -116,18 +116,22 @@
|
||||
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
|
||||
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
|
||||
--layout-max-width: 75rem; /* 1200px */
|
||||
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
|
||||
--layout-padding-x: clamp(2rem, -0.4rem + 6vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.6rem + 1vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.65rem + 1.5vw, 2rem);
|
||||
|
||||
/* One-off fluid values that aren't part of a broader scale */
|
||||
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
|
||||
--hero-avatar-size: clamp(2.25rem, 1.8rem + 1.125vw, 2.8125rem);
|
||||
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
|
||||
room for the form column at the 768px tablet floor (only 656px of
|
||||
content width available for 612px column plus 56px gap), crushing the
|
||||
input/button/privacy text to near-zero width and overflowing the page.
|
||||
Scales down to 23rem at 768px instead. */
|
||||
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
|
||||
room for the form column at the 640px structural floor (only ~576px of
|
||||
content width available for a 612px column plus gap at that width),
|
||||
crushing the input/button/privacy text to near-zero width and
|
||||
overflowing the page. Scales down to 23rem at 640px instead. Note: the
|
||||
inner input+button row still switches to a row layout at `lg:` (1024px),
|
||||
not at the site's `sm:` (640px) structural line — even at this reduced
|
||||
floor there isn't enough room for input+button side by side below
|
||||
~900px, see Newsletter.tsx's own comment. */
|
||||
--newsletter-copy-width: clamp(23rem, 10.8rem + 30.5vw, 38.25rem);
|
||||
|
||||
/* Divider section icons (arrow separators, sparkle) — previously hard
|
||||
rem values while the "Klarheit/Fokus/Entlastung" text next to them
|
||||
@@ -135,30 +139,30 @@
|
||||
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
|
||||
of this file, floor ~82.5% of the Desktop value per the styleguide's
|
||||
80-85% Mobile-ratio guidance. */
|
||||
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
|
||||
--divider-arrow-w: clamp(2.125rem, 1.725rem + 1vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.2775rem + 0.7125vw, 1.9188rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.7075rem + 0.8875vw, 2.5063rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.2003rem + 0.593vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.5875rem + 0.875vw, 2.375rem);
|
||||
/* Own token, mirrors --text-h2's clamp() exactly rather than the Word
|
||||
component reading var(--text-h2) directly — that's what lets the
|
||||
mobile override below shrink just this component's words without
|
||||
touching every other text-h2 heading site-wide. */
|
||||
--divider-word-size: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--divider-word-size: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
}
|
||||
|
||||
/* This project's fluid() scale (see fluid.ts) is calibrated for the
|
||||
768-1440px Tablet-Desktop range and floors out at the 768px value for
|
||||
any narrower viewport (clamp()'s MIN bound) — by design, see the other
|
||||
fluid tokens above. Divider is the one spot that floor doesn't work:
|
||||
the "Klarheit → Fokus → Entlastung" phrase plus its connector icons
|
||||
needs ~550px of width to lay out on one row even at the 768px floor
|
||||
size, far more than a phone's ~310px content width. Below Tailwind's
|
||||
sm: breakpoint, shrink these tokens further so the phrase gets much
|
||||
closer to fitting on one row instead of stacking into three separate
|
||||
centered lines (see Divider.tsx's gap-x-3/gap-3 mobile overrides,
|
||||
same breakpoint). Scoped to these component-only tokens, not
|
||||
640-1440px structural-to-Desktop range and floors out at the 640px value
|
||||
for any narrower viewport (clamp()'s MIN bound) — by design, see the
|
||||
other fluid tokens above. Divider is the one spot that floor doesn't
|
||||
work: the "Klarheit → Fokus → Entlastung" phrase plus its connector
|
||||
icons needs ~550px of width to lay out on one row even at the 640px
|
||||
floor size, far more than a phone's ~310px content width. Below
|
||||
Tailwind's sm: breakpoint, shrink these tokens further so the phrase
|
||||
gets much closer to fitting on one row instead of stacking into three
|
||||
separate centered lines (see Divider.tsx's gap-x-3/gap-3 mobile
|
||||
overrides, same breakpoint). Scoped to these component-only tokens, not
|
||||
--text-h2 itself. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect x="0" y="0" width="64" height="64" rx="14" fill="#f6a701" />
|
||||
<text
|
||||
x="32"
|
||||
y="46"
|
||||
text-anchor="middle"
|
||||
font-family="Georgia, 'Playfair Display', 'Times New Roman', serif"
|
||||
font-weight="700"
|
||||
font-style="italic"
|
||||
font-size="42"
|
||||
fill="#1a1a1a"
|
||||
>e</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -35,7 +35,8 @@ import type { TOCSection } from "../../components/SectionTOC";
|
||||
// basis first, and don't assume the old attempt's reasoning was correct.
|
||||
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
|
||||
if (!seller) return [];
|
||||
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
|
||||
const sections = ["Angaben zum Anbieter"];
|
||||
if (seller.vatId) sections.push("Umsatzsteuer");
|
||||
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
|
||||
if (seller.managingDirector) sections.push("Geschäftsführung");
|
||||
sections.push("Verantwortlich für den Inhalt");
|
||||
@@ -75,14 +76,20 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</P>
|
||||
<P>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
{seller.vatId && (
|
||||
<>
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{seller.registerCourt && seller.registerNumber && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
|
||||
|
||||
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
|
||||
// eligibility check, mirroring api/account/orders/[orderNumber]/
|
||||
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
|
||||
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
|
||||
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
|
||||
// once this endpoint hands back a clientSecret — the order already
|
||||
// exists, this only changes how it gets paid.
|
||||
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
|
||||
const [state, setState] = useState<
|
||||
| { step: "idle" }
|
||||
| { step: "loading" }
|
||||
| { step: "error"; reason: string }
|
||||
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
|
||||
>({ step: "idle" });
|
||||
|
||||
async function start() {
|
||||
setState({ step: "loading" });
|
||||
try {
|
||||
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
step: "paying",
|
||||
clientSecret: data.clientSecret,
|
||||
orderId: data.orderId,
|
||||
testMode: Boolean(data.testMode),
|
||||
providerReference: data.providerReference,
|
||||
});
|
||||
} catch {
|
||||
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
|
||||
}
|
||||
}
|
||||
|
||||
if (state.step === "paying") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Mit Kreditkarte/PayPal bezahlen</p>
|
||||
<PaymentStep
|
||||
clientSecret={state.clientSecret}
|
||||
orderNumber={orderNumber}
|
||||
orderId={state.orderId}
|
||||
testMode={state.testMode}
|
||||
providerReference={state.providerReference}
|
||||
returnContext="account"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={state.step === "loading"}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
|
||||
</button>
|
||||
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { Footer } from "../../../components/Footer";
|
||||
import { VatBreakdown } from "../../../components/VatBreakdown";
|
||||
import { formatPrice, formatDate } from "../../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
|
||||
import { getProductImagesByIds } from "../../../lib/payload";
|
||||
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
|
||||
|
||||
// Dynamic (was a static "Bestelldetails" title despite this being a
|
||||
// per-order route) — just formats the already-known order number into
|
||||
@@ -33,7 +35,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
|
||||
if (!order) notFound();
|
||||
|
||||
const address =
|
||||
@@ -46,7 +48,16 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
|
||||
// itself re-checks authoritatively — only offer the button when it
|
||||
// would actually succeed.
|
||||
const canSwitchPayment =
|
||||
order.paymentProvider === "manual" &&
|
||||
order.status === "received" &&
|
||||
(order.paymentStatus === "pending" || order.paymentStatus === "not_applicable") &&
|
||||
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,6 +84,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-label text-text-muted">Zahlungsart</p>
|
||||
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.trackingNumber && (
|
||||
@@ -91,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhlReturnLabel && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
|
||||
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
|
||||
Retourenschein herunterladen
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{/* Labeled "Rechnungsadresse" only once there's an actual
|
||||
second (shipping) address to distinguish it from — the
|
||||
@@ -120,6 +144,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
@@ -127,6 +152,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
{(order.shippingContactEmail || order.shippingContactPhone) && (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,6 +175,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</p>
|
||||
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
@@ -220,12 +251,17 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
{(action || canSwitchPayment) && (
|
||||
<div className="flex flex-wrap gap-3 items-start w-full">
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { CustomSelect } from "../../../components/CustomSelect";
|
||||
|
||||
// Three custom-styled dropdowns in a row instead of a wall of filter
|
||||
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
|
||||
// payment-status options + N years, a chip per option read as cluttered
|
||||
// and ate a lot of vertical space) or plain native <select>s (tried next,
|
||||
// also reverted the same day — a native <select>'s open options popup is
|
||||
// rendered by the browser/OS and can't be styled at all, so it looked
|
||||
// completely off-brand next to everything else on the page; see
|
||||
// CustomSelect.tsx for the fully custom-styled replacement). Client
|
||||
// Component only for the onChange→navigate wiring; the actual filtering
|
||||
// still happens server-side in page.tsx via the same URL search params,
|
||||
// so this stays a plain GET-style filter (shareable/bookmarkable/
|
||||
// back-button-safe), not client-side state.
|
||||
export function OrderFilters({
|
||||
statusOptions,
|
||||
paymentStatusOptions,
|
||||
years,
|
||||
}: {
|
||||
statusOptions: { value: string; label: string }[];
|
||||
paymentStatusOptions: { value: string; label: string }[];
|
||||
years: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const paymentStatus = searchParams.get("paymentStatus") ?? "";
|
||||
const year = searchParams.get("year") ?? "";
|
||||
const hasAnyFilter = Boolean(status || paymentStatus || year);
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
|
||||
}
|
||||
|
||||
const yearOptions = years.map((y) => ({ value: y, label: y }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
|
||||
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
|
||||
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
|
||||
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
|
||||
{hasAnyFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/konto/bestellungen")}
|
||||
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrders,
|
||||
getCustomerOrderYears,
|
||||
ORDER_STATUS_LABEL,
|
||||
} from "../../lib/customerAuth";
|
||||
import { getOrderFilterEnabled } from "../../lib/payload";
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
import { OrderFilters } from "./components/OrderFilters";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
@@ -18,11 +27,37 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoBestellungenPage() {
|
||||
// Mirrors PaymentStatusBadge's own grouping ("Offen" covers both
|
||||
// not_applicable and pending) — see getCustomerOrders's OPEN_PAYMENT_STATUSES.
|
||||
const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
|
||||
open: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ({ value, label }));
|
||||
const PAYMENT_STATUS_OPTIONS = Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default async function KontoBestellungenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; paymentStatus?: string; year?: string }>;
|
||||
}) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderFilterEnabled = await getOrderFilterEnabled();
|
||||
const rawSearchParams = await searchParams;
|
||||
const status = orderFilterEnabled ? rawSearchParams.status : undefined;
|
||||
const paymentStatus = orderFilterEnabled ? rawSearchParams.paymentStatus : undefined;
|
||||
const year = orderFilterEnabled ? rawSearchParams.year : undefined;
|
||||
|
||||
const [orders, availableYears] = await Promise.all([
|
||||
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
|
||||
getCustomerOrderYears(session.token, session.customer.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,33 +70,63 @@ export default async function KontoBestellungenPage() {
|
||||
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
|
||||
</p>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
{orderFilterEnabled && availableYears.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<OrderFilters statusOptions={STATUS_OPTIONS} paymentStatusOptions={PAYMENT_STATUS_OPTIONS} years={availableYears} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{availableYears.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Keine Bestellungen gefunden, die zu den gewählten Filtern passen.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{orders.map((order) => (
|
||||
<Link
|
||||
key={order.orderNumber}
|
||||
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
|
||||
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 sm:gap-x-6 gap-y-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Below sm (640px): 2 columns, plain DOM order —
|
||||
Bestellnummer/Datum, Status/Zahlungsstatus,
|
||||
Artikel/Gesamtbetrag tile into exactly those 3 pairs
|
||||
on their own, no explicit placement needed. From sm
|
||||
up: 4 columns, every cell explicitly pinned to a
|
||||
row+column (sm:row-start-N sm:col-start-N) rather
|
||||
than relying on `order` + Grid's auto-placement —
|
||||
mixing `order` with only SOME items explicitly
|
||||
positioned is a real footgun: the auto-placement
|
||||
cursor for the un-pinned items starts scanning from
|
||||
column 1 again regardless of what's already
|
||||
explicitly placed elsewhere. Pinning every cell's row
|
||||
AND column explicitly removes that ambiguity
|
||||
entirely. Row 1: Bestellnummer/Datum in columns 1-2,
|
||||
left-aligned (centering them in columns 2-3 was
|
||||
tried and reverted — not wanted). Row 2: Artikel/
|
||||
Status/Zahlungsstatus/Gesamtbetrag fill all 4
|
||||
columns. */}
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Datum</p>
|
||||
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 ml-auto">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-3">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-4">
|
||||
<p className="text-label text-text-muted">Gesamtbetrag</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
@@ -70,7 +135,14 @@ export default async function KontoBestellungenPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* self-center below sm — this row otherwise inherits the parent
|
||||
Reveal's items-start (left-aligned); centered here per its
|
||||
own request, but only the row itself (self-center keeps its
|
||||
shrink-to-fit content width, doesn't stretch it to the full
|
||||
parent width the way w-full/justify-center on the parent
|
||||
would). Back to left-aligned (matching the rest of the page)
|
||||
from sm up. */}
|
||||
<div className="flex gap-6 self-center sm:self-start">
|
||||
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Profil & Adresse
|
||||
</Link>
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
// The local cart is already mirrored server-side by CartSync, so it's
|
||||
// safe to clear it here — the next login's mergeServerCartIntoLocal()
|
||||
// restores it from the server. Without this, the local cart survived
|
||||
// logout untouched, and mergeServerCartIntoLocal()'s additive merge
|
||||
// (existing.qty += qty) would add the already-synced server quantities
|
||||
// on top of it on every login, doubling every logout/login cycle.
|
||||
clearCart();
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
|
||||
@@ -20,7 +20,7 @@ const STYLES: Record<string, string> = {
|
||||
export function OrderStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{ORDER_STATUS_LABEL[status] ?? status}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Same visual pattern as OrderStatusBadge — but answers a different
|
||||
// question ("hat Stripe/die Buchhaltung eine Zahlung bestätigt?", not
|
||||
// "wo im Fulfillment steht die Bestellung"). 'not_applicable' (an
|
||||
// Überweisung order before payment is manually reconciled) reads as
|
||||
// "offen", same as a still-'pending' Stripe order — the customer doesn't
|
||||
// need to know the internal distinction between the two.
|
||||
const LABEL: Record<string, string> = {
|
||||
not_applicable: "Offen",
|
||||
pending: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STYLES: Record<string, string> = {
|
||||
not_applicable: "bg-red-50 text-red-600",
|
||||
pending: "bg-red-50 text-red-600",
|
||||
paid: "bg-success-subtle text-success",
|
||||
failed: "bg-red-50 text-red-600",
|
||||
refunded: "bg-bg-muted text-text-light",
|
||||
partially_refunded: "bg-orange-50 text-orange-600",
|
||||
};
|
||||
|
||||
export function PaymentStatusBadge({ paymentStatus }: { paymentStatus: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[paymentStatus] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{LABEL[paymentStatus] ?? paymentStatus}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
@@ -9,6 +9,12 @@ import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// WishlistButton (and anything else login-gated) sends the shopper back
|
||||
// here with ?redirect=<where they were>, e.g. a product page they
|
||||
// wanted to heart — previously ignored, always landing on
|
||||
// /konto/bestellungen regardless of where the login was triggered from.
|
||||
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +38,7 @@ export function LoginForm() {
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push("/konto/bestellungen");
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Login ist gerade nicht möglich.");
|
||||
@@ -81,11 +87,14 @@ export function LoginForm() {
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Noch kein Konto? Einfach beim{" "}
|
||||
<Link href="/checkout" className="underline hover:text-brand transition-colors">
|
||||
nächsten Einkauf
|
||||
</Link>{" "}
|
||||
anlegen.
|
||||
Noch kein Konto?{" "}
|
||||
<Link
|
||||
href={`/konto/registrieren${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
|
||||
className="underline hover:text-brand transition-colors"
|
||||
>
|
||||
Jetzt anlegen
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./components/LoginForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
@@ -16,7 +17,12 @@ export default function KontoLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<LoginForm />
|
||||
{/* Suspense required — LoginForm uses useSearchParams() (?redirect=,
|
||||
e.g. from WishlistButton's login-gate) which opts any consumer
|
||||
into client-side rendering unless wrapped. */}
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { RevealGroup, RevealItem } from "../../../components/Reveal";
|
||||
import { formatPrice, discountPercent } from "../../../lib/format";
|
||||
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../../components/WishlistButton";
|
||||
import { useWishlist } from "../../../lib/useWishlist";
|
||||
import { effectiveTaxRate } from "../../../lib/cartTotals";
|
||||
import type { Product } from "../../../lib/payload";
|
||||
|
||||
// Client Component so removing an item (WishlistButton toggling it off)
|
||||
// disappears from this grid immediately — the server-rendered initial
|
||||
// list alone doesn't react to that client-side toggle at all (the parent
|
||||
// page.tsx is a Server Component, its render is fixed at request time).
|
||||
// useWishlist()'s live `items` is the actual source of truth for which
|
||||
// products are still wishlisted; `initialProducts` only supplies the
|
||||
// display data (name/image/price) for whatever numericIds are currently
|
||||
// wishlisted, since the wishlist itself only stores product ids.
|
||||
export function MerklisteGrid({
|
||||
initialProducts,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
}) {
|
||||
const { items } = useWishlist();
|
||||
const wishlistedIds = new Set(items.map((i) => i.productId));
|
||||
// Preserve the wishlist's own order (most-recently-added-first, via
|
||||
// `items`) rather than initialProducts' own order.
|
||||
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
|
||||
const visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(p));
|
||||
|
||||
if (visibleProducts.length === 0) {
|
||||
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
{visibleProducts.map((product) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
return (
|
||||
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{fullyOutOfStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
)}
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.name}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
{/* No className override — AddToCartInlineButton's `className`
|
||||
prop REPLACES its whole default styling (`?? defaultClass`,
|
||||
not a merge), so passing just "w-full" here previously threw
|
||||
away all the button's actual styling. Its default is
|
||||
already `w-full`. */}
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
|
||||
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { MerklisteGrid } from "./components/MerklisteGrid";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /konto/bestellungen.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Merkliste",
|
||||
description: "Deine gemerkten Produkte bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoMerklistePage() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
// The feature can be switched off after a customer already had rows in
|
||||
// their wishlist — 404 rather than showing a stale page for a feature
|
||||
// that's no longer offered, same reasoning as any other feature-flagged
|
||||
// route in this codebase.
|
||||
const wishlistEnabled = await getWishlistEnabled();
|
||||
if (!wishlistEnabled) notFound();
|
||||
|
||||
const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getWishlist(session.token, session.customer.id),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[75rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Merkliste
|
||||
</p>
|
||||
|
||||
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
|
||||
<div className="flex gap-6 self-center sm:self-start">
|
||||
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Bestellungen
|
||||
</Link>
|
||||
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Weiter einkaufen
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function ProfileForm({
|
||||
shippingCountries: ShippingCountry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(profile.hasDifferentShippingAddress);
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
profile.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -49,15 +52,32 @@ export function ProfileForm({
|
||||
const body = {
|
||||
firstName: String(form.get("firstName") ?? ""),
|
||||
lastName: String(form.get("lastName") ?? ""),
|
||||
deliveryMethod,
|
||||
// Always a plain street address — same "Rechnungsadresse is never a
|
||||
// Packstation" rule as /checkout's own billing card (an invoice
|
||||
// needs a real postal address). Packstation is only ever offered
|
||||
// below, in the optional "Lieferadresse" section.
|
||||
deliveryMethod: "address" as const,
|
||||
street: String(form.get("street") ?? "") || undefined,
|
||||
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
|
||||
postNumber: String(form.get("postNumber") ?? "") || undefined,
|
||||
zip: String(form.get("zip") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
companyName: String(form.get("companyName") ?? "") || undefined,
|
||||
vatId: String(form.get("vatId") ?? "") || undefined,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName: hasDifferentShippingAddress ? String(form.get("shippingFirstName") ?? "") : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? String(form.get("shippingLastName") ?? "") : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? String(form.get("shippingCompanyName") ?? "") || undefined : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? String(form.get("shippingStreet") ?? "") || undefined : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress
|
||||
? String(form.get("shippingPackstationNumber") ?? "") || undefined
|
||||
: undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? String(form.get("shippingPostNumber") ?? "") || undefined : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? String(form.get("shippingZip") ?? "") : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? String(form.get("shippingCity") ?? "") : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? String(form.get("shippingCountry") ?? "") : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? String(form.get("shippingContactEmail") ?? "") || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? String(form.get("shippingContactPhone") ?? "") || undefined : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -91,6 +111,11 @@ export function ProfileForm({
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
|
||||
card title — without it, this section and the "Lieferadresse"
|
||||
one further down read as one undifferentiated form instead of
|
||||
two distinct addresses. */}
|
||||
<p className="font-semibold text-body-sm text-text-primary">Rechnungsadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
|
||||
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
|
||||
@@ -112,43 +137,18 @@ export function ProfileForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("address")}
|
||||
aria-pressed={deliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("packstation")}
|
||||
aria-pressed={deliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
|
||||
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
{/* Always a plain street address, no Lieferart/Packstation toggle —
|
||||
matches /checkout's own Rechnungsadresse card exactly (an
|
||||
invoice needs a real postal address). Packstation is only ever
|
||||
offered below, in the optional "Lieferadresse" section. */}
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
|
||||
@@ -164,6 +164,111 @@ export function ProfileForm({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasDifferentShippingAddress}
|
||||
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-body-sm text-text-primary">
|
||||
Abweichende Lieferadresse hinterlegen — wird beim Checkout vorgeschlagen, sobald dort "Abweichende Lieferadresse" aktiviert wird
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="shippingFirstName" type="text" defaultValue={profile.shippingFirstName ?? ""} required />
|
||||
<Field label="Nachname" name="shippingLastName" type="text" defaultValue={profile.shippingLastName ?? ""} required />
|
||||
</div>
|
||||
<Field
|
||||
label="Firma (optional)"
|
||||
name="shippingCompanyName"
|
||||
type="text"
|
||||
defaultValue={profile.shippingCompanyName ?? ""}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("address")}
|
||||
aria-pressed={shippingDeliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${shippingDeliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("packstation")}
|
||||
aria-pressed={shippingDeliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${shippingDeliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="shippingStreet"
|
||||
type="text"
|
||||
defaultValue={profile.shippingStreet ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Packstationnummer"
|
||||
name="shippingPackstationNumber"
|
||||
type="text"
|
||||
defaultValue={profile.shippingPackstationNumber ?? ""}
|
||||
required
|
||||
/>
|
||||
<Field label="Postnummer" name="shippingPostNumber" type="text" defaultValue={profile.shippingPostNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="shippingZip" type="text" defaultValue={profile.shippingZip ?? ""} required />
|
||||
<Field label="Ort" name="shippingCity" type="text" defaultValue={profile.shippingCity ?? ""} required />
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select name="shippingCountry" defaultValue={profile.shippingCountry ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
name="shippingContactEmail"
|
||||
type="email"
|
||||
defaultValue={profile.shippingContactEmail ?? ""}
|
||||
/>
|
||||
<Field
|
||||
label="Telefonnummer (optional)"
|
||||
name="shippingContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.shippingContactPhone ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
{success && <p className="text-label text-success">Gespeichert.</p>}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
// Standalone registration, independent of checkout — until the Wishlist
|
||||
// shipped, the only way to get a customer account was the inline
|
||||
// registration step inside checkout (see api/checkout/route.ts), which
|
||||
// made sense when an account only ever existed to hold an order. That
|
||||
// stopped being true the moment a shopper could want an account just to
|
||||
// save products to a Wishlist without buying anything yet.
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ firstName, lastName, email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Registrierung fehlgeschlagen.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Registrierung ist gerade nicht möglich.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Konto anlegen
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex gap-4 w-full">
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Vorname</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Nachname</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">E-Mail-Adresse</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Passwort</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "Einen Moment…" : "Konto anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Schon ein Konto?{" "}
|
||||
<Link
|
||||
href={`/konto/login${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
|
||||
className="underline hover:text-brand transition-colors"
|
||||
>
|
||||
Einloggen
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "./components/RegisterForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /konto/login.
|
||||
export const metadata: Metadata = {
|
||||
title: "Konto anlegen",
|
||||
description: "Lege ein Konto bei einfach produktiv an.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function KontoRegistrierenPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{/* Suspense required — RegisterForm uses useSearchParams() (?redirect=),
|
||||
same reasoning as /konto/login's own page. */}
|
||||
<Suspense fallback={null}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings } from "./lib/payload";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -66,8 +67,21 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const products = await getProducts();
|
||||
const [products, seller, wishlistEnabled, searchEnabled] = await Promise.all([
|
||||
getProducts(),
|
||||
getCompanySettings(),
|
||||
getWishlistEnabled(),
|
||||
getSearchEnabled(),
|
||||
]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
|
||||
// instead of repeating the full seller object per page (see
|
||||
// structuredData.ts's own comment). getCompanySettings() is already the
|
||||
// established pattern for a public page needing seller data server-side
|
||||
// (see /impressum) — only non-sensitive fields (name/address/email/
|
||||
// vatID) ever make it into the rendered schema, never iban/bic.
|
||||
const organizationSchema = buildOrganizationSchema(seller);
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -75,9 +89,10 @@ export default async function RootLayout({
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased scroll-smooth`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
</body>
|
||||
|
||||
@@ -13,11 +13,11 @@ function LockIcon() {
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.</p>;
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">{successMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -60,7 +60,7 @@ export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabe
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
@@ -18,12 +18,12 @@ export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/challenge",
|
||||
canonical: "/lebensuhr",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/challenge",
|
||||
url: "/lebensuhr",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
twitter: {
|
||||
@@ -152,7 +152,7 @@ export default async function ChallengePage() {
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">7-Tage-Challenge</span>
|
||||
<span className="text-text-primary">Lebensuhr</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space,
|
||||
@@ -6,10 +6,12 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
id: 1,
|
||||
slug: "starter-set",
|
||||
name: "Starter-Set",
|
||||
sku: null,
|
||||
price: 29.9,
|
||||
active: true,
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
trackInventory: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Product } from "../payload";
|
||||
|
||||
const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
numericId: 1,
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
price: 12.9,
|
||||
@@ -17,11 +18,13 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
spotlightShowWishlist: false,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ export async function sendVerificationEmail(to: string, firstName: string, token
|
||||
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
|
||||
const seller = await getSellerForInvoice();
|
||||
await transport.sendMail({
|
||||
// See orderEmail.ts's own comment on why only the display name is
|
||||
// dynamic — the address stays admin@mk360.de until sellerEmail's domain
|
||||
// is confirmed SPF-authorized on the Hostinger account.
|
||||
from: `"${seller?.sellerName ?? "einfach produktiv"}" <admin@mk360.de>`,
|
||||
// See orderEmail.ts's own comment — From now matches sellerEmail
|
||||
// itself since the SMTP account moved to a mailbox on that domain,
|
||||
// and emailFromName/emailFromAddress let an admin override both.
|
||||
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
|
||||
replyTo: seller?.sellerEmail || undefined,
|
||||
to,
|
||||
subject: "Bitte bestätige deine E-Mail-Adresse",
|
||||
|
||||
@@ -15,11 +15,36 @@
|
||||
// Switched 2026-07-25 per explicit request once the confirmation-email
|
||||
// template existed to point templateId at.
|
||||
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
|
||||
const BREVO_CONTACTS_URL = "https://api.brevo.com/v3/contacts";
|
||||
|
||||
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
|
||||
export type BrevoSyncResult = { ok: true; alreadySubscribed?: boolean } | { ok: false; reason: string };
|
||||
|
||||
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
|
||||
|
||||
// Checked before calling doubleOptinConfirmation — that endpoint gives
|
||||
// no way to tell "brand new signup" apart from "already confirmed,
|
||||
// resending the same mail again" (verified directly: calling it a
|
||||
// second time for an already-subscribed contact still returns a plain
|
||||
// 201, same as the first time). `listIds` on a Brevo contact is only
|
||||
// populated once double opt-in actually confirms (never for a merely
|
||||
// *requested*, still-pending one), so its presence here is a reliable
|
||||
// "already subscribed to this list" signal. Fails open on any error —
|
||||
// this check is a UX nicety (skip an unnecessary resend, show a
|
||||
// friendlier message), never a reason to block a real signup attempt.
|
||||
async function isAlreadySubscribed(email: string, apiKey: string, listId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BREVO_CONTACTS_URL}/${encodeURIComponent(email)}`, {
|
||||
headers: { "api-key": apiKey },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return false; // 404 (never signed up before) or any transient error
|
||||
const contact: { listIds?: number[] } = await res.json();
|
||||
return (contact.listIds ?? []).includes(Number(listId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// `source` becomes a Brevo contact attribute so campaigns/segments can
|
||||
// tell a checkout opt-in apart from the standalone signup forms without
|
||||
// needing separate lists.
|
||||
@@ -33,6 +58,11 @@ export async function upsertNewsletterContact(
|
||||
if (!apiKey || !listId || !templateId) {
|
||||
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
|
||||
}
|
||||
|
||||
if (await isAlreadySubscribed(email, apiKey, listId)) {
|
||||
return { ok: true, alreadySubscribed: true };
|
||||
}
|
||||
|
||||
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
|
||||
|
||||
try {
|
||||
@@ -52,12 +82,8 @@ export async function upsertNewsletterContact(
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
// 201 Created is this endpoint's success status (unlike the plain
|
||||
// contacts upsert this replaced, which used 204). A contact who's
|
||||
// already confirmed-and-subscribed re-submitting the form is not
|
||||
// treated as an error either — Brevo resends the confirmation email
|
||||
// in that case rather than erroring, which is an acceptable no-op
|
||||
// resend from this app's point of view (matches the previous
|
||||
// endpoint's "always succeeds for an existing contact too" behavior).
|
||||
// contacts upsert this replaced, which used 204). The already-
|
||||
// subscribed case is handled above, before this call ever fires.
|
||||
if (res.ok || res.status === 201) return { ok: true };
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
|
||||
|
||||
@@ -18,7 +18,8 @@ function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CART_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -90,7 +91,8 @@ export function getCart(): CartItem[] {
|
||||
if (raw === cachedRaw) return cachedItems;
|
||||
cachedRaw = raw;
|
||||
try {
|
||||
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
const parsed = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
cachedItems = Array.isArray(parsed) ? parsed : EMPTY_CART;
|
||||
} catch {
|
||||
cachedItems = EMPTY_CART;
|
||||
}
|
||||
@@ -125,9 +127,14 @@ export function useCart(): CartItem[] {
|
||||
// Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's
|
||||
// inline login toggle) — folds whatever was saved server-side into the
|
||||
// local cart by quantity (addToCart adds to an existing line rather than
|
||||
// overwriting it), so items added before logging in aren't lost. CartSync
|
||||
// then picks up the resulting change and pushes the merged cart back to
|
||||
// the server on its own, closing the loop without a separate save call here.
|
||||
// overwriting it), so items added as a guest before logging in aren't lost.
|
||||
// This only stays correct because LogoutButton.tsx clears the local cart on
|
||||
// logout — the local cart is always either empty (no guest additions since
|
||||
// the last logout) or holds only genuinely new guest-session items, never a
|
||||
// stale copy of what's already in the server cart, so this add never
|
||||
// double-counts. CartSync then picks up the resulting change and pushes the
|
||||
// merged cart back to the server on its own, closing the loop without a
|
||||
// separate save call here.
|
||||
export async function mergeServerCartIntoLocal(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/account/cart");
|
||||
|
||||
@@ -37,6 +37,16 @@ export function computeSubtotal(items: CartLine[]): number {
|
||||
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
|
||||
}
|
||||
|
||||
// A cart only needs a shipping line at all if at least one item doesn't
|
||||
// opt out via Products.noShippingCost (e.g. a purely digital download) —
|
||||
// mirrors api/checkout/route.ts's own hasShippableItem check, which is
|
||||
// the actual charged amount; this is only the storefront's estimate/
|
||||
// display before that. A single non-exempt item still triggers normal
|
||||
// shipping for the whole cart, this never partially discounts it.
|
||||
export function cartHasShippableItem(items: CartLine[]): boolean {
|
||||
return items.some(({ product }) => !product.noShippingCost);
|
||||
}
|
||||
|
||||
export type CartTotals = {
|
||||
subtotal: number;
|
||||
/** compareAtPrice-based per-product savings — already excluded from
|
||||
|
||||
@@ -36,6 +36,9 @@ export type CheckoutDraft = {
|
||||
shippingZip: string;
|
||||
shippingCity: string;
|
||||
shippingCountry: string;
|
||||
shippingCompanyName: string;
|
||||
shippingContactEmail: string;
|
||||
shippingContactPhone: string;
|
||||
newsletterOptIn: boolean;
|
||||
shippingMethodId: number | null;
|
||||
paymentMethodId: number | null;
|
||||
|
||||
@@ -203,6 +203,23 @@ export type CustomerAddress = {
|
||||
// /checkout's Firma/USt-IdNr. fields for a returning customer.
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
// Optional second/shipping address — see Customers.ts's "Lieferadresse"
|
||||
// tab. Prefills /checkout's "Abweichende Lieferadresse" section once
|
||||
// hasDifferentShippingAddress is set here; still fully overwritable per
|
||||
// order (Orders keeps its own shipping* snapshot regardless).
|
||||
hasDifferentShippingAddress: boolean;
|
||||
shippingFirstName: string | null;
|
||||
shippingLastName: string | null;
|
||||
shippingCompanyName: string | null;
|
||||
shippingDeliveryMethod: "address" | "packstation" | null;
|
||||
shippingStreet: string | null;
|
||||
shippingPackstationNumber: string | null;
|
||||
shippingPostNumber: string | null;
|
||||
shippingZip: string | null;
|
||||
shippingCity: string | null;
|
||||
shippingCountry: string | null;
|
||||
shippingContactEmail: string | null;
|
||||
shippingContactPhone: string | null;
|
||||
};
|
||||
|
||||
export type CustomerProfile = CustomerSummary & CustomerAddress;
|
||||
@@ -223,6 +240,19 @@ type PayloadCustomerMe = {
|
||||
country: string | null;
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
hasDifferentShippingAddress: boolean | null;
|
||||
shippingFirstName: string | null;
|
||||
shippingLastName: string | null;
|
||||
shippingCompanyName: string | null;
|
||||
shippingDeliveryMethod: "address" | "packstation" | null;
|
||||
shippingStreet: string | null;
|
||||
shippingPackstationNumber: string | null;
|
||||
shippingPostNumber: string | null;
|
||||
shippingZip: string | null;
|
||||
shippingCity: string | null;
|
||||
shippingCountry: string | null;
|
||||
shippingContactEmail: string | null;
|
||||
shippingContactPhone: string | null;
|
||||
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
|
||||
};
|
||||
|
||||
@@ -251,6 +281,19 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
|
||||
country: u.country,
|
||||
companyName: u.companyName,
|
||||
vatId: u.vatId,
|
||||
hasDifferentShippingAddress: Boolean(u.hasDifferentShippingAddress),
|
||||
shippingFirstName: u.shippingFirstName,
|
||||
shippingLastName: u.shippingLastName,
|
||||
shippingCompanyName: u.shippingCompanyName,
|
||||
shippingDeliveryMethod: u.shippingDeliveryMethod,
|
||||
shippingStreet: u.shippingStreet,
|
||||
shippingPackstationNumber: u.shippingPackstationNumber,
|
||||
shippingPostNumber: u.shippingPostNumber,
|
||||
shippingZip: u.shippingZip,
|
||||
shippingCity: u.shippingCity,
|
||||
shippingCountry: u.shippingCountry,
|
||||
shippingContactEmail: u.shippingContactEmail,
|
||||
shippingContactPhone: u.shippingContactPhone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +312,19 @@ export async function updateCustomerProfile(
|
||||
country: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
hasDifferentShippingAddress?: boolean;
|
||||
shippingFirstName?: string;
|
||||
shippingLastName?: string;
|
||||
shippingCompanyName?: string;
|
||||
shippingDeliveryMethod?: "address" | "packstation";
|
||||
shippingStreet?: string;
|
||||
shippingPackstationNumber?: string;
|
||||
shippingPostNumber?: string;
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
},
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
|
||||
@@ -301,6 +357,71 @@ export async function changeCustomerPassword(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type WishlistItem = {
|
||||
id: number;
|
||||
productId: number;
|
||||
variant: string;
|
||||
};
|
||||
|
||||
// `variant` empty string, not undefined — matches WishlistItems.ts's own
|
||||
// defaultValue: '' so the (customer, product, variant) unique index
|
||||
// actually catches a duplicate add for a variant-less product too.
|
||||
export async function getWishlist(token: string, customerId: number): Promise<WishlistItem[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
depth: "0",
|
||||
limit: "200",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
|
||||
}
|
||||
|
||||
// Toggles a single (product, variant) — tries to create first; a 400 here
|
||||
// means the unique (customer, product, variant) index rejected it because
|
||||
// it already exists, so this falls back to finding + deleting that row
|
||||
// instead. Avoids a separate "is it already wishlisted" read before every
|
||||
// toggle (the common case, adding something new, only needs one request).
|
||||
export async function toggleWishlistItem(
|
||||
token: string,
|
||||
productId: number,
|
||||
variant: string,
|
||||
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
|
||||
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ product: productId, variant }),
|
||||
});
|
||||
if (createRes.ok) return { ok: true, wishlisted: true };
|
||||
|
||||
const findParams = new URLSearchParams({
|
||||
"where[product][equals]": String(productId),
|
||||
"where[variant][equals]": variant,
|
||||
depth: "0",
|
||||
limit: "1",
|
||||
});
|
||||
const findRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${findParams}`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!findRes.ok) return { ok: false };
|
||||
const found: { docs?: { id: number }[] } = await findRes.json();
|
||||
const existingId = found.docs?.[0]?.id;
|
||||
if (!existingId) return { ok: false };
|
||||
|
||||
const deleteRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items/${existingId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
});
|
||||
if (!deleteRes.ok) return { ok: false };
|
||||
return { ok: true, wishlisted: false };
|
||||
}
|
||||
|
||||
// Called from app/api/account/verify-email/route.ts — no customer session
|
||||
// exists at this point (cold click from an email client), so this
|
||||
// authenticates as the service instead (see SERVICE_SECRET above).
|
||||
@@ -399,7 +520,14 @@ export const ORDER_STATUS_LABEL: Record<string, string> = {
|
||||
// decide which button, if any, to show).
|
||||
export function customerOrderAction(status: string): "cancel" | "request-return" | null {
|
||||
if (status === "received") return "cancel";
|
||||
if (status === "shipped" || status === "delivered") return "request-return";
|
||||
// Only once actually delivered — a (partial) return before the package
|
||||
// even arrived doesn't make sense yet. The backend's own
|
||||
// CUSTOMER_ALLOWED_TRANSITIONS still technically permits 'shipped' →
|
||||
// 'return_requested' too (an extensive existing test suite is built
|
||||
// around that as its base fixture) — this only narrows what the UI
|
||||
// itself offers, a stricter subset of what the backend already allows,
|
||||
// not a security boundary being loosened.
|
||||
if (status === "delivered") return "request-return";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -408,6 +536,7 @@ export type CustomerOrder = {
|
||||
createdAt: string;
|
||||
total: number;
|
||||
status: string;
|
||||
paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded";
|
||||
itemCount: number;
|
||||
/** Raw product relationship ids, in item order — depth=0 keeps them as
|
||||
* plain numbers, not populated objects. Callers resolve these to image
|
||||
@@ -416,9 +545,79 @@ export type CustomerOrder = {
|
||||
productIds: number[];
|
||||
};
|
||||
|
||||
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
|
||||
// Excludes orders that never actually happened from the customer's own
|
||||
// point of view — a `pending_payment` order whose Stripe payment failed
|
||||
// (or timed out, see the backend's expirePendingPayments job) transitions
|
||||
// straight to `cancelled` without ever getting an `invoiceNumber`
|
||||
// (deferred until payment confirms, see confirmPayment.ts). A *real*
|
||||
// cancellation (Storno) is always of an already-`received`, already-
|
||||
// invoiced order, so `invoiceNumber` is always present there. That
|
||||
// distinction — `status: 'cancelled'` with no `invoiceNumber` — is what
|
||||
// separates "a real order that got cancelled" (show it) from "a checkout
|
||||
// attempt whose payment never went through" (nothing to show — the row
|
||||
// stays in Payload for admin/audit purposes, just not surfaced here).
|
||||
function excludeFailedPaymentAttemptsQuery(): Record<string, string> {
|
||||
return {
|
||||
"where[and][1][or][0][status][not_equals]": "cancelled",
|
||||
"where[and][1][or][1][invoiceNumber][exists]": "true",
|
||||
};
|
||||
}
|
||||
|
||||
// "Offen" in the account UI's PaymentStatusBadge covers two distinct
|
||||
// backend values (an unconfirmed Stripe payment vs. an Überweisung order
|
||||
// awaiting manual reconciliation) — the filter chip mirrors that same
|
||||
// grouping rather than exposing the internal distinction as two options.
|
||||
const OPEN_PAYMENT_STATUSES = ["pending", "not_applicable"] as const;
|
||||
|
||||
export type CustomerOrderFilters = {
|
||||
status?: string;
|
||||
paymentStatus?: string;
|
||||
/** Calendar year as a string, e.g. "2026" — matches createdAt within
|
||||
* [Jan 1, Jan 1 of next year). */
|
||||
year?: string;
|
||||
};
|
||||
|
||||
function customerOrderFilterQuery(filters: CustomerOrderFilters | undefined, whereIndex: number): Record<string, string> {
|
||||
if (!filters) return {};
|
||||
const params: Record<string, string> = {};
|
||||
let i = whereIndex;
|
||||
if (filters.status) {
|
||||
params[`where[and][${i}][status][equals]`] = filters.status;
|
||||
i += 1;
|
||||
}
|
||||
if (filters.paymentStatus) {
|
||||
if (filters.paymentStatus === "open") {
|
||||
OPEN_PAYMENT_STATUSES.forEach((value, j) => {
|
||||
params[`where[and][${i}][or][${j}][paymentStatus][equals]`] = value;
|
||||
});
|
||||
} else {
|
||||
params[`where[and][${i}][paymentStatus][equals]`] = filters.paymentStatus;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if (filters.year && /^\d{4}$/.test(filters.year)) {
|
||||
const year = Number(filters.year);
|
||||
params[`where[and][${i}][createdAt][greater_than_equal]`] = new Date(Date.UTC(year, 0, 1)).toISOString();
|
||||
params[`where[and][${i}][createdAt][less_than]`] = new Date(Date.UTC(year + 1, 0, 1)).toISOString();
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// `excludeFailedPaymentAttempts` defaults to true (list views) — the one
|
||||
// exception is /api/account/export/route.ts's GDPR data export, which
|
||||
// passes false: a legal completeness export must include every order
|
||||
// row that exists about this customer, not just the ones normally shown
|
||||
// in "Meine Bestellungen".
|
||||
export async function getCustomerOrders(
|
||||
token: string,
|
||||
customerId: number,
|
||||
excludeFailedPaymentAttempts = true,
|
||||
filters?: CustomerOrderFilters,
|
||||
): Promise<CustomerOrder[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
"where[and][0][customer][equals]": String(customerId),
|
||||
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
|
||||
...customerOrderFilterQuery(filters, excludeFailedPaymentAttempts ? 2 : 1),
|
||||
sort: "-createdAt",
|
||||
depth: "0",
|
||||
limit: "50",
|
||||
@@ -429,20 +628,42 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: {
|
||||
docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: { product: number }[] }[];
|
||||
docs?: {
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
total: number;
|
||||
status: string;
|
||||
paymentStatus: CustomerOrder["paymentStatus"];
|
||||
items: { product: number }[];
|
||||
}[];
|
||||
} = await res.json();
|
||||
return (data.docs ?? []).map((doc) => ({
|
||||
orderNumber: doc.orderNumber,
|
||||
createdAt: doc.createdAt,
|
||||
total: doc.total,
|
||||
status: doc.status,
|
||||
paymentStatus: doc.paymentStatus,
|
||||
itemCount: doc.items.length,
|
||||
productIds: doc.items.map((item) => item.product),
|
||||
}));
|
||||
}
|
||||
|
||||
// All years that have at least one (non-filtered-out) order for this
|
||||
// customer — powers the year filter's option list without hardcoding a
|
||||
// range. Cheap: reuses the same excludeFailedPaymentAttempts query, no
|
||||
// separate collection/aggregation endpoint needed for this order volume.
|
||||
export async function getCustomerOrderYears(token: string, customerId: number): Promise<string[]> {
|
||||
const orders = await getCustomerOrders(token, customerId, true);
|
||||
const years = new Set(orders.map((o) => new Date(o.createdAt).getUTCFullYear().toString()));
|
||||
return Array.from(years).sort((a, b) => Number(b) - Number(a));
|
||||
}
|
||||
|
||||
export type CustomerOrderDetail = CustomerOrder & {
|
||||
id: number;
|
||||
// 'manual' (Überweisung) vs 'stripe' (Kreditkarte/PayPal) — see
|
||||
// api/account/orders/[orderNumber]/switch-to-stripe/route.ts, which only
|
||||
// offers a payment-method switch for a still-'manual' order.
|
||||
paymentProvider: "manual" | "stripe";
|
||||
// 'not_applicable' for Überweisung orders (never gated); see
|
||||
// spicy-leaping-pizza.md §1 — read by /api/checkout/status for the
|
||||
// post-Stripe-redirect polling page.
|
||||
@@ -453,6 +674,11 @@ export type CustomerOrderDetail = CustomerOrder & {
|
||||
correctionInvoiceIssuedAt: string | null;
|
||||
carrier: string | null;
|
||||
trackingNumber: string | null;
|
||||
// Raw media id, not populated — this fetch stays depth=0 (see this
|
||||
// function's own comment on why), so the order-detail page resolves the
|
||||
// actual download URL itself via a separate media lookup when present.
|
||||
dhlReturnLabelMedia: number | null;
|
||||
dhlReturnTrackingNumber: string | null;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
@@ -471,6 +697,7 @@ export type CustomerOrderDetail = CustomerOrder & {
|
||||
hasDifferentShippingAddress: boolean;
|
||||
shippingFirstName: string | null;
|
||||
shippingLastName: string | null;
|
||||
shippingCompanyName: string | null;
|
||||
shippingDeliveryMethod: "address" | "packstation" | null;
|
||||
shippingStreet: string | null;
|
||||
shippingPackstationNumber: string | null;
|
||||
@@ -478,6 +705,8 @@ export type CustomerOrderDetail = CustomerOrder & {
|
||||
shippingZip: string | null;
|
||||
shippingCity: string | null;
|
||||
shippingCountry: string | null;
|
||||
shippingContactEmail: string | null;
|
||||
shippingContactPhone: string | null;
|
||||
subtotal: number;
|
||||
shippingCost: number;
|
||||
shippingMethodTitle: string;
|
||||
@@ -497,6 +726,7 @@ export type CustomerOrderItem = {
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
returnQuantity: number;
|
||||
sku: string | null;
|
||||
};
|
||||
|
||||
// Access control (Orders.ts) already scopes a customer's own JWT to only
|
||||
@@ -508,10 +738,23 @@ export type CustomerOrderItem = {
|
||||
// caller round-trip a full, valid items array back on a return request
|
||||
// (Orders.ts's field-lock hook needs every required item field present,
|
||||
// not just returnQuantity — see that hook's own comment).
|
||||
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
|
||||
// `excludeFailedPaymentAttempts` defaults to false because this function
|
||||
// is shared with /api/checkout/status/route.ts's polling right after a
|
||||
// Stripe payment fails — that flow needs to keep seeing the
|
||||
// `cancelled`/no-`invoiceNumber` order (to show "Zahlung fehlgeschlagen,
|
||||
// bitte erneut versuchen") for exactly the same order this flag would
|
||||
// otherwise hide. Only /konto/bestellungen/[orderNumber] (a customer
|
||||
// browsing their own history, not mid-checkout) opts in.
|
||||
export async function getCustomerOrderDetail(
|
||||
token: string,
|
||||
customerId: number,
|
||||
orderNumber: string,
|
||||
excludeFailedPaymentAttempts = false,
|
||||
): Promise<CustomerOrderDetail | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[orderNumber][equals]": orderNumber,
|
||||
"where[customer][equals]": String(customerId),
|
||||
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
|
||||
depth: "0",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
@@ -66,6 +66,32 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Vorkasse (Überweisung/manual) instruction — the invoice PDF already
|
||||
// shows this same information (see @einfach-produktiv/invoicing's own
|
||||
// unpaidNoticeText), but a customer often only glances at the email body
|
||||
// itself, not the attached PDF, so it's repeated here in plain text too.
|
||||
// Own full-width block, margin-top matching the other section gaps in
|
||||
// this template (16px) — not squeezed into the narrow Gesamtsumme table
|
||||
// like an initial draft of the invoice version was before that got
|
||||
// widened per feedback.
|
||||
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null, hasOnlinePaymentOption: boolean): string {
|
||||
const bankLine = seller && (seller.iban || seller.bic)
|
||||
? [seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")
|
||||
: null;
|
||||
return `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;background:${BG_MUTED};border-radius:8px;padding:16px 20px;">
|
||||
<tr>
|
||||
<td style="font-size:13px;line-height:1.6;color:${TEXT_MUTED};text-align:center;">
|
||||
<p style="margin:0 0 8px;">Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}</p>
|
||||
${bankLine ? `<p style="margin:0 0 12px;font-weight:700;color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</p>` : ""}
|
||||
<p style="margin:0;">Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 1–2 Werktagen).</p>
|
||||
${hasOnlinePaymentOption ? `<p style="margin:8px 0 0;">Zahlungsart geändert? Solange die Überweisung noch nicht bei uns eingegangen ist, kannst du in deinem Konto jederzeit auf Kreditkarte/PayPal umsteigen.</p>` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// Live-Preview-only fallback (no real order/company-settings fetch there,
|
||||
// see /email-preview/[type]) — the actual send always passes the real
|
||||
// seller (company-settings) through buildLegalFooterLines() below. Email
|
||||
@@ -171,6 +197,7 @@ export type OrderConfirmationItem = {
|
||||
bundleContents?: string | null;
|
||||
variantName?: string | null;
|
||||
taxRatePercent: number;
|
||||
sku?: string | null;
|
||||
};
|
||||
export type OrderConfirmationData = {
|
||||
orderNumber: string;
|
||||
@@ -181,6 +208,20 @@ export type OrderConfirmationData = {
|
||||
discountAmount: number;
|
||||
discountCode: string | null;
|
||||
total: number;
|
||||
// Explicit boolean set by each caller (checkout route's manual branch:
|
||||
// true; the Stripe webhook path: always false, since only a *paid*
|
||||
// Stripe order ever reaches this send) — not derived from
|
||||
// paymentMethodTitle here, since that string ("Online-Zahlung",
|
||||
// "Kreditkarte", "Überweisung (Vorkasse)", ...) is exactly the kind of
|
||||
// fragile thing a payment-methods rename already broke once this
|
||||
// session (see @einfach-produktiv/invoicing's isPaidImmediately()).
|
||||
isManualPayment: boolean;
|
||||
// Only meaningful when isManualPayment is true — whether at least one
|
||||
// Stripe-backed payment method is currently active, so the Vorkasse
|
||||
// notice can mention the option to switch instead of promising it
|
||||
// unconditionally. Set by the checkout route from the same
|
||||
// getPaymentMethods() call it already makes.
|
||||
hasOnlinePaymentOption?: boolean;
|
||||
};
|
||||
|
||||
export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
@@ -202,6 +243,18 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
discountAmount: 5,
|
||||
discountCode: "WILLKOMMEN10",
|
||||
total: 37.7,
|
||||
isManualPayment: false,
|
||||
};
|
||||
|
||||
// Second preview fixture — demonstrates the Vorkasse/Überweisung branch
|
||||
// (vorkasseNotice(), incl. the "switch to Kreditkarte/PayPal" mention)
|
||||
// that SAMPLE_ORDER's own isManualPayment: false never shows. Used by
|
||||
// LiveEmailPreviewClient.tsx's toggle, not by any real send — a real
|
||||
// order-confirmation email always computes both flags live per order.
|
||||
export const SAMPLE_ORDER_MANUAL: OrderConfirmationData = {
|
||||
...SAMPLE_ORDER,
|
||||
isManualPayment: true,
|
||||
hasOnlinePaymentOption: true,
|
||||
};
|
||||
|
||||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
|
||||
@@ -215,7 +268,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
|
||||
}
|
||||
</td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}${item.sku ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">Art.-Nr. ${escapeHtml(item.sku)}</span>` : ""}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -263,6 +316,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
</tr>
|
||||
${taxRows}
|
||||
</table>
|
||||
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller, Boolean(order.hasOnlinePaymentOption)) : ""}
|
||||
`;
|
||||
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||||
@@ -278,6 +332,10 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-cancelled": "✕",
|
||||
"order-return-requested": "↩",
|
||||
"order-returned": "✓",
|
||||
"order-tracking-added": "📦",
|
||||
"order-tracking-corrected": "📦",
|
||||
"order-delivered": "🎉",
|
||||
"payment-method-switched": "💳",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const MIN_VW = 768;
|
||||
const MIN_VW = 640;
|
||||
const MAX_VW = 1440;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { transport } from "./mailer";
|
||||
import { getEmailTemplate } from "./payload";
|
||||
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
|
||||
import { renderOrderConfirmationHtml, renderOrderStatusHtml, type OrderConfirmationData } from "./emailTemplates";
|
||||
import { generateInvoicePdf, getSellerForInvoice } from "./invoiceData";
|
||||
import { sendCriticalAlert } from "./alertAdmin";
|
||||
|
||||
@@ -27,6 +27,7 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
hasDifferentShippingAddress?: boolean;
|
||||
shippingFirstName?: string | null;
|
||||
shippingLastName?: string | null;
|
||||
shippingCompanyName?: string | null;
|
||||
shippingDeliveryMethod?: "address" | "packstation" | null;
|
||||
shippingStreet?: string | null;
|
||||
shippingPackstationNumber?: string | null;
|
||||
@@ -34,6 +35,8 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
shippingZip?: string | null;
|
||||
shippingCity?: string | null;
|
||||
shippingCountry?: string | null;
|
||||
shippingContactEmail?: string | null;
|
||||
shippingContactPhone?: string | null;
|
||||
paymentMethodTitle: string;
|
||||
};
|
||||
|
||||
@@ -52,18 +55,20 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
// its own try/catch and just sends without the attachment if generation
|
||||
// fails (still alerted, same severity as the frontend's own critical-error
|
||||
// path for this checkout flow).
|
||||
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
|
||||
const template = (await getEmailTemplate("order-confirmation")) ?? {
|
||||
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
|
||||
heading: "Bestellt!",
|
||||
bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.",
|
||||
footerText: null,
|
||||
};
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const html = renderOrderConfirmationHtml(template, order, seller);
|
||||
|
||||
let attachments: { filename: string; content: Buffer }[] | undefined;
|
||||
// Shared by sendOrderConfirmationEmail and sendPaymentSwitchedEmail — both
|
||||
// need to (re)generate the exact same invoice PDF for the exact same
|
||||
// order, just with a different email body wrapped around it. Regenerated
|
||||
// fresh each time rather than cached anywhere, same "deterministic
|
||||
// regeneration, not file storage" approach as the on-demand download
|
||||
// routes — this also means a switched-payment send picks up the now
|
||||
// up-to-date paymentMethodTitle, so the PDF's own "✓ Bereits beglichen"
|
||||
// vs. Vorkasse-notice branch (see @einfach-produktiv/invoicing's
|
||||
// isPaidImmediately()) reflects the real, current payment state even
|
||||
// though invoiceNumber/invoiceIssuedAt never change.
|
||||
async function buildInvoiceAttachment(
|
||||
order: OrderConfirmationEmailData,
|
||||
seller: Awaited<ReturnType<typeof getSellerForInvoice>>,
|
||||
): Promise<{ filename: string; content: Buffer }[] | undefined> {
|
||||
try {
|
||||
const pdf = await generateInvoicePdf(
|
||||
{
|
||||
@@ -86,6 +91,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
|
||||
hasDifferentShippingAddress: order.hasDifferentShippingAddress ?? false,
|
||||
shippingFirstName: order.shippingFirstName,
|
||||
shippingLastName: order.shippingLastName,
|
||||
shippingCompanyName: order.shippingCompanyName,
|
||||
shippingDeliveryMethod: order.shippingDeliveryMethod,
|
||||
shippingStreet: order.shippingStreet,
|
||||
shippingPackstationNumber: order.shippingPackstationNumber,
|
||||
@@ -93,6 +99,8 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
|
||||
shippingZip: order.shippingZip,
|
||||
shippingCity: order.shippingCity,
|
||||
shippingCountry: order.shippingCountry,
|
||||
shippingContactEmail: order.shippingContactEmail,
|
||||
shippingContactPhone: order.shippingContactPhone,
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
items: order.items.map((i) => ({
|
||||
productName: i.productName,
|
||||
@@ -102,6 +110,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
|
||||
bundleContents: i.bundleContents ?? null,
|
||||
variantName: i.variantName ?? null,
|
||||
imageUrl: i.imageUrl ?? null,
|
||||
sku: i.sku ?? null,
|
||||
})),
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
@@ -111,23 +120,95 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
|
||||
else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
|
||||
if (!pdf) throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
|
||||
return [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Rechnungs-PDF konnte nicht erzeugt werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
|
||||
const fetchedTemplate = await getEmailTemplate("order-confirmation");
|
||||
// `active === false` is a deliberate admin decision to suppress this
|
||||
// email entirely — distinct from `fetchedTemplate` being null (no row
|
||||
// saved yet), which still sends below with the hardcoded default
|
||||
// wording. Checked before the fallback is applied, since the fallback
|
||||
// object has no `active` field of its own (implicitly always on).
|
||||
if (fetchedTemplate && !fetchedTemplate.active) return false;
|
||||
const template = fetchedTemplate ?? {
|
||||
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
|
||||
heading: "Bestellt!",
|
||||
bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.",
|
||||
footerText: null,
|
||||
};
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const html = renderOrderConfirmationHtml(template, order, seller);
|
||||
const attachments = await buildInvoiceAttachment(order, seller);
|
||||
|
||||
await transport.sendMail({
|
||||
// Display name only, not the address — see the memory note on why the
|
||||
// envelope stays admin@mk360.de (that's the domain the Hostinger SMTP
|
||||
// account is actually authorized for; sellerEmail's domain isn't
|
||||
// confirmed SPF-authorized on it yet). Reply-To is what actually routes
|
||||
// a customer's reply to the seller, regardless of the From address.
|
||||
from: `"${seller?.sellerName ?? "einfach produktiv"}" <admin@mk360.de>`,
|
||||
// SPF confirmed 2026-07-29 for einfach-produktiv.com, and the SMTP
|
||||
// account itself switched to a mailbox on that domain — see the
|
||||
// backend's sellerInfo.ts buildFromHeader() comment for why both had
|
||||
// to move together (Hostinger's relay rejects a From address the
|
||||
// authenticated mailbox doesn't own). Reply-To stays set too, though
|
||||
// now redundant with From itself pointing at sellerEmail.
|
||||
// emailFromName/emailFromAddress (company-settings) are the
|
||||
// admin-editable override, same fallback chain as the backend's
|
||||
// buildFromHeader().
|
||||
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
|
||||
replyTo: seller?.sellerEmail || undefined,
|
||||
to: customerEmail,
|
||||
subject: template.subject,
|
||||
html,
|
||||
attachments,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sent instead of sendOrderConfirmationEmail() when the confirmed payment
|
||||
// came from switchPaymentToStripe.ts (an existing Überweisung order the
|
||||
// customer moved to Kreditkarte/PayPal), not a fresh checkout — see
|
||||
// confirmPaymentEmail.ts's own branch on order.paymentSwitchedAt. A second
|
||||
// full "Vielen Dank für deine Bestellung!" email (with its own invoice PDF
|
||||
// re-attached) would read as a brand-new purchase; this is a short,
|
||||
// dedicated confirmation instead, same shape as the order-status emails
|
||||
// (icon + admin-editable text + "Bestellung ansehen" button), no item
|
||||
// table/invoice attachment — the invoice itself didn't change, only how
|
||||
// it got paid.
|
||||
export async function sendPaymentSwitchedEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
|
||||
const fetchedTemplate = await getEmailTemplate("payment-method-switched");
|
||||
if (fetchedTemplate && !fetchedTemplate.active) return false;
|
||||
const template = fetchedTemplate ?? {
|
||||
subject: "Erledigt! Deine Zahlung ist da 🎉",
|
||||
heading: "Erledigt!",
|
||||
bodyText: "Deine Zahlung ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch weiter, du musst dich um nichts mehr kümmern. Deine aktualisierte Rechnung findest du im Anhang.",
|
||||
footerText: null,
|
||||
};
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const html = renderOrderStatusHtml(
|
||||
template,
|
||||
"💳",
|
||||
order.orderNumber,
|
||||
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`,
|
||||
seller,
|
||||
);
|
||||
// Same invoiceNumber as always (never re-issued for a switch, see
|
||||
// confirmPayment.ts), but paymentMethodTitle now reflects the actually-
|
||||
// confirmed instrument — worth a fresh PDF, not the original attachment,
|
||||
// since @einfach-produktiv/invoicing's own isPaidImmediately() check
|
||||
// reads that title to decide "✓ Bereits beglichen" vs. the Vorkasse
|
||||
// notice.
|
||||
const attachments = await buildInvoiceAttachment(order, seller);
|
||||
|
||||
await transport.sendMail({
|
||||
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
|
||||
replyTo: seller?.sellerEmail || undefined,
|
||||
to: customerEmail,
|
||||
subject: template.subject,
|
||||
|
||||
@@ -27,6 +27,11 @@ export type OrderItemInput = {
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
// Resolved by the caller (api/checkout/route.ts): the variant's own sku
|
||||
// if one was selected, else the product's sku, else null — same
|
||||
// "variant overrides product" precedence as unitPrice/taxRatePercent
|
||||
// elsewhere in this checkout flow.
|
||||
sku: string | null;
|
||||
};
|
||||
|
||||
export type CreateOrderInput = {
|
||||
@@ -67,11 +72,23 @@ export type CreateOrderInput = {
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
// Optional, mirrors companyName above — no shipping-side vatId (billing-
|
||||
// only concept). Contact email/phone have no billing-side equivalent:
|
||||
// they're handed to the shipping carrier, never used for customer
|
||||
// communication.
|
||||
shippingCompanyName?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
newsletterOptIn: boolean;
|
||||
items: OrderItemInput[];
|
||||
subtotal: number;
|
||||
shippingCost: number;
|
||||
shippingMethodTitle: string;
|
||||
// Numeric ShippingMethod id, not the same as shippingMethodTitle's frozen
|
||||
// text snapshot — lets pollCarrierTracking.ts resolve order → shipping
|
||||
// method → carrier without a fragile title-text match. See Orders.ts's
|
||||
// own comment on why both fields exist side by side.
|
||||
shippingMethod: number;
|
||||
paymentMethodTitle: string;
|
||||
discountCode: string | null;
|
||||
discountAmount: number;
|
||||
@@ -140,6 +157,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
shippingZip: input.shippingZip,
|
||||
shippingCity: input.shippingCity,
|
||||
shippingCountry: input.shippingCountry,
|
||||
shippingCompanyName: input.shippingCompanyName,
|
||||
shippingContactEmail: input.shippingContactEmail,
|
||||
shippingContactPhone: input.shippingContactPhone,
|
||||
newsletterOptIn: input.newsletterOptIn,
|
||||
items: input.items.map((i) => ({
|
||||
product: i.productId,
|
||||
@@ -149,10 +169,12 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
sku: i.sku,
|
||||
})),
|
||||
subtotal: input.subtotal,
|
||||
shippingCost: input.shippingCost,
|
||||
shippingMethodTitle: input.shippingMethodTitle,
|
||||
shippingMethod: input.shippingMethod,
|
||||
paymentMethodTitle: input.paymentMethodTitle,
|
||||
discountCode: input.discountCode,
|
||||
discountAmount: input.discountAmount,
|
||||
|
||||
@@ -21,7 +21,7 @@ export type BlogPost = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
category: string;
|
||||
categories: string[];
|
||||
readTime: number;
|
||||
excerpt: string;
|
||||
thumbnail: string | null;
|
||||
@@ -33,7 +33,7 @@ type PayloadPost = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
category: { name: string } | number | null;
|
||||
categories: ({ name: string } | number)[];
|
||||
readTime: number;
|
||||
excerpt: string;
|
||||
thumbnail: { url: string } | number | null;
|
||||
@@ -49,6 +49,11 @@ type PayloadPost = {
|
||||
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
// Draft/scheduled posts never appear publicly — same "filter, not
|
||||
// access-control" pattern as Products.active. Live preview
|
||||
// (LivePostContent.tsx) bypasses this entirely since it fetches the
|
||||
// one specific document by id directly, not through this list.
|
||||
"where[status][equals]": "published",
|
||||
sort: "-featured,-publishedAt",
|
||||
depth: "2",
|
||||
limit: String(limit),
|
||||
@@ -68,10 +73,9 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
category:
|
||||
typeof post.category === "object" && post.category
|
||||
? post.category.name
|
||||
: "",
|
||||
categories: (post.categories ?? [])
|
||||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
readTime: post.readTime,
|
||||
excerpt: post.excerpt,
|
||||
thumbnail:
|
||||
@@ -120,8 +124,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
category:
|
||||
typeof doc.category === "object" && doc.category ? doc.category.name : "",
|
||||
categories: (doc.categories ?? [])
|
||||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
readTime: doc.readTime,
|
||||
excerpt: doc.excerpt,
|
||||
thumbnail:
|
||||
@@ -144,6 +149,10 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
|
||||
depth: "2",
|
||||
limit: "1",
|
||||
});
|
||||
// Draft/scheduled posts 404 for a normal visitor — draftMode's preview
|
||||
// (options.draft, wired from the page's own draftMode() call) is the
|
||||
// one legitimate way to view one before its scheduledPublishAt fires.
|
||||
if (!options?.draft) params.set("where[status][equals]", "published");
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||||
if (!res.ok) {
|
||||
@@ -164,6 +173,13 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
|
||||
// to numeric ids here would silently orphan every existing shopper's cart.
|
||||
export type Product = {
|
||||
id: string;
|
||||
// The raw Payload numeric id — `id` above is the slug (used everywhere
|
||||
// as the "commerce id" — cart, checkout, URLs), but a few relationships
|
||||
// (Orders.items.product, WishlistItems.product) are real Payload
|
||||
// relationship fields storing this number instead. Kept alongside the
|
||||
// slug rather than replacing it, to avoid touching every existing
|
||||
// slug-based call site.
|
||||
numericId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
@@ -184,6 +200,10 @@ export type Product = {
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightImage: string | null;
|
||||
// Per-product opt-in for a wishlist heart on the homepage spotlight —
|
||||
// independent of (in addition to) the global wishlistEnabled toggle,
|
||||
// which still gates the feature site-wide regardless of this flag.
|
||||
spotlightShowWishlist: boolean;
|
||||
// Plain booleans, not the raw stock/threshold numbers — the public API
|
||||
// has no reason to leak exact stock counts, callers only ever need
|
||||
// "can this be bought right now". `outOfStock` on the product itself
|
||||
@@ -207,6 +227,13 @@ export type Product = {
|
||||
// storefront; the actual rate used for order totals is resolved and
|
||||
// snapshotted server-side at checkout (api/checkout/route.ts).
|
||||
taxRatePercent: number | null;
|
||||
// No shipping cost for this product at all (e.g. a digital download) —
|
||||
// never shows "zzgl. Versand" on its own product page, and doesn't count
|
||||
// toward "does this cart need a shipping line" (lib/cartTotals.ts's
|
||||
// cartHasShippableItem()). A cart with even one item that does NOT have
|
||||
// this set still gets charged/shown the normal shipping cost — this only
|
||||
// exempts the individual product, not the whole cart.
|
||||
noShippingCost: boolean;
|
||||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
};
|
||||
|
||||
@@ -226,11 +253,13 @@ type PayloadProduct = {
|
||||
spotlightHeadline: string | null;
|
||||
spotlightText: string | null;
|
||||
spotlightImage: { url: string } | number | null;
|
||||
spotlightShowWishlist: boolean;
|
||||
trackInventory: boolean;
|
||||
stock: number | null;
|
||||
allowBackorder: boolean;
|
||||
lowStockThreshold: number | null;
|
||||
taxRatePercent: number | null;
|
||||
noShippingCost: boolean;
|
||||
variants:
|
||||
| {
|
||||
name: string;
|
||||
@@ -273,6 +302,7 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
|
||||
export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
return {
|
||||
id: product.slug,
|
||||
numericId: product.id,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: product.price,
|
||||
@@ -287,10 +317,12 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
spotlightText: product.spotlightText || null,
|
||||
spotlightImage:
|
||||
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
|
||||
spotlightShowWishlist: product.spotlightShowWishlist,
|
||||
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
|
||||
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
|
||||
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
|
||||
taxRatePercent: product.taxRatePercent ?? null,
|
||||
noShippingCost: product.noShippingCost,
|
||||
variants: (product.variants ?? []).map((v) => ({
|
||||
name: v.name,
|
||||
priceOverride: v.priceOverride,
|
||||
@@ -335,6 +367,23 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
// from getProducts()'s slug-keyed catalog (an order can reference a
|
||||
// product that's since been deactivated/deleted, and slugs aren't even
|
||||
// the key an order item stores).
|
||||
// Powers /konto/merkliste — WishlistItems.product is a real numeric
|
||||
// relationship (see Product.numericId's own comment), so displaying the
|
||||
// wishlist needs a numeric-id lookup rather than getProducts()'s
|
||||
// slug-keyed list.
|
||||
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) {
|
||||
console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
|
||||
return [];
|
||||
}
|
||||
const data: { docs?: PayloadProduct[] } = await res.json();
|
||||
return (data.docs ?? []).map(mapPayloadProduct);
|
||||
}
|
||||
|
||||
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const map = new Map<number, string>();
|
||||
@@ -349,6 +398,21 @@ export async function getProductImagesByIds(ids: number[]): Promise<Map<number,
|
||||
return map;
|
||||
}
|
||||
|
||||
// Resolves a bare media id to its download URL — used by
|
||||
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
|
||||
// stays a raw id on the order fetch itself (that fetch is deliberately
|
||||
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
|
||||
// that fetch's depth just for this one occasional field. `no-store`, not
|
||||
// ISR-cached like getProductImagesByIds — a return label is a one-off,
|
||||
// account-specific document, not shared/reusable content worth caching.
|
||||
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
const data: { url?: string; filename?: string } = await res.json();
|
||||
if (!data.url) return null;
|
||||
return { url: data.url, filename: data.filename ?? "download.pdf" };
|
||||
}
|
||||
|
||||
// Derived from getProducts() (same 60s-ISR-cached fetch every other
|
||||
// discovery surface already uses) instead of its own separate Payload
|
||||
// query — also what lets the auto-spotlight rule below just be a plain
|
||||
@@ -638,9 +702,8 @@ export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
|
||||
// `manual` rows (Überweisung) pass through unchanged — one real gateway
|
||||
// there, one option, nothing to collapse.
|
||||
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
|
||||
const manual = methods.filter((m) => m.provider !== "stripe");
|
||||
const stripeMethods = methods.filter((m) => m.provider === "stripe");
|
||||
if (stripeMethods.length === 0) return manual;
|
||||
if (stripeMethods.length === 0) return methods;
|
||||
|
||||
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
|
||||
const online: CheckoutPaymentOption = {
|
||||
@@ -650,7 +713,27 @@ export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): Checko
|
||||
provider: "stripe",
|
||||
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
|
||||
};
|
||||
return [...manual, online];
|
||||
|
||||
// Preserve `methods`' own order (already sortOrder-sorted by the fetch)
|
||||
// instead of hardcoding manual-first — a real bug: "Online-Zahlung" had
|
||||
// a lower sortOrder than "Überweisung (Vorkasse)" in the admin, but
|
||||
// this function always put manual rows first regardless, so the
|
||||
// checkout showed them in the wrong order. Splice the combined entry in
|
||||
// at the position of the *first* stripe row encountered, drop any
|
||||
// further stripe rows (already folded into `online`).
|
||||
const result: CheckoutPaymentOption[] = [];
|
||||
let onlineInserted = false;
|
||||
for (const m of methods) {
|
||||
if (m.provider === "stripe") {
|
||||
if (!onlineInserted) {
|
||||
result.push(online);
|
||||
onlineInserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type WerkzeugeCard = {
|
||||
@@ -798,7 +881,11 @@ export type EmailTemplateType =
|
||||
| "order-shipped"
|
||||
| "order-cancelled"
|
||||
| "order-return-requested"
|
||||
| "order-returned";
|
||||
| "order-returned"
|
||||
| "order-tracking-added"
|
||||
| "order-tracking-corrected"
|
||||
| "order-delivered"
|
||||
| "payment-method-switched";
|
||||
|
||||
type PayloadEmailTemplate = {
|
||||
type: EmailTemplateType;
|
||||
@@ -806,6 +893,11 @@ type PayloadEmailTemplate = {
|
||||
heading: string;
|
||||
bodyText: string;
|
||||
footerText: string | null;
|
||||
// false means the admin deliberately suppressed this email — checked
|
||||
// by orderEmail.ts before sending order-confirmation, never treated as
|
||||
// "row missing, use hardcoded default" (that's what a null return from
|
||||
// this function itself already means).
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
|
||||
@@ -851,6 +943,12 @@ export type CompanySettings = {
|
||||
sellerCity: string;
|
||||
sellerCountry: string;
|
||||
sellerEmail: string;
|
||||
// Admin-editable override for outgoing mail's "Von"-Feld (company-settings
|
||||
// "Adresse & Kontakt" tab, added 2026-07-29 alongside the SPF/SMTP-account
|
||||
// switch to einfach-produktiv.com) — falls back to sellerName/sellerEmail
|
||||
// when empty, see orderEmail.ts/alertAdmin.ts's own comments.
|
||||
emailFromName: string | null;
|
||||
emailFromAddress: string | null;
|
||||
vatId: string;
|
||||
taxRatePercent: number;
|
||||
// Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
|
||||
@@ -863,6 +961,7 @@ export type CompanySettings = {
|
||||
kleinunternehmer: boolean;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
bankName: string | null;
|
||||
};
|
||||
|
||||
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
|
||||
@@ -924,6 +1023,89 @@ export async function getKleinunternehmer(): Promise<boolean> {
|
||||
return data.docs?.[0]?.kleinunternehmer ?? false;
|
||||
}
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||||
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
|
||||
// the Navbar link) site-wide. Deliberately off by default (see
|
||||
// CompanySettings.ts's own field comment) so the feature stays entirely
|
||||
// invisible in the frontend until a tenant actually wants it, rather than
|
||||
// shipping a half-finished-looking icon everywhere.
|
||||
export async function getWishlistEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.wishlistEnabled ?? false;
|
||||
}
|
||||
|
||||
// Same pattern as getWishlistEnabled() — gates the Navbar's search icon
|
||||
// (SearchOverlay.tsx). Off by default so the feature stays invisible
|
||||
// until a tenant explicitly wants it.
|
||||
export async function getSearchEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getSearchEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { searchEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.searchEnabled ?? false;
|
||||
}
|
||||
|
||||
// Same pattern as getWishlistEnabled()/getSearchEnabled() — gates the
|
||||
// shop overview's price-range filter (ProductGrid.tsx/PriceRangeFilter.tsx).
|
||||
export async function getShopFilterEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getShopFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { shopFilterEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.shopFilterEnabled ?? false;
|
||||
}
|
||||
|
||||
// Same pattern — gates the blog overview's category filter (blog/page.tsx).
|
||||
export async function getBlogFilterEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getBlogFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { blogFilterEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.blogFilterEnabled ?? false;
|
||||
}
|
||||
|
||||
// Same pattern — gates /konto/bestellungen's status/paymentStatus/year filters.
|
||||
export async function getOrderFilterEnabled(): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`getOrderFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||||
return false;
|
||||
}
|
||||
const data: { docs?: { orderFilterEnabled?: boolean }[] } = await res.json();
|
||||
return data.docs?.[0]?.orderFilterEnabled ?? false;
|
||||
}
|
||||
|
||||
export type SeoSettings = {
|
||||
defaultTitle: string | null;
|
||||
titleTemplate: string | null;
|
||||
@@ -936,10 +1118,11 @@ export type SeoSettings = {
|
||||
// filling in the CompanySettings SEO tab is optional, not a hard
|
||||
// dependency for the site to render sensible metadata.
|
||||
const SEO_SETTINGS_FALLBACK: SeoSettings = {
|
||||
defaultTitle: "einfach produktiv. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
defaultTitle: "einfach produktiv. – Weil du auch noch ein Leben hast",
|
||||
titleTemplate: "%s | einfach produktiv.",
|
||||
defaultDescription: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
defaultOgImage: null,
|
||||
defaultDescription:
|
||||
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag – weil du auch noch ein Leben hast.",
|
||||
defaultOgImage: "/og-image.png",
|
||||
};
|
||||
|
||||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sendOrderConfirmationEmail, type OrderConfirmationEmailData } from "../orderEmail";
|
||||
import { sendOrderConfirmationEmail, sendPaymentSwitchedEmail, type OrderConfirmationEmailData } from "../orderEmail";
|
||||
import { sendCriticalAlert } from "../alertAdmin";
|
||||
|
||||
// The `order` snapshot returned by the backend's confirm-payment endpoint
|
||||
@@ -9,7 +9,13 @@ import { sendCriticalAlert } from "../alertAdmin";
|
||||
// templates), so it returns everything needed here instead of the
|
||||
// frontend needing an authenticated order-read path it doesn't otherwise
|
||||
// have (ORDER_SERVICE_SECRET only ever authorizes *creating* an order).
|
||||
export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { customerEmail: string };
|
||||
export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & {
|
||||
customerEmail: string;
|
||||
// Present only when this payment came from switchPaymentToStripe.ts (an
|
||||
// existing Überweisung order moved to Stripe) — see this function's own
|
||||
// branch below.
|
||||
paymentSwitchedAt?: string;
|
||||
};
|
||||
|
||||
// Called from both the real Stripe webhook route and its PAYMENT_TEST_MODE
|
||||
// test-confirm sibling, right after confirm-payment reports success (and
|
||||
@@ -17,10 +23,20 @@ export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { custome
|
||||
// this). Mirrors exactly what app/api/checkout/route.ts already does for
|
||||
// a manual/Überweisung order today, just triggered from the payment
|
||||
// webhook instead of the checkout request itself for gated methods.
|
||||
//
|
||||
// A payment-method switch (paymentSwitchedAt set) sends a short dedicated
|
||||
// "Zahlung erhalten" confirmation instead — the customer already got the
|
||||
// full order-confirmation email (with its invoice) when they originally
|
||||
// placed the Überweisung order; resending that same email here would read
|
||||
// as a second, brand-new purchase.
|
||||
export async function sendConfirmedPaymentEmail(order: ConfirmPaymentOrderSnapshot): Promise<void> {
|
||||
const { customerEmail, ...emailData } = order;
|
||||
try {
|
||||
await sendOrderConfirmationEmail(emailData, customerEmail);
|
||||
if (order.paymentSwitchedAt) {
|
||||
await sendPaymentSwitchedEmail(emailData, customerEmail);
|
||||
} else {
|
||||
await sendOrderConfirmationEmail(emailData, customerEmail);
|
||||
}
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nach Zahlungsbestätigung nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
|
||||
@@ -20,10 +20,12 @@ export type RawProduct = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
sku: string | null;
|
||||
price: number;
|
||||
active: boolean;
|
||||
image: { url: string } | number | null;
|
||||
taxRatePercent: number | null;
|
||||
noShippingCost: boolean;
|
||||
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
|
||||
variants: RawProductVariant[] | null;
|
||||
trackInventory: boolean;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
|
||||
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
|
||||
// same "no shared package yet" convention as app/lib/tracking.ts.
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export async function validateDhlPostNumber(args: {
|
||||
postNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}): Promise<{ ok: true; valid: boolean } | { ok: false; reason: string }> {
|
||||
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, ...args });
|
||||
try {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/dhl/validate-post-number?${params}`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) return { ok: false, reason: data.reason ?? "Postnummer konnte nicht geprüft werden." };
|
||||
return { ok: true, valid: Boolean(data.valid) };
|
||||
} catch {
|
||||
return { ok: false, reason: "Postnummer-Prüfung ist gerade nicht erreichbar." };
|
||||
}
|
||||
}
|
||||
|
||||
export type DhlAddressSuggestion = {
|
||||
street: string;
|
||||
houseNumber?: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export async function autocompleteDhlAddress(query: string): Promise<DhlAddressSuggestion[]> {
|
||||
if (query.trim().length < 3) return [];
|
||||
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, query });
|
||||
try {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/dhl/autocomplete-address?${params}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) return [];
|
||||
return data.suggestions as DhlAddressSuggestion[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CompanySettings, Product } from "./payload";
|
||||
|
||||
// Pure JSON-LD builders — schema.org structured data for Google rich
|
||||
// snippets (business info, product rich results, article cards). No
|
||||
// component/rendering logic here; callers render the result via
|
||||
// `<script type="application/ld+json">`. Kept separate from
|
||||
// emailTemplates.ts/invoiceData.ts's own seller-formatting logic since
|
||||
// schema.org's shape requirements are different from what an email/PDF
|
||||
// needs (e.g. a `PostalAddress` object, not formatted address lines).
|
||||
|
||||
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
||||
|
||||
// One Organization node reused as `publisher`/`seller` wherever those
|
||||
// are needed (Article, Product) — schema.org allows (and Google prefers)
|
||||
// linking back to a single canonical Organization via @id rather than
|
||||
// repeating the full object on every page.
|
||||
export function buildOrganizationSchema(seller: CompanySettings | null): Record<string, unknown> {
|
||||
if (!seller) {
|
||||
// Minimal fallback — still valid Organization markup even if
|
||||
// company-settings is unreachable, better than emitting nothing at
|
||||
// all (a page load shouldn't fail over structured data).
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: "einfach produktiv",
|
||||
url: SITE_URL,
|
||||
};
|
||||
}
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: seller.sellerName,
|
||||
url: SITE_URL,
|
||||
email: seller.sellerEmail,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: seller.sellerStreet,
|
||||
postalCode: seller.sellerZip,
|
||||
addressLocality: seller.sellerCity,
|
||||
addressCountry: seller.sellerCountry === "Deutschland" ? "DE" : seller.sellerCountry,
|
||||
},
|
||||
// vatID is a real schema.org Organization property (distinct from
|
||||
// taxID) — only included when set, same "omit rather than print an
|
||||
// empty value" convention as buildLegalFooterLines() elsewhere.
|
||||
...(seller.vatId ? { vatID: seller.vatId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductSchema(product: Product, url: string, seller: CompanySettings | null): Record<string, unknown> {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
image: product.image,
|
||||
url,
|
||||
// No reviews/ratings system exists yet — `aggregateRating` is
|
||||
// optional in the spec and deliberately omitted rather than faked;
|
||||
// add it here once real reviews exist, not before.
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url,
|
||||
priceCurrency: "EUR",
|
||||
price: product.price.toFixed(2),
|
||||
availability: product.outOfStock
|
||||
? "https://schema.org/OutOfStock"
|
||||
: "https://schema.org/InStock",
|
||||
seller: { "@id": `${SITE_URL}/#organization` },
|
||||
},
|
||||
...(seller ? { brand: { "@type": "Brand", name: seller.sellerName } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildArticleSchema(
|
||||
post: { title: string; excerpt: string; thumbnail: string | null; publishedAt: string; slug: string },
|
||||
seller: CompanySettings | null,
|
||||
): Record<string, unknown> {
|
||||
const url = `${SITE_URL}/blog/${post.slug}`;
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
mainEntityOfPage: url,
|
||||
datePublished: post.publishedAt,
|
||||
...(post.thumbnail ? { image: post.thumbnail } : {}),
|
||||
// Single-author blog with no author field on Posts (see Posts.ts) —
|
||||
// "Björn" is already hardcoded in the page's own author-bio block,
|
||||
// matched here rather than left out entirely.
|
||||
author: { "@type": "Person", name: "Björn" },
|
||||
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv" },
|
||||
};
|
||||
}
|
||||
|
||||
// Renders as a plain object, not a component — callers do
|
||||
// `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />`
|
||||
// directly (no need for a shared component around one line of JSX, and
|
||||
// keeps this file free of "use client"/React concerns so server
|
||||
// components can import it without issue).
|
||||
@@ -9,6 +9,16 @@ import type { NewsletterOptInSource } from "./brevo";
|
||||
// hero form, /challenge's EmailCapture) — four places with the same
|
||||
// email+consent+submit shape but different markup/visual style, so only
|
||||
// the logic is shared here rather than a one-size-fits-all component.
|
||||
// Per the user's own explicit wording request, see the newsletter-DOI
|
||||
// memory — kept here once rather than duplicated across all 4 forms.
|
||||
const SUCCESS_MESSAGE = "Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.";
|
||||
// Deliberately routed through the *error* state, not a success variant —
|
||||
// per explicit feedback: swapping the whole form out for a bare message
|
||||
// (the real-success treatment) felt wrong for "you're already signed up,
|
||||
// nothing to do" — the form should stay visible, with a small note below
|
||||
// it, exactly like every other inline validation error already does.
|
||||
const ALREADY_SUBSCRIBED_MESSAGE = "Diese E-Mail-Adresse ist schon für unseren Newsletter angemeldet.";
|
||||
|
||||
export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
@@ -17,15 +27,34 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
const [error, setError] = useState("");
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Clears a previous submit-time error (real failure or "already
|
||||
// subscribed") the moment the customer interacts with the form again —
|
||||
// same "stale validation message shouldn't linger" behavior
|
||||
// emailError already had for itself, extended to the submit-result
|
||||
// error too, since it's otherwise easy to misread as still describing
|
||||
// the current (possibly already-corrected) input.
|
||||
function clearSubmitError() {
|
||||
if (status === "error") {
|
||||
setStatus("idle");
|
||||
setError("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailChange(value: string) {
|
||||
setEmail(value);
|
||||
if (emailError) setEmailError("");
|
||||
clearSubmitError();
|
||||
}
|
||||
|
||||
function handleEmailBlur(value: string) {
|
||||
setEmailError(validateEmailFormat(value));
|
||||
}
|
||||
|
||||
function handleConsentChange(checked: boolean) {
|
||||
setConsent(checked);
|
||||
clearSubmitError();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formatError = validateEmailFormat(email);
|
||||
@@ -48,6 +77,11 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (data.alreadySubscribed) {
|
||||
setError(ALREADY_SUBSCRIBED_MESSAGE);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
|
||||
@@ -55,5 +89,5 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
}
|
||||
}
|
||||
|
||||
return { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
|
||||
return { email, emailError, consent, handleConsentChange, status, error, successMessage: SUCCESS_MESSAGE, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Server-backed (needs a logged-in customer, unlike the cart which works
|
||||
// for guests via localStorage — a wishlist tied to nothing would just
|
||||
// evaporate on the next visit, which defeats the point) — so this can't
|
||||
// reuse cart.ts's useSyncExternalStore-over-localStorage pattern. Instead:
|
||||
// a plain fetch on mount + a custom window event so every WishlistButton/
|
||||
// the Navbar badge on the page stays in sync after any one of them toggles
|
||||
// an item, without a shared cache library.
|
||||
const WISHLIST_EVENT = "ep-wishlist-updated";
|
||||
|
||||
type WishlistItem = { id: number; productId: number; variant: string };
|
||||
|
||||
let cachedItems: WishlistItem[] | null = null;
|
||||
|
||||
async function fetchWishlist(): Promise<WishlistItem[]> {
|
||||
const res = await fetch("/api/account/wishlist", { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data: { items?: WishlistItem[] } = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
window.dispatchEvent(new Event(WISHLIST_EVENT));
|
||||
}
|
||||
|
||||
export function useWishlist() {
|
||||
const [items, setItems] = useState<WishlistItem[]>(cachedItems ?? []);
|
||||
const [loading, setLoading] = useState(cachedItems === null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const fresh = await fetchWishlist();
|
||||
cachedItems = fresh;
|
||||
setItems(fresh);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
window.addEventListener(WISHLIST_EVENT, load);
|
||||
return () => window.removeEventListener(WISHLIST_EVENT, load);
|
||||
}, [load]);
|
||||
|
||||
const isWishlisted = useCallback(
|
||||
(productId: number, variant = "") => items.some((i) => i.productId === productId && i.variant === variant),
|
||||
[items],
|
||||
);
|
||||
|
||||
// Optimistic: flips the LOCAL list immediately (this component's own
|
||||
// `items`/`cachedItems`), reconciles with the server response (or
|
||||
// reverts on failure) rather than waiting for the round trip — same
|
||||
// "feels instant" reasoning as AddToCartButton. Crucially, `broadcast()`
|
||||
// (which tells every OTHER useWishlist() instance — e.g. the Navbar
|
||||
// badge — to refetch) only fires AFTER the request settles, never
|
||||
// before: broadcasting immediately after the optimistic update used to
|
||||
// race the POST itself — another instance's resulting `load()` could
|
||||
// hit the server before the toggle had actually been persisted there,
|
||||
// fetch the pre-toggle list, and then never get told to refetch again,
|
||||
// leaving e.g. the Navbar count permanently one behind the real value.
|
||||
const toggle = useCallback(async (productId: number, variant = "") => {
|
||||
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
|
||||
const optimistic = wasWishlisted
|
||||
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
|
||||
: [...(cachedItems ?? []), { id: -1, productId, variant }];
|
||||
cachedItems = optimistic;
|
||||
setItems(optimistic);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/wishlist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productId, variant }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
return { ok: false as const, unauthorized: true as const };
|
||||
}
|
||||
if (!res.ok) throw new Error("request failed");
|
||||
const data: { ok: boolean; wishlisted?: boolean } = await res.json();
|
||||
if (!data.ok) throw new Error("toggle failed");
|
||||
// Reconcile with the server's own id (needed for a later toggle-off
|
||||
// that hasn't refetched the list yet) rather than trusting the
|
||||
// optimistic placeholder id (-1) forever.
|
||||
await load();
|
||||
broadcast();
|
||||
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
|
||||
} catch {
|
||||
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
|
||||
setItems(cachedItems);
|
||||
return { ok: false as const };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, isWishlisted, toggle, count: items.length };
|
||||
}
|
||||
@@ -40,17 +40,36 @@ export default function NewsletterConfirmedPage() {
|
||||
>
|
||||
Bestätigt!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Du bist jetzt Teil unseres Newsletters.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
<p className="text-body text-text-muted max-w-[28rem] pt-2">
|
||||
Schön, dass du dabei bist! Ab jetzt bekommst du hin und wieder Impulse, neue Produkte
|
||||
und kleine Erinnerungen von uns, damit dein Alltag ein bisschen leichter wird.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 text-left text-body text-text-muted max-w-[28rem] pt-2">
|
||||
<p>Hallo,</p>
|
||||
<p>schön, dass du da bist!</p>
|
||||
<p>
|
||||
Ab jetzt bekommst du wöchentlich meine Sonntags-Impulse direkt in dein Postfach –
|
||||
mit kurzen Gedanken und praktischen Impulsen für mehr Klarheit im Alltag.
|
||||
</p>
|
||||
<p>Als kleines Dankeschön habe ich direkt etwas für dich:</p>
|
||||
|
||||
<div className="flex flex-col gap-3 bg-bg-muted rounded-md px-6 py-5">
|
||||
<p className="font-semibold text-text-primary">🧭 Dein Klarheitskompass</p>
|
||||
<p>Du erhältst ihn in einer separaten E-Mail in den nächsten Minuten – er hilft dir, kurz innezuhalten und dich zu fragen:</p>
|
||||
<ul className="flex flex-col gap-1 list-disc pl-5">
|
||||
<li>Was beschäftigt mich gerade?</li>
|
||||
<li>Was ist wirklich wichtig?</li>
|
||||
<li>Worauf möchte ich meinen Fokus legen?</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Bis die erste Mail am Sonntag kommt, habe ich noch eine kleine Einladung:</p>
|
||||
<p>
|
||||
Beobachte in den nächsten Tagen einfach einmal, womit du deine Aufmerksamkeit
|
||||
verbringst. Nicht bewerten. Nur wahrnehmen.
|
||||
</p>
|
||||
<p>Bis Sonntag</p>
|
||||
<p className="text-text-primary font-semibold">Björn</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
|
||||
@@ -39,7 +39,7 @@ const steps = [
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
@@ -53,14 +53,14 @@ export function HowItWorks() {
|
||||
</Reveal>
|
||||
|
||||
{/* Same pattern as /todo-cards's HowItWorks: arrows always visible
|
||||
(rotated to point down while stacked below md:), RevealGroup/
|
||||
(rotated to point down while stacked below sm:), RevealGroup/
|
||||
RevealItem stagger the steps in. lg:px-[10rem] mirrors Figma's
|
||||
Desktop-only px-[160px] inset (no fluid token — pure aesthetic
|
||||
narrowing, fine to just drop below lg:). */}
|
||||
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[10rem]">
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
|
||||
{steps.map((step, i) => (
|
||||
<Fragment key={step.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs md:max-w-none">
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
<Image
|
||||
src={step.icon}
|
||||
alt=""
|
||||
@@ -72,15 +72,15 @@ export function HowItWorks() {
|
||||
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
// md:mt-[1.5rem] centers the arrow on the h-16 icon above it,
|
||||
// sm:mt-[1.5rem] centers the arrow on the h-16 icon above it,
|
||||
// same technique as todo-cards'/Challenge's own step
|
||||
// connector. Below md: pulled up with a negative margin so it
|
||||
// connector. Below sm: pulled up with a negative margin so it
|
||||
// sits nearer the icon row above it instead of dead-center in
|
||||
// the whole gap between steps (fixed 2026-07-24, consistency
|
||||
// with Challenge's icon-at-top layout). Bigger below md:
|
||||
// with Challenge's icon-at-top layout). Bigger below sm:
|
||||
// (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div className="flex items-center justify-center shrink-0 -mt-2 md:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
|
||||
<div className="flex items-center justify-center shrink-0 -mt-2 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -35,16 +35,17 @@ const checklist = [
|
||||
];
|
||||
|
||||
export function WeeklyImpulsesHero() {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-hero");
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
|
||||
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
|
||||
to a text column gets too cramped at Tablet widths under the
|
||||
site-wide md: convention. lg:items-center removed — see the
|
||||
breadcrumb/centering comment below, same fix as /todo-cards. */}
|
||||
to a text column gets too cramped at Tablet widths even under
|
||||
the site-wide sm: (640px) structural consolidation. lg:items-
|
||||
center removed — see the breadcrumb/centering comment below,
|
||||
same fix as /todo-cards. */}
|
||||
{/* pt-10 md:pt-12, no lg override — same fix and same reasoning as
|
||||
/todo-cards's Hero: this is now the only thing positioning the
|
||||
breadcrumb, and it must match /todo-cards's and /challenge's
|
||||
@@ -111,9 +112,7 @@ export function WeeklyImpulsesHero() {
|
||||
Newsletter component's panel form (no button-adjacent styling
|
||||
needed here, just input + submit inline). */}
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 items-start w-full">
|
||||
{/* flex-col sm:flex-row, no items-start at the base tier —
|
||||
@@ -157,7 +156,7 @@ export function WeeklyImpulsesHero() {
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
|
||||
@@ -140,11 +140,11 @@ export default function NotFound() {
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-8 w-full max-w-[64rem] divide-y md:divide-y-0 md:divide-x divide-border">
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-3 gap-10 sm:gap-8 w-full max-w-[64rem] divide-y sm:divide-y-0 sm:divide-x divide-border">
|
||||
{helpLinks.map((link) => (
|
||||
<RevealItem
|
||||
key={link.href}
|
||||
className="group flex flex-col items-center text-center gap-3 pt-10 md:pt-0 first:pt-0 px-4 transition-transform duration-200 hover:-translate-y-1"
|
||||
className="group flex flex-col items-center text-center gap-3 pt-10 sm:pt-0 first:pt-0 px-4 transition-transform duration-200 hover:-translate-y-1"
|
||||
>
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-bg-muted text-brand transition-transform duration-300 group-hover:scale-110">
|
||||
{link.icon}
|
||||
@@ -180,17 +180,17 @@ export default function NotFound() {
|
||||
reusing an unrelated existing asset — same real-photo
|
||||
extraction convention the Figma rebuild used, just done on
|
||||
the flat mockup image instead of in Figma. */}
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full md:w-[45%] shrink-0">
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full sm:w-[45%] shrink-0">
|
||||
<Image
|
||||
alt=""
|
||||
src="/404-testimonial-photo.jpg"
|
||||
width={810}
|
||||
height={291}
|
||||
sizes="(min-width: 768px) 45vw, 100vw"
|
||||
sizes="(min-width: 640px) 45vw, 100vw"
|
||||
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
|
||||
/>
|
||||
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16 py-6">
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Real min/max range (not preset toggle buckets, tried first and
|
||||
// reverted 2026-07-30 — "keine toggle badges") — two plain number inputs,
|
||||
// submitted via a small Client Component's router.push. Still a plain
|
||||
// URL search param underneath (?minPrice=&maxPrice=), so the result stays
|
||||
// shareable/bookmarkable like every other filter on the site.
|
||||
export function PriceRangeFilter({
|
||||
catalogMin,
|
||||
catalogMax,
|
||||
layout = "bar",
|
||||
}: {
|
||||
catalogMin: number;
|
||||
catalogMax: number;
|
||||
/** "bar" (default): horizontal row, wraps — used above the grid at
|
||||
* <lg (see ProductGrid.tsx's mobile/tablet filter bar). "sidebar":
|
||||
* stacked vertically to fit the lg:+ left sidebar column instead. */
|
||||
layout?: "bar" | "sidebar";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
|
||||
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
|
||||
|
||||
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
|
||||
|
||||
function apply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (minPrice) params.set("minPrice", minPrice);
|
||||
if (maxPrice) params.set("maxPrice", maxPrice);
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setMinPrice("");
|
||||
setMaxPrice("");
|
||||
router.push("/shop");
|
||||
}
|
||||
|
||||
const sidebar = layout === "sidebar";
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={apply}
|
||||
className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-wrap items-end gap-3 w-full pb-6"}
|
||||
>
|
||||
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
|
||||
<div className={sidebar ? "flex items-end gap-3 w-full" : "flex items-end gap-3"}>
|
||||
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
|
||||
<span className="text-label text-text-muted">Von</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder={`${catalogMin}`}
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
|
||||
/>
|
||||
</label>
|
||||
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
|
||||
<span className="text-label text-text-muted">Bis</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder={`${catalogMax}`}
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
|
||||
/>
|
||||
</label>
|
||||
{!sidebar && <span className="text-body-sm text-text-muted">€</span>}
|
||||
</div>
|
||||
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${sidebar ? "w-full" : ""} px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all`}
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
{hasFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className={`${sidebar ? "text-center" : ""} text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors`}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
import { WishlistButton } from "../../components/WishlistButton";
|
||||
import { PriceRangeFilter } from "./PriceRangeFilter";
|
||||
|
||||
// Server Component — fetches straight from Payload (getProducts(), ISR
|
||||
// cached 60s) rather than going through the client-side useProducts()
|
||||
@@ -12,16 +14,24 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
// there's no reason to pay for a client fetch when a server one already
|
||||
// gives faster first paint and no loading flash.
|
||||
|
||||
export async function ProductGrid() {
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
// Products have no category taxonomy today (only Posts do) — a "N
|
||||
// checkboxes" category sidebar isn't buildable against real data yet, so
|
||||
// this only covers price, the one dimension that already exists on every
|
||||
// product. A real min/max range (PriceRangeFilter.tsx), not preset toggle
|
||||
// buckets — tried buckets-as-toggle-chips first, reverted 2026-07-30
|
||||
// ("keine toggle badges").
|
||||
export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?: string; maxPrice?: string } }) {
|
||||
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled, shopFilterEnabled] = await Promise.all([
|
||||
getProducts(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
getShopFilterEnabled(),
|
||||
]);
|
||||
const products = allProducts.filter((p) => p.active);
|
||||
const allActiveProducts = allProducts.filter((p) => p.active);
|
||||
|
||||
if (products.length === 0) {
|
||||
if (allActiveProducts.length === 0) {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col items-center pb-16 md:pb-20 px-[var(--layout-padding-x)]">
|
||||
<p className="text-body text-text-muted">Aktuell keine Produkte verfügbar.</p>
|
||||
@@ -29,10 +39,49 @@ export async function ProductGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
const catalogPrices = allActiveProducts.map((p) => p.price);
|
||||
const catalogMin = Math.min(...catalogPrices);
|
||||
const catalogMax = Math.max(...catalogPrices);
|
||||
const minPrice = shopFilterEnabled && searchParams?.minPrice ? Number(searchParams.minPrice) : null;
|
||||
const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
|
||||
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
|
||||
|
||||
const hasSidebarFilter = shopFilterEnabled && catalogMin !== catalogMax;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
{products.map((product) => {
|
||||
{/* <lg: filter (if any) sits as its own bar above the grid — same as
|
||||
before. lg+: it moves into a left sidebar instead (see aside
|
||||
below), so it's hidden here to avoid rendering twice. */}
|
||||
{hasSidebarFilter && (
|
||||
<div className="lg:hidden">
|
||||
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* lg:flex — a real left sidebar only once there's an actual filter
|
||||
to put in it (hasSidebarFilter); with no filter, the grid alone
|
||||
fills the row exactly as before, no empty reserved column. */}
|
||||
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
|
||||
{hasSidebarFilter && (
|
||||
<aside className="hidden lg:block lg:w-56 shrink-0">
|
||||
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
|
||||
|
||||
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px — was
|
||||
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
|
||||
card through that tablet range. lg+ is now 3-up (col-span-4 of
|
||||
12) rather than 4-up — narrowed to leave room for the sidebar
|
||||
filter alongside it (see hasSidebarFilter above); with no
|
||||
filter active the grid still renders at this same 3-up density,
|
||||
simplest to keep one fixed lg: density rather than branching
|
||||
the whole grid on hasSidebarFilter too. */}
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
|
||||
{products.map((product) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// A varianted product only reads as "ausverkauft" overall once
|
||||
@@ -48,14 +97,14 @@ export async function ProductGrid() {
|
||||
return (
|
||||
<RevealItem
|
||||
key={product.id}
|
||||
className="group md:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 25vw, 100vw"
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
@@ -69,6 +118,9 @@ export async function ProductGrid() {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p
|
||||
@@ -119,8 +171,10 @@ export async function ProductGrid() {
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
</RevealGroup>
|
||||
})}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,12 +19,17 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShopPage() {
|
||||
export default async function ShopPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ minPrice?: string; maxPrice?: string }>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<ShopHeader />
|
||||
<ProductGrid />
|
||||
<ProductGrid searchParams={resolvedSearchParams} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -32,7 +32,7 @@ const steps = [
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
@@ -47,17 +47,17 @@ export function HowItWorks() {
|
||||
|
||||
{/* Arrows always visible (see Divider lesson — hiding them below a
|
||||
breakpoint isn't the fix), just rotated 90° to point down while
|
||||
stacked below md:. lg:px-[16.25rem] mirrors Figma's Desktop-only
|
||||
stacked below sm:. lg:px-[16.25rem] mirrors Figma's Desktop-only
|
||||
px-[260px] inset — that value doesn't get its own fluid token
|
||||
since it's a pure aesthetic narrowing, not something that breaks
|
||||
if it's just absent below lg:. RevealGroup/RevealItem stagger the
|
||||
3 steps in on scroll, same pattern as Tools.tsx's card grid —
|
||||
arrows aren't part of the stagger (purely decorative connectors,
|
||||
not content), they just sit in the DOM between items. */}
|
||||
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[16.25rem]">
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
|
||||
{steps.map((step, i) => (
|
||||
<Fragment key={step.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs md:max-w-none">
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
{/* Explicit per-icon width/height (real pixel dimensions, not
|
||||
a guessed/uniform size) — matters since one of the three
|
||||
source PNGs isn't square (icon-step-2 is 180x168); passing
|
||||
@@ -79,13 +79,13 @@ export function HowItWorks() {
|
||||
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
// md:mt-[1.625rem] (26px) centers the arrow on the h-16
|
||||
// sm:mt-[1.625rem] (26px) centers the arrow on the h-16
|
||||
// (64px) icon above it — (64 - arrow's own 16px height) / 2
|
||||
// — same margin-based centering technique as Challenge's
|
||||
// step connector, not just the same icon asset. Bigger below
|
||||
// md: (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div className="flex items-center justify-center shrink-0 md:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
|
||||
// sm: (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -97,10 +97,18 @@ export async function Pricing() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with —
|
||||
plain conditional line, same reasoning as ProductSpotlight.tsx. */}
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function TodoKartenHero() {
|
||||
{/* Same lg:-only structural exception as the Home Hero (see
|
||||
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
|
||||
to a text column is exactly the shape that gets too cramped at
|
||||
Tablet widths under the site-wide md: convention. lg:items-center
|
||||
Tablet widths even under the site-wide sm: (640px) structural
|
||||
consolidation. lg:items-center
|
||||
removed (default grid align-items is stretch) — that's what lets
|
||||
the breadcrumb below sit at a fixed Y position across every hero
|
||||
section on the site instead of shifting per page depending on how
|
||||
@@ -123,12 +124,20 @@ export async function TodoKartenHero() {
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
{!product?.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{/* Single product, no grid siblings to stay equal-height with —
|
||||
plain conditional line, same reasoning as ProductSpotlight.tsx/Pricing.tsx. */}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { getTestimonials, getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "ToDo-Karten – Kleine Karten. Große Wirkung.";
|
||||
const description =
|
||||
@@ -34,10 +35,18 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function TodoCardsPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("todo-cards", { draft: isPreview });
|
||||
const [testimonials, product, seller] = await Promise.all([
|
||||
getTestimonials("todo-cards", { draft: isPreview }),
|
||||
getProductBySlug("todo-karten"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product ? buildProductSchema(product, "https://einfach-produktiv.mk360.de/todo-cards", seller) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<TodoKartenHero />
|
||||
<HowItWorks />
|
||||
|
||||
@@ -77,6 +77,10 @@ export function VersandSections({
|
||||
wir kostenlos.
|
||||
</p>
|
||||
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
|
||||
<p>
|
||||
Einzelne Produkte können von den oben genannten Versandkosten ausgenommen sein (z.B.
|
||||
digitale Produkte) — das ist dann direkt auf der jeweiligen Produktseite vermerkt.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="liefergebiet" title="Liefergebiet" withAnchor={withAnchors}>
|
||||
|
||||
@@ -75,14 +75,18 @@ export default async function WiderrufPage() {
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
|
||||
{page?.attachment && (
|
||||
<a
|
||||
href={page.attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
|
||||
>
|
||||
{/* Generated live from company-settings (/api/muster-widerrufsformular)
|
||||
instead of the CMS attachment field — see that route's own
|
||||
comment on why: the static uploaded PDF's "An:" address
|
||||
silently went stale whenever an admin updated the
|
||||
Impressum's Anbieterdaten without also re-exporting this
|
||||
file by hand. */}
|
||||
<a
|
||||
href="/api/muster-widerrufsformular"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-sm bg-bg-white border border-border text-brand">
|
||||
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
|
||||
<path
|
||||
@@ -103,20 +107,19 @@ export default async function WiderrufPage() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<p className="font-semibold text-body text-text-primary">
|
||||
{page.attachment.title || "Muster-Widerrufsformular (PDF)"}
|
||||
Muster-Widerrufsformular (PDF)
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">Herunterladen</p>
|
||||
<p className="text-body-sm text-text-muted">In neuem Tab öffnen</p>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-y-0.5 transition-all"
|
||||
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-x-0.5 transition-all"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4v13m0 0-5-5m5 5 5-5M5 21h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -455,8 +455,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@einfach-produktiv/invoicing": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#ceaa437724dae1adfd582ca209300d2ee0a10e68",
|
||||
"version": "0.2.9",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#87cf1db330e6a415fbcd31b83c6c4470a73b07a9",
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 82 KiB |