Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,94 @@ 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.
|
||||
|
||||
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).
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -126,7 +126,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,
|
||||
@@ -444,6 +444,7 @@ export async function POST(request: Request) {
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -183,6 +183,12 @@ 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.
|
||||
<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"
|
||||
@@ -328,8 +334,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 +349,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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
+9
-9
@@ -30,8 +30,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,9 +42,9 @@ 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.jpg" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover" />
|
||||
<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>
|
||||
@@ -56,14 +56,14 @@ 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>•</span>
|
||||
|
||||
@@ -176,9 +176,10 @@ export function CartContent({
|
||||
{/* 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);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -911,7 +911,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"
|
||||
|
||||
@@ -26,6 +26,7 @@ const FALLBACK: CompanySettings = {
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
|
||||
+32
-23
@@ -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">
|
||||
|
||||
@@ -80,13 +83,14 @@ export function About() {
|
||||
</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>
|
||||
|
||||
+13
-13
@@ -31,20 +31,20 @@ 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>
|
||||
@@ -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,25 +75,25 @@ 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 on Mobile, side-by-side from sm+ */}
|
||||
<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 sm: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 sm:aspect-auto sm:col-span-2 sm:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 29vw, 100vw"
|
||||
sizes="(min-width: 640px) 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 sm: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>
|
||||
@@ -107,7 +107,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 sm:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</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,11 +19,10 @@ 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:. */}
|
||||
<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">
|
||||
sm: (640px, moved down from the old md:/lg: skip-pattern now
|
||||
that grid/flex structure across the site aligns with the fluid
|
||||
floor). Side by side again once there's real room at sm:. */}
|
||||
<div className="flex flex-col sm:flex-row items-center sm:justify-between gap-6 sm:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
<div className="flex items-center p-2 shrink-0">
|
||||
|
||||
+67
-47
@@ -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
|
||||
Finde heraus, was wichtig ist
|
||||
</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"
|
||||
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 />
|
||||
|
||||
@@ -428,7 +428,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);
|
||||
|
||||
@@ -32,9 +32,9 @@ type NewsletterProps = {
|
||||
*/
|
||||
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.",
|
||||
description = "Melde dich an und bekommst 7 kurze Impulse direkt ins Postfach – in ein paar Minuten gelesen, sofort im Alltag umsetzbar.",
|
||||
}: 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,17 @@ 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">
|
||||
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
|
||||
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 sm:py-0 rounded-md w-full">
|
||||
|
||||
{/* 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. Row layout with the icon beside the text
|
||||
is fine again from sm+, where the fixed copy-column width
|
||||
leaves real room. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full sm:flex-row sm:items-start sm:gap-8 sm:text-left sm:w-[var(--newsletter-copy-width)] sm:py-4 sm:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
@@ -89,22 +89,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 +142,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">
|
||||
|
||||
@@ -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,7 +144,17 @@ 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"
|
||||
>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
@@ -156,14 +166,17 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<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 +185,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>
|
||||
|
||||
@@ -186,13 +199,11 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
</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 zum Newsletter an und bekommst 7 kurze Impulse direkt ins Postfach – in ein paar Minuten gelesen, sofort im Alltag umsetzbar.
|
||||
</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">
|
||||
@@ -209,9 +220,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 +240,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 +256,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 +276,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">
|
||||
|
||||
@@ -42,14 +42,14 @@ 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)]">
|
||||
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col sm:flex-row gap-8 sm:gap-12 items-center p-6 sm:p-10">
|
||||
<div className="group relative w-full sm:w-[23.75rem] sm: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: 640px) 380px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-11
@@ -20,33 +20,39 @@ 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.
|
||||
Werkzeuge 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"
|
||||
className="sm:col-span-4 flex gap-8 items-start 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>
|
||||
|
||||
@@ -11,10 +11,10 @@ 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)]">
|
||||
<div className="w-full bg-bg-base flex flex-col sm:flex-row gap-6 sm:gap-12 items-start sm: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 key={item.id} className="flex items-center gap-6 sm:gap-12">
|
||||
{i > 0 && <div className="hidden sm: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" />
|
||||
|
||||
@@ -16,6 +16,9 @@ const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-cancelled",
|
||||
"order-return-requested",
|
||||
"order-returned",
|
||||
"order-tracking-added",
|
||||
"order-tracking-corrected",
|
||||
"order-delivered",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
@@ -23,6 +26,9 @@ 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",
|
||||
};
|
||||
|
||||
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
|
||||
|
||||
+57
-53
@@ -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 {
|
||||
|
||||
@@ -33,7 +33,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 =
|
||||
|
||||
+12
-2
@@ -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 } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -66,8 +67,16 @@ 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] = await Promise.all([getProducts(), getCompanySettings()]);
|
||||
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,6 +84,7 @@ 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} />
|
||||
|
||||
+33
-7
@@ -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}` };
|
||||
|
||||
+40
-3
@@ -416,9 +416,33 @@ 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",
|
||||
};
|
||||
}
|
||||
|
||||
// `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): Promise<CustomerOrder[]> {
|
||||
const params = new URLSearchParams({
|
||||
"where[customer][equals]": String(customerId),
|
||||
"where[and][0][customer][equals]": String(customerId),
|
||||
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
|
||||
sort: "-createdAt",
|
||||
depth: "0",
|
||||
limit: "50",
|
||||
@@ -508,10 +532,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,31 @@ 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): 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>
|
||||
</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
|
||||
@@ -181,6 +206,14 @@ 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;
|
||||
};
|
||||
|
||||
export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
@@ -202,6 +235,7 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
discountAmount: 5,
|
||||
discountCode: "WILLKOMMEN10",
|
||||
total: 37.7,
|
||||
isManualPayment: false,
|
||||
};
|
||||
|
||||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
|
||||
@@ -263,6 +297,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
</tr>
|
||||
${taxRows}
|
||||
</table>
|
||||
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller) : ""}
|
||||
`;
|
||||
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||||
@@ -278,6 +313,9 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-cancelled": "✕",
|
||||
"order-return-requested": "↩",
|
||||
"order-returned": "✓",
|
||||
"order-tracking-added": "📦",
|
||||
"order-tracking-corrected": "📦",
|
||||
"order-delivered": "🎉",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const MIN_VW = 768;
|
||||
const MIN_VW = 640;
|
||||
const MAX_VW = 1440;
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,14 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
|
||||
// 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")) ?? {
|
||||
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.",
|
||||
|
||||
+32
-4
@@ -638,9 +638,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 +649,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 +817,10 @@ export type EmailTemplateType =
|
||||
| "order-shipped"
|
||||
| "order-cancelled"
|
||||
| "order-return-requested"
|
||||
| "order-returned";
|
||||
| "order-returned"
|
||||
| "order-tracking-added"
|
||||
| "order-tracking-corrected"
|
||||
| "order-delivered";
|
||||
|
||||
type PayloadEmailTemplate = {
|
||||
type: EmailTemplateType;
|
||||
@@ -806,6 +828,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,
|
||||
@@ -863,6 +890,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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function ProductGrid() {
|
||||
|
||||
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">
|
||||
<RevealGroup className="grid grid-cols-1 sm: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);
|
||||
@@ -48,14 +48,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 sm: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"
|
||||
>
|
||||
<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: 640px) 25vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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 />
|
||||
|
||||
Generated
+2
-2
@@ -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.6",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#d6e83db5cf6343e65e2fa25f9ead49fce9a5a308",
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user