Compare commits
354 Commits
225a8567a9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f3adb92c | |||
| 31e4f907f2 | |||
| d02707a212 | |||
| e9b7a2f582 | |||
| bd1b73e304 | |||
| 98b3b3b6da | |||
| 8f006b6603 | |||
| 0791e7c8bc | |||
| e882b8b6ac | |||
| 1ab4088bf0 | |||
| a2d4cb29fc | |||
| 1d7f49217c | |||
| 8b9e2bdb42 | |||
| a1b0dffff7 | |||
| 1330e3e621 | |||
| 21b5f41127 | |||
| 7489f83564 | |||
| 82696cb259 | |||
| 0fea84f362 | |||
| 56fae6ff13 | |||
| 39ebf2604d | |||
| 2280a4a4ba | |||
| b0d76c891f | |||
| 7fc7ee2c3a | |||
| 098ba79c8f | |||
| f4af0bc15c | |||
| 2d9508324a | |||
| 885389b300 | |||
| 940545888c | |||
| e2d81fabc7 | |||
| a1c9a2127b | |||
| c2d0b6e364 | |||
| 349bea5261 | |||
| 85478ff746 | |||
| cf5d7f8739 | |||
| b26a44b979 | |||
| 0fedb386b4 | |||
| 7b9e833560 | |||
| 3be8682e1a | |||
| 5e48b67588 | |||
| 4326072fbe | |||
| f0f5252d10 | |||
| 0de2e1c1be | |||
| 76d59a5682 | |||
| af5956848b | |||
| cdc779aebe | |||
| 9af970587d | |||
| fff2467d19 | |||
| e9a793db08 | |||
| cfa658a210 | |||
| 2dee2e7871 | |||
| 790762979e | |||
| c64902c381 | |||
| bf48a1561c | |||
| 168f17bcef | |||
| 7e00d51b89 | |||
| 233a41f7c9 | |||
| 24ae330037 | |||
| 821fbb0207 | |||
| 194117c07d | |||
| d2332c37e9 | |||
| d2255c47f2 | |||
| 2b39ec757e | |||
| 1f537ccb06 | |||
| b59c9a0e4b | |||
| 6debcd5a46 | |||
| b3d7855b0e | |||
| ba6e486e14 | |||
| 71cd75425d | |||
| 4fa80d6bfb | |||
| eb160cac61 | |||
| 13fb37eb5e | |||
| 988f259371 | |||
| a085a75dea | |||
| b6e76ccd22 | |||
| c065a3223f | |||
| 7f95711048 | |||
| e7ad9cd88d | |||
| 340dcd2c94 | |||
| 70bc839f48 | |||
| 82c1b1fd43 | |||
| c8f231baa6 | |||
| 9617478b0d | |||
| 23f8efcc2b | |||
| 6e6f273524 | |||
| 3336ce77c1 | |||
| 90c52687bf | |||
| 7ef6d2652b | |||
| ac6c3d6b71 | |||
| 417bbd430e | |||
| f6b001dd11 | |||
| a8ab3e4f44 | |||
| 72b7bc629c | |||
| 43ab5f2407 | |||
| 537543bf91 | |||
| 259002f5c0 | |||
| 87388479de | |||
| 70ee518e1d | |||
| c6b12e9d60 | |||
| d046e5c3bb | |||
| 2638515683 | |||
| 0dcaad8b8c | |||
| b0df2f13fa | |||
| 51632b1668 | |||
| d010a3aff1 | |||
| 1ec442cc05 | |||
| 552718c09a | |||
| ce3ac3e79a | |||
| 56c279b51b | |||
| 77a9ef27a9 | |||
| 45bd321bb0 | |||
| 9a0729adef | |||
| cc17311d03 | |||
| 83de7df089 | |||
| aabfad3541 | |||
| 557f6a1abc | |||
| f95feafb1a | |||
| e49dacf057 | |||
| 5c4a0e011d | |||
| aa491aa5fd | |||
| 8b636bef25 | |||
| 9f61322604 | |||
| b88a8edd35 | |||
| 7c26bf7b7c | |||
| bf6c5d079a | |||
| d018d87e25 | |||
| f54ff267a7 | |||
| 6f033d52b5 | |||
| 92727ff22a | |||
| 1a3a8ba20e | |||
| b144758b82 | |||
| 92c6953724 | |||
| 9bf6e3f4f2 | |||
| a077adea0f | |||
| b6a826dc46 | |||
| 230b8ebaad | |||
| 8a4170a1e6 | |||
| 51e4860fe3 | |||
| 8c2220ae45 | |||
| ee39a5d7dc | |||
| 2b2ee4d23c | |||
| f5b898a69a | |||
| fc8a1d4201 | |||
| bce5a9f82c | |||
| bc40e22910 | |||
| 2eda211a29 | |||
| 8c5e284b6c | |||
| 72c7b0454a | |||
| 11aa9689c1 | |||
| 1d91a3f21c | |||
| 475ee3fa16 | |||
| c8cf6ae10d | |||
| 2f8c184dc6 | |||
| e7d5dd8b1e | |||
| 79d3f836a2 | |||
| 113d25eef9 | |||
| 785c1e6eba | |||
| 9024112d88 | |||
| 7ce8d02afe | |||
| 11c8d0b7b6 | |||
| 6b8ba44d46 | |||
| ea7f4a4b3b | |||
| 6262a8ab01 | |||
| 7baf8f9e24 | |||
| a036c89291 | |||
| 32f4068272 | |||
| 275d5abd95 | |||
| 0f50ab334c | |||
| 54725f7c5f | |||
| 7c9bfc3897 | |||
| 014f1edcdc | |||
| cca555c0f4 | |||
| c6fe65ad99 | |||
| 73b4b06eae | |||
| 3704acab41 | |||
| 53c452583d | |||
| 80843cc08b | |||
| c6051442df | |||
| 1fe1a260e4 | |||
| 5d588c55b4 | |||
| 44f98a3d64 | |||
| 8ddde89143 | |||
| 84e21589c3 | |||
| 9502cf81b9 | |||
| 18f5156fd8 | |||
| f0b2d21989 | |||
| 2e537bc78d | |||
| 4adb844f8d | |||
| beea592706 | |||
| 6448c8736a | |||
| 0e995884a7 | |||
| 8c843c0ac1 | |||
| e3352d7e32 | |||
| 1467750db6 | |||
| c83bbfbc35 | |||
| c852752006 | |||
| a3f843ad15 | |||
| bffc7d39a2 | |||
| 70abe20250 | |||
| 22fef4fe49 | |||
| fb1bedcacd | |||
| 27d383b559 | |||
| 4524005f0e | |||
| 14b47fd049 | |||
| 47789e94db | |||
| a6e1734799 | |||
| 0a467aed59 | |||
| 14094ab1d4 | |||
| 96fddf4cd9 | |||
| 3fc98b8480 | |||
| 5f219c23f9 | |||
| 908f46c962 | |||
| d389790c57 | |||
| 3aa8720daf | |||
| 077986affb | |||
| 5d61dce12b | |||
| 17679cc81e | |||
| 19440b9bcf | |||
| 56fc8d970d | |||
| 61afa0dcae | |||
| 724530b073 | |||
| 5c43a49ba9 | |||
| e0299713f5 | |||
| a9c8344472 | |||
| cbb423f8ca | |||
| dfb6eb5e23 | |||
| 5e1d88d13e | |||
| c3a6c84d8a | |||
| 932cf60c5c | |||
| cc935420ec | |||
| 94cba756c8 | |||
| b835e8622f | |||
| 7286c76787 | |||
| e175d2b4c3 | |||
| 1306dce6e4 | |||
| 8fa4a20e63 | |||
| 1d875a65c0 | |||
| 0436e87147 | |||
| 7feda76b3c | |||
| c4fb6586e7 | |||
| 3dccc229af | |||
| 6c43171b11 | |||
| 8d4ee75cfb | |||
| 8a910f7ff8 | |||
| a7fa0ec027 | |||
| 0ac2e45077 | |||
| fd98f68ba6 | |||
| bccfc28012 | |||
| 55df2e104c | |||
| 1706da8598 | |||
| 1dbc0c31ff | |||
| 06a00d67e4 | |||
| 952b902702 | |||
| f959ebb998 | |||
| bae23775f2 | |||
| 0e12ab1f1e | |||
| cb4c9d77f2 | |||
| ff6118a778 | |||
| 3328f30a06 | |||
| df4bd700e6 | |||
| bab2c916be | |||
| 4e22942031 | |||
| 740b791e5e | |||
| bb3f94d39e | |||
| d48a00973d | |||
| 7b4b54a9ac | |||
| 797d9d42fe | |||
| b1b1aa2037 | |||
| 4f95a347dc | |||
| 268f2e841d | |||
| 8d4f167374 | |||
| 6a6d50abdb | |||
| bca29ab7a3 | |||
| 3da8b75395 | |||
| f20a02dfa2 | |||
| 55de9b3e29 | |||
| a3fb864f7d | |||
| 9f92f7324a | |||
| 9bfd0affd0 | |||
| f0aec851d3 | |||
| 36bfa3bd84 | |||
| ba830947d2 | |||
| 89dd11bf77 | |||
| 14b1d5685c | |||
| 19f6559c29 | |||
| 0c884a73ec | |||
| f035ccaacc | |||
| c60e936b7e | |||
| 789a818c6b | |||
| 6a4539bf9b | |||
| 0c849c9525 | |||
| 47d03dd61b | |||
| 50db2fcb4b | |||
| 0c9050cc8a | |||
| 80b82e0117 | |||
| 4d2e78dd2a | |||
| ba4d7b443f | |||
| 6802636d1d | |||
| e48107470a | |||
| 2d88fb86a1 | |||
| 1212b9d115 | |||
| 97833ab2bb | |||
| 03a29cf93c | |||
| bd884357b0 | |||
| b5ad13cf43 | |||
| a3912a47c4 | |||
| 0c3f7ddf2e | |||
| b70aefd5cc | |||
| 21c4e9f007 | |||
| 039b8ba28d | |||
| 05a3b009d3 | |||
| 66ac184a6f | |||
| 21e150f177 | |||
| 2df4dc7ea7 | |||
| d72102bdf0 | |||
| ddf842f910 | |||
| 02c3fef9b2 | |||
| 5e198a30c6 | |||
| 44029cdaad | |||
| b2bffd13a3 | |||
| a50524832e | |||
| dc6b61324f | |||
| 43944d8cc8 | |||
| 7a9fed6f95 | |||
| b3c44e3082 | |||
| 39782eeab9 | |||
| c5500bcc97 | |||
| a935357e70 | |||
| 07c70c86f5 | |||
| d7e7928dfc | |||
| e61a62e579 | |||
| a801d41d79 | |||
| 51fee198f4 | |||
| 91f6fef6ea | |||
| d249614027 | |||
| e50d43ea44 | |||
| f144ad25f2 | |||
| 179b59d73d | |||
| 04cc69f98b | |||
| 6102fef6d1 | |||
| 5232b14cdf | |||
| fa02d95dff | |||
| ec75a480bd | |||
| f0df359db4 | |||
| adca6e0f64 | |||
| df05ea5358 | |||
| 7f37f111e8 | |||
| 516945fc8c | |||
| 06abf1a6ae | |||
| 028a1fc4ec | |||
| 37b710c933 | |||
| 4f2f137b27 | |||
| 26ae4a15f4 | |||
| 2d6cff9f40 |
@@ -0,0 +1,5 @@
|
||||
# npm 12+ disables fetching git-protocol dependencies by default
|
||||
# (allow-git=none). @einfach-produktiv/invoicing is declared directly in
|
||||
# this file's own package.json (not a transitive dependency), so "root" is
|
||||
# the narrowest setting that still allows it.
|
||||
allow-git=root
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# git — needed for `npm ci` to fetch @einfach-produktiv/invoicing, a git-URL
|
||||
# dependency (see package.json); Alpine's base image doesn't ship it.
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Klarheits-Check starten" }: { buttonLabel?: string }) {
|
||||
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">{successMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
|
||||
{/* Stacked full-width below sm: — side by side, the button's own
|
||||
content width plus the input's min-w-0 squeeze left it cramped
|
||||
on a narrow phone. Default align-items: stretch in flex-col
|
||||
mode is what makes both the input and the button (shrink-0,
|
||||
fixed to its label's width) fill the row once stacked, no
|
||||
explicit w-full needed on either. */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-white border rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-[#d9d9d9] focus:border-[#f6a701]"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2 disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && <p className="text-[0.8rem] text-red-600">{emailError}</p>}
|
||||
{/* Consent checkbox — this signup's legal basis is consent (email
|
||||
marketing), same wording as the other newsletter forms; colors
|
||||
match this page's own hardcoded palette instead of the shared
|
||||
design tokens, consistent with the rest of the page. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
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">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#f6a701]"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && <p className="text-[0.8rem] text-red-600">{error}</p>}
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { StepArrow } from "../components/StepArrow";
|
||||
import { STEP_ICONS } from "../components/icons/StepIcons";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { EmailCapture } from "./components/EmailCapture";
|
||||
|
||||
const title = "7-Tage-Challenge – Mehr Klarheit in 7 Tagen";
|
||||
const description =
|
||||
@@ -12,12 +19,12 @@ export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/challenge",
|
||||
canonical: "/7-tage-klarheits-check",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/challenge",
|
||||
url: "/7-tage-klarheits-check",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
twitter: {
|
||||
@@ -27,84 +34,32 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
function IconEnvelope() {
|
||||
return (
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M2 2l24 22L50 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMailLines() {
|
||||
return (
|
||||
<svg width="56" height="44" viewBox="0 0 56 44" fill="none">
|
||||
<line x1="2" y1="12" x2="12" y2="12" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="20" x2="9" y2="20" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="2" y1="28" x2="7" y2="28" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="13" y="2" width="41" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13 2l20.5 19L54 2" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconNotepad() {
|
||||
return (
|
||||
<svg width="46" height="52" viewBox="0 0 46 52" fill="none">
|
||||
<rect x="2" y="4" width="32" height="42" rx="2" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="9" y1="16" x2="27" y2="16" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="23" x2="24" y2="23" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="30" x2="20" y2="30" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="37" cy="40" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
<line x1="37" y1="27" x2="37" y2="33" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCheckCircle() {
|
||||
return (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M14 24l7.5 7.5L34 16" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-0.5">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: <IconEnvelope />,
|
||||
icon: STEP_ICONS.envelope(),
|
||||
title: "1. Anmelden",
|
||||
desc: "Trage dich mit deiner E-Mail-Adresse ein und starte sofort.",
|
||||
},
|
||||
{
|
||||
icon: <IconMailLines />,
|
||||
icon: STEP_ICONS.mailLines(),
|
||||
title: "2. E-Mail erhalten",
|
||||
desc: "Du bekommst 7 Tage lang jeweils einen Impuls direkt in dein Postfach.",
|
||||
},
|
||||
{
|
||||
icon: <IconNotepad />,
|
||||
icon: STEP_ICONS.notepad(),
|
||||
title: "3. Umsetzen",
|
||||
desc: "Setze die einfache Aufgabe in wenigen Minuten um – für mehr Klarheit und Fokus.",
|
||||
desc: "Gehe die einfachen Gedankenanstöße in wenigen Minuten durch – für mehr Klarheit und Fokus",
|
||||
},
|
||||
{
|
||||
icon: <IconCheckCircle />,
|
||||
icon: STEP_ICONS.checkCircle(),
|
||||
title: "4. Dranbleiben",
|
||||
desc: "Kleine Schritte führen zu großen Veränderungen – Tag für Tag.",
|
||||
},
|
||||
@@ -118,74 +73,10 @@ const benefits = [
|
||||
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
avatar: "/avatar-1.jpg",
|
||||
quote: "„Die 7-Tage-Challenge hat mir geholfen, wieder klar zu sehen und mit kleinen Schritten wirklich etwas zu verändern.“",
|
||||
name: "Sarah M.",
|
||||
role: "Marketing Managerin",
|
||||
},
|
||||
{
|
||||
avatar: "/avatar-2.jpg",
|
||||
quote: "„Kurz, konkret und unglaublich wirkungsvoll. Ich habe direkt mehr Fokus und weniger Druck im Kopf.“",
|
||||
name: "Thomas K.",
|
||||
role: "Selbständiger Berater",
|
||||
},
|
||||
{
|
||||
avatar: "/avatar-3.jpg",
|
||||
quote: "„Endlich eine Challenge, die nicht überfordert, sondern genau die richtigen Impulse gibt – jeden Tag.“",
|
||||
name: "Miriam L.",
|
||||
role: "Projektleiterin",
|
||||
},
|
||||
];
|
||||
export default async function ChallengePage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("klarheits-check", { draft: isPreview });
|
||||
|
||||
function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-3 w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none focus:border-[#f6a701] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Consent checkbox — this signup's legal basis is consent (email
|
||||
marketing), same wording as the other newsletter forms; colors
|
||||
match this page's own hardcoded palette instead of the shared
|
||||
design tokens, consistent with the rest of the page. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#f6a701]"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengePage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-[#f5f0e8]">
|
||||
@@ -219,7 +110,7 @@ export default function ChallengePage() {
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">7-Tage-Challenge</span>
|
||||
<span className="text-text-primary">7-Tage-Klarheits-Check</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space,
|
||||
@@ -294,37 +185,39 @@ export default function ChallengePage() {
|
||||
className="font-semibold text-[#222221]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
>
|
||||
So funktioniert die Challenge
|
||||
So funktioniert der Check
|
||||
</h2>
|
||||
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{/* items-center below lg: (was items-start) — the step blocks
|
||||
are centered columns now (see RevealItem below), so the
|
||||
connector arrows between them need to be centered too,
|
||||
not flush against the left edge. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{steps.flatMap((step, i) => [
|
||||
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
// Icon-above-text, centered, at every breakpoint now
|
||||
// (previously a left-aligned icon+text row below lg: —
|
||||
// fixed 2026-07-24 to match the lg: layout instead of
|
||||
// diverging from it).
|
||||
<RevealItem key={step.title} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{step.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 lg:text-center">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
|
||||
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
|
||||
</div>
|
||||
</RevealItem>,
|
||||
i < steps.length - 1 ? (
|
||||
// Same /icon-arrow-connector.svg asset and rotate-on-stack
|
||||
// pattern as todo-cards/newsletter's HowItWorks — this
|
||||
// used to be its own hand-drawn SVG arrow, inconsistent
|
||||
// with those two. Always visible (rotated 90° while
|
||||
// stacked below lg, this page's own structural
|
||||
// breakpoint) rather than hidden below lg like before.
|
||||
// Shared StepArrow component (see its own file) — same
|
||||
// rotate-on-stack pattern as todo-cards/newsletter's
|
||||
// HowItWorks. Always visible (rotated 90° while stacked
|
||||
// below lg, this page's own structural breakpoint) rather
|
||||
// than hidden below lg like before. Bigger below lg:
|
||||
// (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div key={`arrow-${i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-arrow-connector.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rotate-90 lg:w-10 lg:h-3 lg:rotate-0"
|
||||
/>
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
@@ -383,68 +276,22 @@ export default function ChallengePage() {
|
||||
</section>
|
||||
|
||||
{/* ── Was andere sagen ── */}
|
||||
{/* max-w-[1600px], not this page's other sections' 1280px — a
|
||||
deliberate compromise with /todo-cards's and /newsletter's
|
||||
unbounded fluid width, so all three testimonial sections cap
|
||||
at the same width instead of Challenge's reading narrower on
|
||||
wide viewports. Only this one section's cap changed, not the
|
||||
rest of the page. */}
|
||||
<section className="bg-white w-full py-14 lg:py-20">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
|
||||
|
||||
<Reveal
|
||||
className="font-semibold text-[#222221] text-center"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
{/* Same style + micro-interactions as /todo-cards's and
|
||||
/newsletter's identically-styled testimonial cards (kept in
|
||||
sync deliberately): decorative quote-mark, hover-lift on
|
||||
the card, avatar scale on the same hover via `group`. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row gap-6">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.name}
|
||||
className="group relative flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden">
|
||||
<Image
|
||||
alt={t.name}
|
||||
src={t.avatar}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
|
||||
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{isPreview ? (
|
||||
<LiveTestimonialsGrid testimonials={testimonials} />
|
||||
) : (
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
)}
|
||||
|
||||
{/* ── Bottom CTA ── */}
|
||||
<section className="w-full py-14 lg:py-16">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
|
||||
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
|
||||
|
||||
{/* Left: icon + copy */}
|
||||
<div className="flex gap-5 items-start flex-1 min-w-0">
|
||||
{/* Left: icon + copy — icon above text, centered, below lg:
|
||||
(matches the Home Newsletter card's icon-above-text
|
||||
pattern), row layout again from lg: up alongside the
|
||||
outer Reveal's own flex-col -> lg:flex-row switch. */}
|
||||
<div className="flex flex-col items-center text-center gap-5 lg:flex-row lg:items-start lg:text-left flex-1 min-w-0">
|
||||
<div className="shrink-0 -rotate-4">
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
@@ -456,7 +303,7 @@ export default function ChallengePage() {
|
||||
className="font-semibold text-[#222221] leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.125rem, 2vw, 1.375rem)" }}
|
||||
>
|
||||
Starte jetzt deine 7-Tage-Challenge
|
||||
Starte jetzt deine 7 Tage Klarheit
|
||||
</p>
|
||||
<p className="text-[0.9rem] text-[#555] leading-[1.5]">
|
||||
Trage dich ein und erhalte ab sofort täglich einen Impuls für mehr Klarheit und Fokus.
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { PageBlocks } from "../components/PageBlocks";
|
||||
import { LivePageContent } from "../components/LivePageContent";
|
||||
import { getPageBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildWebPageSchema } from "../lib/structuredData";
|
||||
|
||||
// First generic catch-all content route in this repo — every other page
|
||||
// (blog posts excepted, which have their own [slug]) is its own static
|
||||
// directory under app/. Powers Payload's new Pages collection (the page
|
||||
// builder): a doc's `layout` blocks field renders via PageBlocks.tsx,
|
||||
// structurally parallel to how RichText.tsx renders Posts.content's own
|
||||
// (differently-shaped) Blocks. Next.js resolves any matching static route
|
||||
// (e.g. app/lebensuhr/page.tsx) before ever reaching this catch-all, so a
|
||||
// Pages document only actually serves a URL once the static file for that
|
||||
// slug, if any, is removed.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = await getPageBySlug(slug);
|
||||
if (!page) return { title: "Seite nicht gefunden" };
|
||||
|
||||
const title = page.seoTitle || page.title;
|
||||
const description = page.seoDescription ?? undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/${page.slug}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `/${page.slug}`,
|
||||
type: "website",
|
||||
images: page.seoImage ? [{ url: page.seoImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: page.seoImage ? [page.seoImage] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DynamicPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getPageBySlug(slug, { draft: isPreview });
|
||||
if (!page) notFound();
|
||||
|
||||
const seller = await getCompanySettings();
|
||||
const pageSchema = buildWebPageSchema(page, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(pageSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePageContent initialPage={page} />
|
||||
) : (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{page.title}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
|
||||
<PageBlocks blocks={page.layout} />
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { headingId } from "../../components/RichText";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
import type { TOCSection } from "../../components/SectionTOC";
|
||||
|
||||
// Renders "2. Vertragspartner" straight from company-settings, same
|
||||
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx and
|
||||
// Datenschutz's VerantwortlicherBlock.tsx — this used to be hand-typed
|
||||
// name/address/email baked into the AGB richText (seed-agb.ts on the
|
||||
// Payload side), and had already drifted from the real company-settings
|
||||
// values once (the richText's placeholder "Björn Wendt"/"Musterstraße
|
||||
// 12" never got updated when the real address was set). Sits mid-document
|
||||
// (section 1 "Geltungsbereich" comes before it), which is why AGB's
|
||||
// content is split into `content` (section 1) + `contentPart2` (sections
|
||||
// 3 onward) rather than just prepending this block like Datenschutz did —
|
||||
// see LegalPages.ts's `contentPart2` field comment.
|
||||
export function vertragspartnerHeadings(): TOCSection[] {
|
||||
return [{ id: headingId("2. Vertragspartner"), title: "2. Vertragspartner" }];
|
||||
}
|
||||
|
||||
function Heading({ children }: { children: string }) {
|
||||
return (
|
||||
<h2
|
||||
id={headingId(children)}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{children}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function P({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-body text-text-body">{children}</p>;
|
||||
}
|
||||
|
||||
export function VertragspartnerBlock({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>2. Vertragspartner</Heading>
|
||||
<P>Der Kaufvertrag kommt zustande mit:</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>
|
||||
<strong>einfach produktiv. – {seller.sellerName}</strong>
|
||||
</P>
|
||||
<P>
|
||||
<strong>
|
||||
{seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity}
|
||||
</strong>
|
||||
</P>
|
||||
<P>
|
||||
<strong>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="hover:underline">{seller.sellerEmail}</a>
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+36
-7
@@ -1,12 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { formatMonthYear } from "../lib/format";
|
||||
import { VertragspartnerBlock, vertragspartnerHeadings } from "./components/VertragspartnerBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AGB",
|
||||
@@ -15,8 +19,17 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function AgbPage() {
|
||||
const page = await getLegalPage("agb");
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const [page, seller] = await Promise.all([getLegalPage("agb", { draft: isPreview }), getCompanySettings()]);
|
||||
// Section 1 ("Geltungsbereich") comes first from `content`, THEN
|
||||
// "2. Vertragspartner" (dynamic, sits between two CMS-driven halves —
|
||||
// see LegalPages.ts's `contentPart2` comment), then the rest from
|
||||
// `contentPart2`.
|
||||
const headings = [
|
||||
...(page ? extractHeadings(page.content) : []),
|
||||
...vertragspartnerHeadings(),
|
||||
...(page?.contentPart2 ? extractHeadings(page.contentPart2) : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,9 +46,16 @@ export default async function AgbPage() {
|
||||
>
|
||||
Allgemeine Geschäftsbedingungen
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
@@ -58,9 +78,18 @@ export default async function AgbPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
|
||||
{page ? (
|
||||
<RichText content={page.content} />
|
||||
<>
|
||||
{isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />}
|
||||
{/* Name/Adresse/E-Mail kommen direkt aus company-settings,
|
||||
nicht aus der CMS-Richtext — single-sourced, gleiche
|
||||
Begründung wie Impressum/Datenschutz. */}
|
||||
{seller && <VertragspartnerBlock seller={seller} />}
|
||||
{page.contentPart2 ? (
|
||||
isPreview ? <LiveRichText initialContent={page.contentPart2} /> : <RichText content={page.contentPart2} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../../lib/cart";
|
||||
import { getServerCart, getSessionCustomer, saveServerCart } from "../../../lib/customerAuth";
|
||||
import { fetchProductsBySlug } from "../../../lib/productsServer";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ cart: [] }, { status: 401 });
|
||||
const cart = await getServerCart(session.token);
|
||||
return NextResponse.json({ cart });
|
||||
}
|
||||
|
||||
// Called by CartSync.tsx (debounced) on every local cart change while a
|
||||
// session is active — keeps the server-side mirror current so the cart
|
||||
// follows the customer across devices. Silently no-ops when logged out;
|
||||
// the caller doesn't care either way.
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
|
||||
|
||||
const productsBySlug = await fetchProductsBySlug();
|
||||
const lines: { productId: number; productSlug: string; quantity: number; variant?: string }[] = [];
|
||||
for (const item of cart) {
|
||||
const product = productsBySlug.get(item.id);
|
||||
if (product) lines.push({ productId: product.id, productSlug: product.slug, quantity: item.qty, variant: item.variant });
|
||||
}
|
||||
|
||||
const ok = await saveServerCart(session.token, session.customer.id, lines);
|
||||
return NextResponse.json({ ok });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkEmailExists } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
// Called on blur from the checkout email field (CheckoutContent.tsx) —
|
||||
// lets the form switch to login mode as soon as an existing account is
|
||||
// detected, instead of only after a failed registration attempt. Same
|
||||
// exposure as the registration-collision case already had (both reveal
|
||||
// "this email has an account"), so rate-limited the same way rather than
|
||||
// treated as a new problem.
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`check-email:${getClientIp(request)}`, { limit: 20, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ exists: false }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
if (!email) return NextResponse.json({ exists: false });
|
||||
|
||||
const exists = await checkEmailExists(email);
|
||||
return NextResponse.json({ exists });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteCustomerAccount, getSessionCustomer, loginCustomer, clearSessionCookie } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR self-service deletion. Password re-verified here (not just trusting
|
||||
// the active session) before anything is deleted — same reasoning as
|
||||
// changeCustomerPassword. See customerAuth.ts's deleteCustomerAccount for
|
||||
// what actually survives (past orders, anonymized-by-omission — their own
|
||||
// snapshot fields aren't touched, only the account/login disappears).
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!password) return NextResponse.json({ ok: false, reason: "Bitte dein Passwort zur Bestätigung eingeben." }, { status: 400 });
|
||||
|
||||
const verify = await loginCustomer({ email: session.customer.email, password });
|
||||
if (!verify.ok) return NextResponse.json({ ok: false, reason: "Passwort ist falsch." }, { status: 400 });
|
||||
|
||||
const deleted = await deleteCustomerAccount(verify.token, session.customer.id);
|
||||
if (!deleted) return NextResponse.json({ ok: false, reason: "Konto konnte nicht gelöscht werden." }, { status: 500 });
|
||||
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerProfile, getCustomerOrders, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR data portability (Art. 20) — a full, structured, machine-readable
|
||||
// export of everything tied to the account: profile + every order's full
|
||||
// detail (not just the summary list, so this is a genuinely complete
|
||||
// export, not a teaser).
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
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, false);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile,
|
||||
orders: orders.filter(Boolean),
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": 'attachment; filename="meine-daten.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requestPasswordReset } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
// Always responds the same way regardless of whether the email exists —
|
||||
// see requestPasswordReset()'s own comment. Rate-limited a bit tighter
|
||||
// than login/register (5/15min) since there's no secondary defense here
|
||||
// the way Payload's own per-account lockout backs up the login route.
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`forgot-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
if (email) await requestPasswordReset(email);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Complements Payload's own per-account lockout (5 attempts / 10min,
|
||||
// see Customers.ts in the Payload repo) with a per-IP layer — that one
|
||||
// alone doesn't stop someone spraying single attempts across many
|
||||
// different email addresses from the same IP.
|
||||
if (!checkRateLimit(`login:${getClientIp(request)}`, { limit: 10, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte E-Mail und Passwort angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await loginCustomer({ email, password });
|
||||
if (!result.ok) return NextResponse.json(result, { status: 401 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSessionCookie } from "../../../lib/customerAuth";
|
||||
|
||||
export async function POST() {
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ customer: null }, { status: 401 });
|
||||
return NextResponse.json({ customer: session.customer });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateCorrectionInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
import { getProductImagesByIds } from "../../../../../lib/payload";
|
||||
|
||||
// On-demand download for "Stornorechnung/Gutschrift herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber]. The real document was generated once
|
||||
// by Payload's Orders.ts afterChange hook and emailed at the moment of the
|
||||
// status change — this regenerates the identical PDF from the order's own
|
||||
// stored correctionInvoiceNumber/correctionInvoiceIssuedAt (immutable once
|
||||
// set) rather than storing the file anywhere, same approach as the
|
||||
// original invoice's own download route.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (!order.correctionInvoiceNumber || !order.correctionInvoiceIssuedAt || !order.invoiceNumber || !order.invoiceIssuedAt) {
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt keine Korrekturrechnung vor." }, { status: 404 });
|
||||
}
|
||||
const kind = order.status === "returned" ? "gutschrift" : "storno";
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const pdf = await generateCorrectionInvoicePdf(
|
||||
kind,
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
correctionInvoiceNumber: order.correctionInvoiceNumber,
|
||||
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Korrekturrechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
const filename = kind === "storno" ? `Stornorechnung-${order.correctionInvoiceNumber}.pdf` : `Gutschrift-${order.correctionInvoiceNumber}.pdf`;
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
import { getProductImagesByIds } from "../../../../../lib/payload";
|
||||
|
||||
// On-demand download for "Rechnung herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
|
||||
// the checkout-time attachment (app/lib/orderEmail.ts), so a re-download
|
||||
// always matches what was emailed; invoiceNumber itself never changes
|
||||
// (assigned once, server-side, at order creation — see Orders.ts).
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (!order.invoiceNumber || !order.invoiceIssuedAt) {
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt noch keine Rechnung vor." }, { status: 404 });
|
||||
}
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
// On-demand re-download has no imageUrl snapshot to fall back to like
|
||||
// the checkout-time attachment does (order.items only stores a numeric
|
||||
// product id, see CustomerOrderItem) — resolved fresh here instead.
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const pdf = await generateInvoicePdf(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
hasDifferentShippingAddress: order.hasDifferentShippingAddress,
|
||||
shippingFirstName: order.shippingFirstName,
|
||||
shippingLastName: order.shippingLastName,
|
||||
shippingDeliveryMethod: order.shippingDeliveryMethod,
|
||||
shippingStreet: order.shippingStreet,
|
||||
shippingPackstationNumber: order.shippingPackstationNumber,
|
||||
shippingPostNumber: order.shippingPostNumber,
|
||||
shippingZip: order.shippingZip,
|
||||
shippingCity: order.shippingCity,
|
||||
shippingCountry: order.shippingCountry,
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
discountCode: order.discountCode,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Rechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="Rechnung-${order.invoiceNumber}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrderDetail,
|
||||
requestOrderStatusChange,
|
||||
customerOrderAction,
|
||||
type CustomerOrderItem,
|
||||
} from "../../../../lib/customerAuth";
|
||||
|
||||
// The real security boundary is Orders.ts's beforeChange hook in Payload
|
||||
// (only `status`/`returnReason`/items' `returnQuantity` can change, only
|
||||
// via an allowed transition) — the checks here are just for a friendlier
|
||||
// error message than a bare 403 when the request is malformed or stale
|
||||
// (e.g. two tabs open, order shipped in the meantime, a quantity that no
|
||||
// longer fits).
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const action = body?.action;
|
||||
if (action !== "cancel" && action !== "request-return") {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
|
||||
}
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (customerOrderAction(order.status) !== action) {
|
||||
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === "cancel") {
|
||||
const result = await requestOrderStatusChange(session.token, order.id, "cancel");
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
// request-return: partial returns supported — the client sends
|
||||
// { product, returnQuantity } per line it wants to return (0 or
|
||||
// omitted for lines being kept). Reconstruct the order's FULL items
|
||||
// array here (see requestOrderStatusChange's own comment on why a
|
||||
// sparse patch doesn't work), validating each requested quantity
|
||||
// against what was actually ordered.
|
||||
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
|
||||
if (!returnReason) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
|
||||
}
|
||||
const requestedQuantities = new Map<number, number>();
|
||||
if (Array.isArray(body?.returnItems)) {
|
||||
for (const line of body.returnItems) {
|
||||
const product = Number(line?.product);
|
||||
const returnQuantity = Number(line?.returnQuantity);
|
||||
if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) {
|
||||
requestedQuantities.set(product, returnQuantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requestedQuantities.size === 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const items: CustomerOrderItem[] = order.items.map((item) => {
|
||||
const requested = requestedQuantities.get(item.product) ?? 0;
|
||||
if (requested > item.quantity) {
|
||||
throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 });
|
||||
}
|
||||
return { ...item, returnQuantity: requested };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items });
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Lets a logged-in customer move an existing, still-unpaid Überweisung
|
||||
// order onto a Stripe PaymentIntent instead of waiting on their own bank
|
||||
// transfer — see the backend's switchPaymentToStripe.ts for the matching
|
||||
// endpoint and why this needs a dedicated backend route rather than the
|
||||
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
|
||||
// are system fields a customer JWT can never touch).
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Same eligibility the backend endpoint re-checks authoritatively —
|
||||
// checked here too for a friendly error instead of a bare 409. An
|
||||
// explicit allowlist ('pending'/'not_applicable'), not a
|
||||
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
|
||||
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
|
||||
// meaningful states to switch from either.
|
||||
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
|
||||
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only offer this when a real Stripe payment method is actually active
|
||||
// — same "Online-Zahlung" grouping the checkout itself uses, so this
|
||||
// never presents an option that checkout wouldn't currently accept either.
|
||||
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
|
||||
const hasStripeOption = methods.some((m) => m.provider === "stripe");
|
||||
if (!hasStripeOption) {
|
||||
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
|
||||
}
|
||||
|
||||
let intent: { clientSecret: string; providerReference: string };
|
||||
try {
|
||||
intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(order.total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: order.customerEmail,
|
||||
description: `Bestellung ${order.orderNumber}`,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
|
||||
body: JSON.stringify({ providerReference: intent.providerReference }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Best-effort, same as checkout's own call — a failure here doesn't
|
||||
// block the payment itself, only the confirm-payment webhook's metadata
|
||||
// lookup, which the frontend's own webhook route already alerts on.
|
||||
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
clientSecret: intent.clientSecret,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
testMode: isPaymentTestMode,
|
||||
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCustomerOrders, getSessionCustomer } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ orders: [] }, { status: 401 });
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
return NextResponse.json({ orders });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
||||
const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte aktuelles und ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await changeCustomerPassword(session.customer.email, currentPassword, newPassword);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ profile: null }, { status: 401 });
|
||||
return NextResponse.json({ profile: session.customer });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName,
|
||||
vatId,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName,
|
||||
shippingLastName,
|
||||
shippingCompanyName,
|
||||
shippingDeliveryMethod,
|
||||
shippingStreet,
|
||||
shippingPackstationNumber,
|
||||
shippingPostNumber,
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
} = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
typeof lastName !== "string" ||
|
||||
!lastName ||
|
||||
typeof street !== "string" ||
|
||||
!street ||
|
||||
typeof zip !== "string" ||
|
||||
!zip ||
|
||||
typeof city !== "string" ||
|
||||
!city ||
|
||||
typeof country !== "string" ||
|
||||
!country
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
// Both independently optional (see Customers.ts's own comment) — only
|
||||
// format-checked when actually provided, same as the backend field itself.
|
||||
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Same shape as the checkout's own shipping-address-override validation
|
||||
// (api/checkout/route.ts) — required fields only apply when the toggle
|
||||
// is actually on, since this whole block is optional otherwise.
|
||||
if (hasDifferentShippingAddress) {
|
||||
if (
|
||||
typeof shippingFirstName !== "string" ||
|
||||
!shippingFirstName ||
|
||||
typeof shippingLastName !== "string" ||
|
||||
!shippingLastName ||
|
||||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
|
||||
typeof shippingZip !== "string" ||
|
||||
!shippingZip ||
|
||||
typeof shippingCity !== "string" ||
|
||||
!shippingCity ||
|
||||
typeof shippingCountry !== "string" ||
|
||||
!shippingCountry
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "address" && !shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
deliveryMethod: "address",
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
|
||||
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`register:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, email, password } = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
typeof lastName !== "string" ||
|
||||
typeof email !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
!firstName ||
|
||||
!lastName ||
|
||||
!email ||
|
||||
!password
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Felder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await registerCustomer({ firstName, lastName, email, password });
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, resendVerificationEmail } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`resend-verification:${getClientIp(request)}`, { limit: 3, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
if (session.customer.emailVerified) return NextResponse.json({ ok: true });
|
||||
|
||||
const ok = await resendVerificationEmail(session);
|
||||
return NextResponse.json({ ok }, { status: ok ? 200 : 500 });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resetPassword, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`reset-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const token = typeof body?.token === "string" ? body.token : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!token || !password || password.length < 8) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await resetPassword(token, password);
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyEmailByToken } from "../../../lib/customerAuth";
|
||||
|
||||
// Entered from the link in the verification email — no session exists
|
||||
// yet at this point. See Customers.ts's own comment on why this is a
|
||||
// non-blocking flag (login already works before this is ever clicked).
|
||||
//
|
||||
// Base URL is deliberately NOT built from request.url — behind Caddy's
|
||||
// reverse proxy that reflects the container's internal address
|
||||
// (0.0.0.0:3000, confirmed live), not the public domain, and would send a
|
||||
// real browser to an unreachable address. Same hardcoded-origin approach
|
||||
// as Customers.ts's own FRONTEND_URL default on the Payload side.
|
||||
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token) return new Response("Ungültiger Link.", { status: 400 });
|
||||
|
||||
const ok = await verifyEmailByToken(token);
|
||||
const url = new URL("/konto/profil", SITE_URL);
|
||||
url.searchParams.set("verified", ok ? "1" : "0");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
|
||||
const items = await getWishlist(session.token, session.customer.id);
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const productId = Number(body?.productId);
|
||||
const variant = typeof body?.variant === "string" ? body.variant : "";
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, session.customer.id, productId, variant);
|
||||
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
|
||||
|
||||
// Proxies the checkout's address-autocomplete input through to Payload's
|
||||
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
|
||||
// own route: tenant DHL credentials must never reach the browser.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("query") ?? "";
|
||||
const suggestions = await autocompleteDhlAddress(query);
|
||||
return NextResponse.json({ ok: true, suggestions });
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
// into results like 84.30000000000001 — cosmetically invisible wherever
|
||||
// formatPrice()'s toFixed(2) already rounds for display, but stored as-is
|
||||
// on the order otherwise, which is where it actually showed up (Payload's
|
||||
// admin list/edit view for a plain number field has no such formatting).
|
||||
// Rounded once here, right before persisting, rather than chasing it down
|
||||
// at every downstream display site.
|
||||
function roundMoney(amount: number): number {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
|
||||
type CheckoutBody = {
|
||||
cart: CartItem[];
|
||||
shippingMethodId: number;
|
||||
paymentMethodId: number;
|
||||
discountCode: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string;
|
||||
packstationNumber?: string;
|
||||
postNumber?: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
hasDifferentShippingAddress?: boolean;
|
||||
shippingFirstName?: string;
|
||||
shippingLastName?: string;
|
||||
shippingDeliveryMethod?: "address" | "packstation";
|
||||
shippingStreet?: string;
|
||||
shippingPackstationNumber?: string;
|
||||
shippingPostNumber?: string;
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
shippingCompanyName?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
newsletterOptIn: boolean;
|
||||
};
|
||||
|
||||
function isValidBody(body: unknown): body is CheckoutBody {
|
||||
const b = body as Partial<CheckoutBody> | null;
|
||||
return Boolean(
|
||||
b &&
|
||||
Array.isArray(b.cart) &&
|
||||
b.cart.length > 0 &&
|
||||
typeof b.shippingMethodId === "number" &&
|
||||
typeof b.paymentMethodId === "number" &&
|
||||
typeof b.firstName === "string" &&
|
||||
b.firstName &&
|
||||
typeof b.lastName === "string" &&
|
||||
b.lastName &&
|
||||
typeof b.email === "string" &&
|
||||
b.email &&
|
||||
(b.deliveryMethod === "address" || b.deliveryMethod === "packstation") &&
|
||||
typeof b.zip === "string" &&
|
||||
b.zip &&
|
||||
typeof b.city === "string" &&
|
||||
b.city &&
|
||||
typeof b.country === "string" &&
|
||||
b.country,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!isValidBody(body)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (body.deliveryMethod === "address" && !body.street) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Optional — only format-checked when actually provided, same "never
|
||||
// trust the client" reasoning as every other checkout field re-validated
|
||||
// here. Normalized the same way Orders.ts's own field does (uppercase +
|
||||
// trim), so the snapshot on the order matches what would've been
|
||||
// accepted directly through the Payload admin.
|
||||
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
if (body.hasDifferentShippingAddress) {
|
||||
if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod === "address" && !body.shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod === "packstation" && (!body.shippingPackstationNumber || !body.shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod !== "address" && body.shippingDeliveryMethod !== "packstation") {
|
||||
return NextResponse.json({ ok: false, reason: "Lieferart der Lieferadresse ist ungültig." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Auth: an existing session wins; otherwise this checkout submit doubles
|
||||
// as inline registration ("Konto Pflicht, Registrierung direkt im
|
||||
// Checkout") — logging in with an *existing* account happens separately
|
||||
// beforehand via /api/account/login from the checkout page's own toggle.
|
||||
let customer: CustomerSummary;
|
||||
const session = await getSessionCustomer();
|
||||
if (session) {
|
||||
customer = session.customer;
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
|
||||
}
|
||||
const result = await registerCustomer({
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
});
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
await setSessionCookie(result.token);
|
||||
customer = result.customer;
|
||||
}
|
||||
|
||||
// Re-price everything server-side — never trust client-submitted prices.
|
||||
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
|
||||
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
|
||||
// §19 UStG — a Kleinunternehmer tenant never charges VAT on anything,
|
||||
// full stop, so every item's tax rate is forced to 0% here regardless of
|
||||
// its own catalog/company-settings default rate. Unlike the
|
||||
// intra-community exemption below, prices are NOT de-grossed — see
|
||||
// Orders.ts's own kleinunternehmer field comment and this shop's
|
||||
// Kleinunternehmer decision: catalog gross prices stay exactly what they
|
||||
// are, they simply never had a VAT component charged on top in the
|
||||
// first place.
|
||||
const kleinunternehmer = Boolean(companySettings?.kleinunternehmer);
|
||||
const items: {
|
||||
productId: number;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
imageUrl: string | null;
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
sku: string | null;
|
||||
}[] = [];
|
||||
for (const line of body.cart) {
|
||||
const product = productsBySlug.get(line.id);
|
||||
if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
|
||||
// Same "never trust the client" reasoning as unitPrice below — a
|
||||
// requested variant that no longer exists on this product (removed,
|
||||
// or never existed — a tampered request) fails the whole checkout
|
||||
// rather than silently falling back to the base product/price.
|
||||
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
|
||||
if (line.variant) {
|
||||
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
|
||||
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
|
||||
}
|
||||
// Same depth-in-defense reasoning as the price re-check above — the
|
||||
// storefront already disables "add to cart" for sold-out items, but a
|
||||
// tampered/stale request could still submit one, so stock is
|
||||
// re-validated here as the actual source of truth. Falls through
|
||||
// (buyable) whenever trackInventory is off or backorders are allowed.
|
||||
const stockSource = line.variant ? product.variants?.find((v) => v.name === line.variant) : product;
|
||||
if (stockSource?.trackInventory && !stockSource.allowBackorder && (stockSource.stock ?? 0) < line.qty) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: `"${product.name}"${line.variant ? ` (${line.variant})` : ""} ist nicht mehr in ausreichender Menge verfügbar.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
|
||||
items.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: line.qty,
|
||||
unitPrice: variant?.priceOverride ?? product.price,
|
||||
imageUrl,
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
// Variant sku takes precedence over the product's own — same
|
||||
// "variant overrides product" precedence unitPrice already uses.
|
||||
sku: variant?.sku ?? product.sku ?? null,
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
// Products.noShippingCost — a cart made up entirely of items that opt
|
||||
// out of shipping costs (e.g. purely digital downloads) never gets
|
||||
// charged shipping at all, regardless of the free-shipping threshold.
|
||||
// A single item WITHOUT the flag still triggers normal shipping for the
|
||||
// whole order — this only exempts a product, never the whole cart just
|
||||
// because it contains an exempt item.
|
||||
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
|
||||
|
||||
const shippingMethods = await getShippingMethods();
|
||||
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
|
||||
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
|
||||
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
|
||||
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
|
||||
|
||||
const paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
if (!paymentMethod) return NextResponse.json({ ok: false, reason: "Zahlungsart ist ungültig." }, { status: 400 });
|
||||
|
||||
let discountAmount = 0;
|
||||
if (body.discountCode) {
|
||||
const validation = await validateDiscountCode(body.discountCode, subtotal);
|
||||
if (!validation.valid) return NextResponse.json({ ok: false, reason: validation.reason }, { status: 400 });
|
||||
const redeemed = await redeemDiscountCode(validation.doc);
|
||||
if (!redeemed) return NextResponse.json({ ok: false, reason: "Rabattcode konnte nicht eingelöst werden." }, { status: 400 });
|
||||
discountAmount = roundMoney(
|
||||
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal),
|
||||
);
|
||||
}
|
||||
|
||||
// VAT-ID validity and the exemption decision are two separate questions.
|
||||
// Validity (is this actually a currently-registered VAT ID at all) is
|
||||
// checked via VIES for ANY country whenever one is given — worth
|
||||
// recording regardless of destination, same "data quality" reasoning as
|
||||
// company-settings.vatId's own VIES check on the backend; a merely
|
||||
// format-valid id (e.g. "ED123456789" — "ED" isn't even a real country
|
||||
// code) is never enough on its own. The exemption itself
|
||||
// (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG) additionally
|
||||
// requires the goods' actual destination (the shipping override's
|
||||
// country when set, the billing country otherwise) to be Österreich,
|
||||
// the one EU-cross-border option this checkout offers — a validated
|
||||
// *German* VAT ID never zero-rates a domestic sale, no matter how real
|
||||
// it is. VIES being unreachable fails closed on the exemption: normal
|
||||
// VAT applies, never a guessed exemption (vatIdValidatedAt just stays
|
||||
// unset in that case too).
|
||||
let vatExempt = false;
|
||||
let vatIdValidatedAt: string | null = null;
|
||||
// A Kleinunternehmer never charges VAT on any sale, domestic or
|
||||
// cross-border — the intra-community exemption exists to zero-rate what
|
||||
// would otherwise be a positive-rate charge, which never applies here in
|
||||
// the first place, so the VIES lookup is skipped entirely (also saves an
|
||||
// unneeded network round-trip).
|
||||
const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry);
|
||||
if (!kleinunternehmer && normalizedVatId) {
|
||||
const viesResult = await checkVatIdViaVies(normalizedVatId);
|
||||
if (viesResult.ok && viesResult.valid) {
|
||||
vatIdValidatedAt = new Date().toISOString();
|
||||
if (isExemptionEligibleCountry(buyerDestinationCountry)) {
|
||||
vatExempt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vatExempt) {
|
||||
// Re-price every line net of VAT (0% now applies) instead of the
|
||||
// catalog's normal VAT-inclusive price — the whole point of the
|
||||
// exemption is that the buyer pays less, not that this shop quietly
|
||||
// keeps the VAT portion as extra margin. items/subtotal/shippingCost
|
||||
// below are overwritten with the de-grossed figures actually charged
|
||||
// and actually persisted on the order/invoice.
|
||||
for (const item of items) {
|
||||
item.unitPrice = roundMoney(item.unitPrice / (1 + item.taxRatePercent / 100));
|
||||
item.taxRatePercent = 0;
|
||||
}
|
||||
}
|
||||
const exemptTotals = vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map((i) => ({ quantity: i.quantity, grossUnitPrice: i.unitPrice, taxRatePercent: 0 })),
|
||||
shippingCost,
|
||||
defaultTaxRate,
|
||||
discountAmount,
|
||||
)
|
||||
: null;
|
||||
// Note: exemptTotals recomputes `subtotal` from the already-degrossed
|
||||
// `items` above (taxRatePercent 0 there means computeExemptTotals's own
|
||||
// degross() step is a no-op on them) — it exists mainly to degross
|
||||
// `shippingCost` the same way, and to keep both figures derived through
|
||||
// one shared function rather than duplicating the arithmetic here.
|
||||
const finalSubtotal = exemptTotals?.subtotal ?? subtotal;
|
||||
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
|
||||
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
|
||||
|
||||
// Gated-payment branch (Kreditkarte/PayPal today) — see
|
||||
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
|
||||
// order so its id can be persisted onto the order at creation time
|
||||
// (providerReference), rather than needing a second authenticated
|
||||
// update call that doesn't otherwise exist from this service. Stripe
|
||||
// generates a PaymentIntent id independent of any order existing yet.
|
||||
const requiresPayment = paymentMethod.provider === "stripe";
|
||||
let providerReference: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
if (requiresPayment) {
|
||||
try {
|
||||
const intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: body.email,
|
||||
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
|
||||
});
|
||||
providerReference = intent.providerReference;
|
||||
clientSecret = intent.clientSecret;
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
|
||||
customerEmail: body.email,
|
||||
total,
|
||||
error: String(err),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
customerId: customer.id,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
customerEmail: body.email,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
vatIdValidatedAt,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
shippingMethod: shippingMethod.id,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
// Stripe's Payment Element does that next. Snapshotting the specific
|
||||
// resolved row's title here would just record whichever row happened
|
||||
// to be the group's representative id, not what was really picked.
|
||||
// The webhook route refines this to the real instrument
|
||||
// ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment.
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
total,
|
||||
...(requiresPayment
|
||||
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
|
||||
: {}),
|
||||
});
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
// through checkout believing they bought something, and nothing was
|
||||
// persisted. Kuma's uptime checks can't see this (the site is up,
|
||||
// this route just returned a 500) — this is the one alert path that
|
||||
// can.
|
||||
sendCriticalAlert("Bestellung konnte nicht gespeichert werden", {
|
||||
customerId: customer.id,
|
||||
customerEmail: body.email,
|
||||
cart: body.cart,
|
||||
total,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
if (requiresPayment && providerReference) {
|
||||
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
|
||||
// Not fatal: the order's own `providerReference` field (already
|
||||
// persisted above) remains the source of truth for the
|
||||
// expirePendingPayments cleanup job either way; this only speeds up
|
||||
// the webhook's fast path.
|
||||
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
|
||||
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
providerReference,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
|
||||
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
|
||||
// from the backend's confirm-payment endpoint instead, at that point.
|
||||
// Unchanged for Überweisung: fires immediately, exactly as before.
|
||||
if (!requiresPayment) {
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber as string,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt as string,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
|
||||
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
sku: i.sku,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
// Only meaningful for the Vorkasse notice above — whether a
|
||||
// switch to Kreditkarte/PayPal is even worth mentioning right now.
|
||||
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget, same reasoning as the confirmation email above — a
|
||||
// failed marketing sync is not worth failing checkout over, and doesn't
|
||||
// even need a critical alert (nothing customer-facing depends on it).
|
||||
// Not gated on payment confirmation — a newsletter signup intent isn't
|
||||
// an order-fulfillment concern, unlike the confirmation email/invoice.
|
||||
if (body.newsletterOptIn) {
|
||||
upsertNewsletterContact(body.email, "checkout").catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderNumber: order.orderNumber,
|
||||
orderId: order.id,
|
||||
orderDateIso: order.createdAt,
|
||||
...(requiresPayment
|
||||
? {
|
||||
requiresPayment: true as const,
|
||||
clientSecret,
|
||||
testMode: isPaymentTestMode,
|
||||
// Only surfaced in test mode — PaymentStep's "Testzahlung"
|
||||
// buttons need it to call the test-confirm route directly,
|
||||
// since there's no real Stripe redirect to carry it back
|
||||
// through. A real PaymentIntent id isn't secret (only its
|
||||
// client_secret is), but there's no reason to expose it to the
|
||||
// client outside test mode either.
|
||||
...(isPaymentTestMode ? { providerReference } : {}),
|
||||
}
|
||||
: {}),
|
||||
shippingCost: finalShippingCost,
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// Polled by /checkout/verarbeitung after a Payment Element redirect
|
||||
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
|
||||
// session (checkout is "Konto Pflicht", so one always exists by the time
|
||||
// this page is reachable) rather than accepting a bare orderNumber, so a
|
||||
// guessed/leaked order number can't be used to probe another customer's
|
||||
// payment status.
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
|
||||
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus,
|
||||
// Refined from the checkout-time "Online-Zahlung" placeholder to the
|
||||
// actual instrument (Kreditkarte/PayPal) once confirm-payment sets it
|
||||
// — see resolveStripePaymentMethodLabel's own comment. Returned here
|
||||
// so VerarbeitungContent can patch the pending sessionStorage snapshot
|
||||
// before promoting it, so /bestellbestaetigung shows the real one.
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
|
||||
|
||||
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
|
||||
// delivery). Proxied through this Next.js route rather than fetched
|
||||
// directly from the client the way VIES is (see validate-vat/route.ts) —
|
||||
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
|
||||
// public EU endpoint, so the browser must never call Payload's DHL
|
||||
// endpoint (or hold its own copy of tenant credentials) directly.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
|
||||
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
|
||||
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
|
||||
if (!postNumber || !firstName || !lastName) {
|
||||
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
|
||||
|
||||
// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever
|
||||
// the billing country is Österreich — the only cross-border-EU option this
|
||||
// checkout offers besides Deutschland (domestic, exemption never applies)
|
||||
// and Schweiz (non-EU export, a different exemption entirely, out of
|
||||
// scope here). Gives the shopper immediate feedback on whether their VAT
|
||||
// ID actually qualifies for the innergemeinschaftliche-Lieferung
|
||||
// exemption, before they even submit — api/checkout/route.ts re-runs this
|
||||
// exact same check server-side at submit time regardless (never trusts
|
||||
// this response), since a VIES result could theoretically change between
|
||||
// blur and submit.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const vatId = typeof body?.vatId === "string" ? body.vatId : "";
|
||||
if (!vatId) return NextResponse.json({ ok: false, reason: "USt-IdNr. fehlt." }, { status: 400 });
|
||||
|
||||
const normalized = normalizeVatId(vatId);
|
||||
if (!isValidVatId(normalized)) {
|
||||
return NextResponse.json({ ok: true, valid: false, reason: "Ungültiges USt-IdNr.-Format." });
|
||||
}
|
||||
|
||||
const result = await checkVatIdViaVies(normalized);
|
||||
if (!result.ok) {
|
||||
// `ok: false` here means "VIES couldn't confirm this one way or the
|
||||
// other" (unreachable, or the member state's own gateway is briefly
|
||||
// down — `MS_UNAVAILABLE`, which VIES itself answers 200 for, not an
|
||||
// error status) — NOT "confirmed invalid". Previously this branch
|
||||
// still answered `{ ok: true, valid: false }`, which the client reads
|
||||
// as a rejected VAT ID (`vatIdViesStatus = "invalid"`) instead of
|
||||
// "couldn't check right now" (`"unavailable"`) — a real, currently
|
||||
// registered VAT ID looked wrong to the customer whenever VIES (or
|
||||
// just Germany's own national gateway) had a hiccup.
|
||||
return NextResponse.json({ ok: false, reason: result.reason });
|
||||
}
|
||||
return NextResponse.json({ ok: true, valid: result.valid, name: result.name });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called once, from CheckoutContent.tsx's handlePurchase(), right before
|
||||
// the OrderSnapshot is written — re-validates (the window/limit may have
|
||||
// changed since the cart-side /validate check, however unlikely) and only
|
||||
// then increments the redemption counter. If this fails, the caller must
|
||||
// not complete the purchase with a dead code silently still showing as
|
||||
// applied.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ redeemed: false, reason: "Kein Code angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json({ redeemed: false, reason: result.reason });
|
||||
|
||||
const ok = await redeemDiscountCode(result.doc);
|
||||
if (!ok) return NextResponse.json({ redeemed: false, reason: "Rabattcode konnte nicht eingelöst werden." });
|
||||
|
||||
return NextResponse.json({ redeemed: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called from CartContent.tsx when a shopper clicks "Anwenden" — read-only
|
||||
// check (active/window/minOrderValue/remaining-redemptions), does NOT
|
||||
// increment the redemption counter. That only happens in /redeem, at
|
||||
// actual purchase time (see CheckoutContent.tsx's handlePurchase()).
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ valid: false, reason: "Bitte einen Code eingeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json(result);
|
||||
|
||||
return NextResponse.json({ valid: true, type: result.doc.type, value: result.doc.value });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Deliberately checks Payload connectivity, not just "did this route
|
||||
// handler run" — the site can return 200s from every static/ISR page
|
||||
// while Payload itself is unreachable (stale cached content masks it for
|
||||
// a while). Meant for a Kuma HTTP monitor, added to the existing "Content
|
||||
// & API" group alongside the direct Payload monitors (see ~/dev/README.md).
|
||||
export async function GET() {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/posts?limit=1`, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getCompanySettings } from "../../lib/payload";
|
||||
|
||||
// react-pdf's default hyphenation would otherwise auto-split long words at
|
||||
// syllable boundaries to fit the line — including inside the seller's own
|
||||
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
|
||||
// "einfach-produk-tiv.com"), which must never be broken mid-word.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
// Generated on-the-fly from live company-settings instead of a static
|
||||
// uploaded PDF (see /widerruf's own download link) — the "An:"
|
||||
// address used to be baked into a hand-crafted PDF and silently went
|
||||
// stale the moment an admin updated the Impressum's Anbieterdaten without
|
||||
// remembering to also re-export/re-upload this file by hand (exactly what
|
||||
// happened 2026-07-29: the address changed in company-settings, the PDF
|
||||
// still had the old one). Same source data as AnbieterAngaben.tsx's
|
||||
// Impressum block, so the two can never drift apart again.
|
||||
const BRAND = "#f6a701";
|
||||
const DARK = "#1b1b1a";
|
||||
const GREY = "#737371";
|
||||
const RULE = "#d1cec4";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 10.5,
|
||||
color: DARK,
|
||||
paddingTop: 78,
|
||||
paddingBottom: 64,
|
||||
paddingHorizontal: 64,
|
||||
},
|
||||
topBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
backgroundColor: BRAND,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
accent: {
|
||||
width: 32,
|
||||
height: 2.2,
|
||||
backgroundColor: BRAND,
|
||||
marginBottom: 14,
|
||||
},
|
||||
note: {
|
||||
fontSize: 9.5,
|
||||
color: GREY,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraph: {
|
||||
lineHeight: 1.45,
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraphSpaced: {
|
||||
lineHeight: 1.45,
|
||||
marginTop: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8.5,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: GREY,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionRule: {
|
||||
borderBottomWidth: 0.75,
|
||||
borderBottomColor: RULE,
|
||||
},
|
||||
footerNote: {
|
||||
fontSize: 8.5,
|
||||
color: GREY,
|
||||
marginTop: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const SECTION_HEADERS = [
|
||||
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
|
||||
"BESTELLT AM (*)",
|
||||
"ERHALTEN AM (*)",
|
||||
"NAME DES/DER VERBRAUCHER(S)",
|
||||
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
|
||||
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
|
||||
"DATUM",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const seller = await getCompanySettings();
|
||||
|
||||
const addressLine = seller
|
||||
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
|
||||
: "einfach produktiv.";
|
||||
|
||||
const doc = React.createElement(
|
||||
Document,
|
||||
{ title: "Muster-Widerrufsformular - einfach produktiv." },
|
||||
React.createElement(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
React.createElement(View, { style: styles.topBar }),
|
||||
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
|
||||
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
|
||||
React.createElement(View, { style: styles.accent }),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.note },
|
||||
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
|
||||
),
|
||||
React.createElement(Text, { style: styles.label }, "An:"),
|
||||
React.createElement(Text, { style: styles.paragraph }, addressLine),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.paragraphSpaced },
|
||||
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
|
||||
),
|
||||
...SECTION_HEADERS.map((header) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ key: header, style: styles.section },
|
||||
React.createElement(Text, { style: styles.sectionLabel }, header),
|
||||
React.createElement(View, { style: styles.sectionRule }),
|
||||
),
|
||||
),
|
||||
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
|
||||
),
|
||||
);
|
||||
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { upsertNewsletterContact, type NewsletterOptInSource } from "../../../lib/brevo";
|
||||
import { isValidEmail } from "../../../lib/email";
|
||||
|
||||
type SubscribeBody = {
|
||||
email?: string;
|
||||
consent?: boolean;
|
||||
source?: NewsletterOptInSource;
|
||||
};
|
||||
|
||||
const VALID_SOURCES: NewsletterOptInSource[] = ["newsletter-page", "newsletter-modal", "newsletter-hero", "challenge"];
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body: SubscribeBody = await req.json();
|
||||
const email = body.email?.trim() ?? "";
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
|
||||
}
|
||||
if (!body.consent) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte akzeptiere die Datenschutzerklärung." }, { status: 400 });
|
||||
}
|
||||
|
||||
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, alreadySubscribed: result.alreadySubscribed ?? false });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { draftMode } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Entered only via the `livePreview.url` link Payload puts in its admin
|
||||
// (posts/legal-pages/testimonials, see payload.config.ts and those
|
||||
// collections' own `admin.livePreview.url` resolvers) — enables Draft Mode
|
||||
// so the target page renders its Live-Preview-aware components (see the
|
||||
// `isPreview` checks in those pages' page.tsx), then redirects into the
|
||||
// actual page. `path` is never redirected to as-is (open-redirect risk);
|
||||
// it's validated to be an internal path first.
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const secret = searchParams.get("secret");
|
||||
const path = searchParams.get("path");
|
||||
|
||||
if (!secret || secret !== process.env.PAYLOAD_PREVIEW_SECRET) {
|
||||
return new Response("Invalid secret", { status: 401 });
|
||||
}
|
||||
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
||||
return new Response("Invalid path", { status: 400 });
|
||||
}
|
||||
|
||||
const draft = await draftMode();
|
||||
draft.enable();
|
||||
|
||||
redirect(path);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type SearchResult = {
|
||||
type: "product" | "post";
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// Lightweight instant search — plain `where[...][contains]` queries
|
||||
// against Payload (Postgres ILIKE under the hood) rather than a real
|
||||
// search index (Meilisearch/Algolia). Fine at this catalog size (a
|
||||
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
|
||||
// own "search becomes necessary past ~20 products" note) — worth
|
||||
// upgrading only once the catalog actually grows into that range, this
|
||||
// route can be swapped out later without touching the frontend overlay.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
if (q.length < 2) return NextResponse.json({ results: [] });
|
||||
|
||||
const productParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
"where[name][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
const postParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
"where[title][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
|
||||
const [productsRes, postsRes] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
|
||||
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
|
||||
]);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (productsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
|
||||
await productsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "product",
|
||||
id: `product-${doc.id}`,
|
||||
title: doc.name,
|
||||
href: doc.detailHref || "/shop",
|
||||
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (postsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "post",
|
||||
id: `post-${doc.id}`,
|
||||
title: doc.title,
|
||||
href: `/blog/${doc.slug}`,
|
||||
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isValidEmail } from "../../lib/email";
|
||||
import { createStockNotification } from "../../lib/stockNotifications";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
||||
const productId = Number(body?.productId);
|
||||
const variantName = typeof body?.variantName === "string" ? body.variantName : "";
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
|
||||
}
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createStockNotification(email, productId, variantName);
|
||||
if (!result.ok) return NextResponse.json(result, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
|
||||
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
|
||||
// here then), but left unconditional rather than gated on the env var —
|
||||
// an invalid/missing signature already fails closed on its own.
|
||||
export async function POST(request: Request) {
|
||||
// Raw body only — request.json() would consume/reparse the stream and
|
||||
// Stripe's signature is computed over the exact original bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
|
||||
const event = verifyStripeWebhookSignature(rawBody, signature);
|
||||
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
|
||||
|
||||
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
|
||||
// Stripe sends many event types we don't act on (e.g.
|
||||
// payment_intent.created, charge.*) — ack them so Stripe stops
|
||||
// retrying something we were never going to process.
|
||||
return NextResponse.json({ ok: true, ignored: event.type });
|
||||
}
|
||||
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
const providerReference = intent.id;
|
||||
const orderId = intent.metadata?.orderId;
|
||||
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
|
||||
|
||||
if (!orderId) {
|
||||
// stripeProvider.attachOrderMetadata (called right after order
|
||||
// creation in /api/checkout) failed to complete for this
|
||||
// PaymentIntent — the order's own `providerReference` field is still
|
||||
// the source of truth and expirePendingPayments will reconcile it
|
||||
// eventually, but that's a multi-hour fallback, not instant. Alert
|
||||
// now rather than silently relying on the cleanup job.
|
||||
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
|
||||
// Non-2xx so Stripe retries — a later retry might land after the
|
||||
// metadata attach (which races the checkout response) has caught up.
|
||||
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Best-effort — see resolveStripePaymentMethodLabel's own comment. Only
|
||||
// meaningful on the "paid" path; a failed payment never gets a
|
||||
// paymentMethodTitle refinement (the order becomes 'cancelled' outright).
|
||||
const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined;
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentStatus,
|
||||
providerReference,
|
||||
paidAt: new Date().toISOString(),
|
||||
...(paymentMethodTitle ? { paymentMethodTitle } : {}),
|
||||
}),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
|
||||
// provide resilience instead of building an internal retry queue.
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Fire-and-forget, same reasoning as the checkout route's own send: a
|
||||
// failed confirmation email must never turn an already-successful
|
||||
// payment confirmation into a non-2xx response (that would make Stripe
|
||||
// retry a webhook we've already fully processed). `alreadyProcessed`/
|
||||
// missing `order` means this is a repeat delivery — see confirmPayment.ts's
|
||||
// own comment on why the email must not be sent twice.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPaymentTestMode } from "../../../../lib/payments";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Test-mode stand-in for the real Stripe webhook — see
|
||||
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
|
||||
// endpoint the real webhook calls, just without a real Stripe event/
|
||||
// signature (there is none to verify in test mode). Hard-gated: must
|
||||
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
|
||||
// become an unauthenticated "mark any order paid" endpoint in production.
|
||||
export async function POST(request: Request) {
|
||||
if (!isPaymentTestMode) {
|
||||
return NextResponse.json({ ok: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderId = body?.orderId;
|
||||
const providerReference = body?.providerReference;
|
||||
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
|
||||
if (!orderId || !providerReference) {
|
||||
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Same email-send as the real webhook route — see its own comment and
|
||||
// confirmPaymentEmail.ts. Reproduces today's "immediate confirmation"
|
||||
// behavior on a test click, exercising the real send path rather than a
|
||||
// separate short-circuit.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -5,9 +5,13 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice, formatDate, discountPercent } from "../../lib/format";
|
||||
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals } from "../../lib/vatExemption";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
|
||||
// Rejects (rather than silently patching with fallback values) anything
|
||||
@@ -26,7 +30,11 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
typeof data.orderNumber !== "string" ||
|
||||
typeof data.orderDateIso !== "string" ||
|
||||
typeof data.shippingCost !== "number" ||
|
||||
typeof data.paymentMethodTitle !== "string"
|
||||
typeof data.paymentMethodTitle !== "string" ||
|
||||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
|
||||
typeof data.discountAmount !== "number" ||
|
||||
typeof data.vatExempt !== "boolean" ||
|
||||
typeof data.kleinunternehmer !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -36,7 +44,7 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function BestellbestaetigungContent() {
|
||||
export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: number }) {
|
||||
const products = useProducts();
|
||||
const [order, setOrder] = useState<OrderSnapshot | null>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
@@ -95,12 +103,45 @@ export function BestellbestaetigungContent() {
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const totalSavings = items.reduce((sum, { entry, product }) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
||||
}, 0);
|
||||
const total = subtotal + order.shippingCost;
|
||||
// Displays the *persisted* discount/shippingCost from the snapshot, not
|
||||
// a fresh re-derivation — the purchase already happened, this page is a
|
||||
// receipt, not a live cart, so it doesn't re-validate the code at all.
|
||||
// order.shippingCost is already the actual (possibly de-grossed, if
|
||||
// vatExempt) figure charged at checkout — see api/checkout/route.ts's
|
||||
// own response. `subtotal`/`taxBreakdown` below still need their own
|
||||
// exempt branch, though: computeCartTotals/computeTaxBreakdown build
|
||||
// `subtotal` from each item's *current catalog* gross price via
|
||||
// effectivePrice(), which for an exempt order was never what was
|
||||
// actually charged (the catalog price includes VAT; the exempt order
|
||||
// paid the de-grossed net price instead).
|
||||
const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, {
|
||||
type: "fixed",
|
||||
value: order.discountAmount,
|
||||
});
|
||||
const exemptTotals = order.vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
order.shippingCost,
|
||||
defaultTaxRate,
|
||||
order.discountAmount,
|
||||
)
|
||||
: null;
|
||||
const subtotal = exemptTotals?.subtotal ?? catalogSubtotal;
|
||||
const total = exemptTotals?.total ?? catalogTotal;
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
catalogSubtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -142,9 +183,23 @@ export function BestellbestaetigungContent() {
|
||||
</Reveal>
|
||||
|
||||
{!productsLoading && (
|
||||
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
|
||||
// sidebar, L297 below — same fixed-width-block category as Tier C
|
||||
// exceptions elsewhere), while the inner order-meta/items row
|
||||
// already switches at sm: — intentional, not an inconsistent
|
||||
// leftover: the inner row has no fixed-width sidebar to fight for
|
||||
// room against, just two flexible columns.
|
||||
//
|
||||
// max-w-[36rem] between sm and lg (same fix/value as
|
||||
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
|
||||
// doesn't actually constrain anything below that viewport width,
|
||||
// so at real Tablet widths (640-1023px) this card stretched to
|
||||
// fill the full page width instead of reading as a compact,
|
||||
// centered card. Widens to the real 56rem cap only once lg:'s
|
||||
// sidebar split kicks in and needs the room.
|
||||
<Reveal
|
||||
delay={0.05}
|
||||
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
>
|
||||
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
|
||||
{/* Order meta */}
|
||||
@@ -174,22 +229,30 @@ export function BestellbestaetigungContent() {
|
||||
Bestellübersicht
|
||||
</p>
|
||||
|
||||
{items.map(({ entry, product }) => (
|
||||
<div key={product.id} className="flex gap-4 items-center w-full">
|
||||
{items.map(({ entry, product }) => {
|
||||
const unitPrice = effectivePrice(entry, product);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
|
||||
return (
|
||||
<div key={lineKey} className="flex gap-4 items-center w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">{product.name}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(product.price)} <span>inkl. MwSt.</span>
|
||||
{entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * product.price)}
|
||||
{formatPrice(entry.qty * unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -205,6 +268,13 @@ export function BestellbestaetigungContent() {
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.discountCode && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Versand</span>
|
||||
<span className="flex-1" />
|
||||
@@ -226,7 +296,13 @@ export function BestellbestaetigungContent() {
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
{order.kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : order.vatExempt ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,6 +328,12 @@ export function BestellbestaetigungContent() {
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.08} className="flex items-center justify-center pb-4 px-[var(--layout-padding-x)] w-full">
|
||||
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Bestellungen ansehen
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
{/* Testimonial band — same proven structure as /not-found's version
|
||||
(see that page's own comment), not a new layout: w-[45%] image
|
||||
column, narrow 40px edge gradient into bg-muted, quote in the
|
||||
@@ -260,8 +342,8 @@ export function BestellbestaetigungContent() {
|
||||
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
|
||||
@@ -275,10 +357,10 @@ export function BestellbestaetigungContent() {
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { BestellbestaetigungContent } from "./components/BestellbestaetigungContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getDefaultTaxRatePercent } from "../lib/payload";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart and
|
||||
// /checkout (this one doubles as a receipt, not something to surface in
|
||||
@@ -15,11 +16,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function BestellbestaetigungPage() {
|
||||
export default async function BestellbestaetigungPage() {
|
||||
const defaultTaxRate = await getDefaultTaxRatePercent();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<BestellbestaetigungContent />
|
||||
<BestellbestaetigungContent defaultTaxRate={defaultTaxRate} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { RichText } from "../../../components/RichText";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of the blog detail page: title/categories/readTime/
|
||||
// excerpt/byline, the thumbnail, and the RichText body — the fields an
|
||||
// editor actually watches update while typing. The author bio card,
|
||||
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
|
||||
// aren't post-specific content (bio) or are about a *different* post
|
||||
// (nextPost), not the document currently open in the admin.
|
||||
export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
|
||||
const { data } = useLivePreview<PayloadPostDetail>({
|
||||
initialData: initialPost as unknown as PayloadPostDetail,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const post = data?.slug ? mapPayloadPost(data) : initialPost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{post.thumbnail && (
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} quoteLabel={post.quoteLabel} />
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+133
-94
@@ -2,11 +2,14 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { buildArticleSchema } from "../../lib/structuredData";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -17,16 +20,29 @@ export async function generateMetadata({
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return { title: "Beitrag nicht gefunden" };
|
||||
|
||||
// Each falls back to the normal field when its SEO override (Posts.ts's
|
||||
// "SEO" collapsible group) is empty — filling those in is optional, a
|
||||
// post already has sensible metadata without them.
|
||||
const title = post.seoTitle || post.title;
|
||||
const description = post.seoDescription || post.excerpt;
|
||||
const image = post.seoImage || post.thumbnail;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: `${post.title} | einfach produktiv.`,
|
||||
description: post.excerpt,
|
||||
title: `${title} | einfach produktiv.`,
|
||||
description,
|
||||
url: `/blog/${post.slug}`,
|
||||
type: "article",
|
||||
images: post.thumbnail ? [{ url: post.thumbnail }] : undefined,
|
||||
images: image ? [{ url: image }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,7 +53,8 @@ export default async function BlogDetailPage({
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const post = await getPostBySlug(slug, { draft: isPreview });
|
||||
if (!post) notFound();
|
||||
|
||||
// "Weiterlesen" — any other post, most recent first. Not the current
|
||||
@@ -45,101 +62,123 @@ 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">
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
{/* Playfair (not Lora), matching the actual Figma title's font —
|
||||
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
|
||||
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
|
||||
the legal pages use. Sized down from text-display (Figma's
|
||||
literal 44px), but text-h-feature (max 40px) read too small —
|
||||
no named token sits between the two, so this is a one-off
|
||||
fluid(36, 48) clamp using the project's own fluid.ts formula
|
||||
(768px Tablet floor → 1440px Desktop cap), landing between
|
||||
them instead of jumping all the way back to text-display. */}
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
) : (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
{/* Playfair (not Lora), matching the actual Figma title's font —
|
||||
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
|
||||
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
|
||||
the legal pages use. Sized down from text-display (Figma's
|
||||
literal 44px), but text-h-feature (max 40px) read too small —
|
||||
no named token sits between the two, so this is a one-off
|
||||
fluid(36, 48) clamp using the project's own fluid.ts formula
|
||||
(768px Tablet floor → 1440px Desktop cap), landing between
|
||||
them instead of jumping all the way back to text-display. */}
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{post.thumbnail && (
|
||||
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
|
||||
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
|
||||
// edge (an earlier version guessed that, which came out far too
|
||||
// wide) and not capped to the body column either.
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
{post.thumbnail && (
|
||||
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
|
||||
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
|
||||
// edge (an earlier version guessed that, which came out far too
|
||||
// wide) and not capped to the body column either.
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} quoteLabel={post.quoteLabel} />
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} />
|
||||
|
||||
{/* "Passend dazu" — static cross-promo, not CMS content (same
|
||||
reasoning as the legal pages' brand callouts): every post
|
||||
currently points at the same flagship product rather than
|
||||
needing a per-post "related product" field that nothing else
|
||||
uses yet. Matches the actual built Figma frame exactly (node
|
||||
4674:349, fetched via get_design_context) — 1px border-border,
|
||||
rounded-md, px-9/py-7 padding; an earlier version guessed a
|
||||
plain borderless row instead, which get_metadata's structural
|
||||
dump didn't reveal (frame-level stroke/padding/radius aren't
|
||||
visible there, only get_design_context shows those). */}
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<Image alt="" src="/icon-todo-passend-dazu.png" width={64} height={75} className="w-16 h-[4.6875rem] shrink-0 object-contain" />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
<div className="flex items-end justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
|
||||
so the description wraps at the same point instead of
|
||||
stretching out to fill the space before "Entdecken". */}
|
||||
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
|
||||
Entdecken
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
|
||||
not a hardcoded flagship-product link. Hidden entirely if the
|
||||
post has no related product, or that product has no detail
|
||||
page to send "Entdecken" to. Matches the actual built Figma
|
||||
frame exactly (node 4674:349, fetched via get_design_context) —
|
||||
1px border-border, rounded-md, px-9/py-7 padding; an earlier
|
||||
version guessed a plain borderless row instead, which
|
||||
get_metadata's structural dump didn't reveal (frame-level
|
||||
stroke/padding/radius aren't visible there, only
|
||||
get_design_context shows those). */}
|
||||
{post.relatedProduct?.href && (
|
||||
<Link
|
||||
href={post.relatedProduct.href}
|
||||
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
{/* Stacked below sm: — the fixed w-[19rem] title column plus
|
||||
"Entdecken" on the same row overflowed a mobile-width
|
||||
card (fixed 2026-07-24). "Entdecken" wraps to its own
|
||||
line with a little space above it; back to the
|
||||
side-by-side row (matching Figma) from sm: up, where
|
||||
there's room for both. */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) only from sm: — matches Figma's
|
||||
title-col exactly there, so the description wraps at
|
||||
the same point instead of stretching out to fill the
|
||||
space before "Entdecken"; full width below sm:. */}
|
||||
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.descriptionText}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap mt-1 sm:mt-0">
|
||||
Entdecken
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
|
||||
@@ -182,7 +221,7 @@ export default async function BlogDetailPage({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{nextPost.category}</span>
|
||||
<span>{nextPost.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
+102
-41
@@ -4,65 +4,110 @@ import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
|
||||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
alternates: { canonical: "/blog" },
|
||||
openGraph: {
|
||||
title: "Blog | einfach produktiv.",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
url: "/blog",
|
||||
type: "website",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogOverviewPage() {
|
||||
const posts = await getBlogPosts(100);
|
||||
const [featured, ...rest] = posts;
|
||||
// Every distinct category name in DOM order — Payload's `categories` field
|
||||
// stores related docs, no separate slug on this side, but since this page
|
||||
// already fetches every published post (limit 100, no pagination), a
|
||||
// plain client-agnostic array filter is enough; no separate Payload query
|
||||
// per category needed.
|
||||
function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const post of posts) for (const c of post.categories) seen.add(c);
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export default async function BlogOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categories?: string }>;
|
||||
}) {
|
||||
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
|
||||
const { categories: categoriesParam } = await searchParams;
|
||||
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
|
||||
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
|
||||
const posts =
|
||||
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
|
||||
// getBlogPosts sorts "-featured,-publishedAt", so index 0 IS the actual
|
||||
// featured post when the unfiltered list is shown — but a category
|
||||
// filter can (and often will) exclude it entirely, in which case index 0
|
||||
// is just the newest matching post, not an editorially featured one. It
|
||||
// still doesn't deserve the big hero-card treatment (implies "the
|
||||
// featured post", not "whatever sorted first"), so that layout is gated
|
||||
// on the post's own `featured` flag, not on array position.
|
||||
const [first, ...restAll] = posts;
|
||||
const featured = first?.featured ? first : undefined;
|
||||
const rest = featured ? restAll : posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{/* 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]">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Blog
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
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="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||||
</div>
|
||||
{/* Header — plain text, same treatment as /shop's ShopHeader.tsx
|
||||
and every legal page (the bespoke photo-bleed hero this used to
|
||||
be predated that convention being established elsewhere). */}
|
||||
<Reveal className="flex flex-col gap-4 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full border-b border-border">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Blog</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Blog
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
{allCategories.length > 1 && (
|
||||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
|
||||
Keine Beiträge in dieser Kategorie gefunden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{featured && (
|
||||
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
|
||||
// hero section's bottom edge instead of sitting flush below it.
|
||||
// relative z-10 keeps it stacked above the hero's own photo.
|
||||
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||||
// key'd for the same reason as RevealGroup below — guards against
|
||||
// the same stuck-at-opacity-0 failure mode if a future filter
|
||||
// combination ever swaps in a *different* featured post without
|
||||
// an intervening moment where `featured` was falsy.
|
||||
<Reveal key={featured.id} delay={0.1} className="w-full px-[var(--layout-padding-x)] pt-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 items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{featured.category}</span>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span className="whitespace-nowrap">{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
<span className="whitespace-nowrap">{featured.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
|
||||
<span className="uppercase whitespace-nowrap">{formatDate(featured.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
@@ -88,7 +133,23 @@ export default async function BlogOverviewPage() {
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<RevealGroup className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border">
|
||||
// key'd on the rendered post set — RevealGroup's `whileInView`
|
||||
// only fires once per component instance (viewport.once=true),
|
||||
// and a category-filter change re-renders this same page
|
||||
// component in place (only `searchParams` differs, no full
|
||||
// remount). Since `rest.length > 0` stays true across most
|
||||
// filter transitions, RevealGroup itself never naturally
|
||||
// unmounts, so once it has already fired "show" for one
|
||||
// filter's list, freshly swapped-in RevealItems (new post ids)
|
||||
// mount into an already-settled parent that has no reason to
|
||||
// re-fire the reveal trigger — they were stuck at opacity 0
|
||||
// forever, reported as the list appearing blank after refiltering.
|
||||
// Forcing a remount on every distinct post set restarts
|
||||
// RevealGroup's viewport tracking from scratch each time.
|
||||
<RevealGroup
|
||||
key={rest.map((post) => post.id).join(",")}
|
||||
className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border"
|
||||
>
|
||||
{rest.map((post) => (
|
||||
<RevealItem key={post.id} className="py-8 first:pt-0 last:pb-0">
|
||||
<Link href={`/blog/${post.slug}`} className="group flex flex-col sm:flex-row gap-6 items-start">
|
||||
@@ -98,12 +159,12 @@ export default async function BlogOverviewPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span className="whitespace-nowrap">{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
<span className="whitespace-nowrap">{post.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(post.publishedAt)}</span>
|
||||
<span className="uppercase whitespace-nowrap">{formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
|
||||
import { ProductName } from "../../components/ProductName";
|
||||
import { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -16,6 +23,9 @@ export function CartContent({
|
||||
shippingCost,
|
||||
freeShippingThreshold,
|
||||
shippingSettings,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
showDiscountField,
|
||||
}: {
|
||||
trustBadges: TrustBadge[];
|
||||
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
|
||||
@@ -31,10 +41,31 @@ export function CartContent({
|
||||
* "shippingSettings", not "shipping" — that name is already the local
|
||||
* computed shipping-cost value below. */
|
||||
shippingSettings: ShippingSettings;
|
||||
/** Tenant's default VAT rate (Company Settings), for products that don't
|
||||
* override taxRatePercent themselves — see lib/cartTotals.ts's
|
||||
* effectiveTaxRate(). */
|
||||
defaultTaxRate: number;
|
||||
/** §19 UStG — this tenant's company-settings.kleinunternehmer (Payload's
|
||||
* lib/payload.ts's getKleinunternehmer(), same ISR freshness as
|
||||
* defaultTaxRate above). Drops the "inkl. X% MwSt." hints and the VAT
|
||||
* breakdown in favor of the §19 notice below. */
|
||||
kleinunternehmer: boolean;
|
||||
/** Whether Payload currently has at least one active discount code at
|
||||
* all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
|
||||
* showing an open "enter a code" field when nothing could ever validate
|
||||
* against it. Only gates the manual-entry form; a code already applied
|
||||
* (e.g. from an earlier session, or one deactivated after being shared)
|
||||
* still shows its own result row regardless. */
|
||||
showDiscountField: boolean;
|
||||
}) {
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const discount = useDiscount();
|
||||
const [discountInput, setDiscountInput] = useState("");
|
||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||
const [discountLoading, setDiscountLoading] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
// While the /api/products fetch is still pending, treat a non-empty
|
||||
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
|
||||
// lookup was synchronous, so this distinction didn't exist before;
|
||||
@@ -46,16 +77,60 @@ export function CartContent({
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const totalSavings = items.reduce((sum, { entry, product }) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
||||
}, 0);
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const total = subtotal + shipping;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
subtotal,
|
||||
discountAmount,
|
||||
shipping,
|
||||
);
|
||||
|
||||
async function handleApplyDiscount(code: string) {
|
||||
if (!code) return;
|
||||
setDiscountLoading(true);
|
||||
setDiscountError(null);
|
||||
try {
|
||||
const res = await fetch("/api/discount/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, subtotal }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
|
||||
setDiscountInput("");
|
||||
} else {
|
||||
setDiscountError(data.reason || "Dieser Code ist ungültig.");
|
||||
}
|
||||
} catch {
|
||||
setDiscountError("Rabattcode konnte gerade nicht geprüft werden.");
|
||||
} finally {
|
||||
setDiscountLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// No manual input field anymore (see the Rabattcode section below) —
|
||||
// codes are shared as a direct link instead (e.g. "/cart?code=SAVE10"),
|
||||
// auto-applied once on arrival. Only fires while nothing's applied yet
|
||||
// and there's actually something in the cart to validate a minimum-order
|
||||
// value against.
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
if (code && !discount && items.length > 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from the URL's ?code= param to app state on arrival, via an async server validation call, not a render-cascade
|
||||
handleApplyDiscount(code);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only ever re-run when the cart finishes loading or the URL's code param itself changes, not on every discount/handleApplyDiscount identity change
|
||||
}, [searchParams, items.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,31 +165,89 @@ export function CartContent({
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<ArrowLeftIcon />
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
|
||||
{/* threshold forced to null (banner just doesn't render, see its
|
||||
own early return) whenever nothing in the cart actually
|
||||
triggers shipping — a "€X bis kostenlosem Versand" nudge
|
||||
makes no sense for a cart that was never going to be charged
|
||||
shipping in the first place. */}
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={cartHasShippableItem(items) ? freeShippingThreshold : null} />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Cart card — lg:-only split from the sidebar (same "wide content
|
||||
next to sidebar" shape as the Hero's image/text split, see
|
||||
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
|
||||
already exceeds the 768px Tablet floor, so md: would never
|
||||
have had room for a real 2-column layout at Tablet widths
|
||||
anyway). */}
|
||||
already exceeds even the site's 640px structural floor, so
|
||||
sm: would never have had room for a real 2-column layout at
|
||||
Tablet widths anyway — a deliberate exception to the site-wide
|
||||
sm: consolidation, not a leftover of it). */}
|
||||
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
|
||||
{items.map(({ entry, product }, i) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const unitPrice = effectivePrice(entry, product);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// (id, variant) together, not id alone — two lines for the
|
||||
// same product with different variants need distinct React
|
||||
// keys/element ids and must each only affect their own line
|
||||
// when the quantity or remove control is used, same "full
|
||||
// key" reasoning as cart.ts's own sameLine().
|
||||
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
|
||||
// The exact variant this line is for, not "any variant low"
|
||||
// like the product-grid cards use — a cart line already has
|
||||
// its variant chosen, so it should only warn when that
|
||||
// specific variant (not some other one) is running low.
|
||||
const lowStock = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
|
||||
: product.lowStock;
|
||||
// Same per-line resolution as lowStock above — caps how high
|
||||
// the quantity stepper below can go, instead of only finding
|
||||
// out at checkout that this many aren't actually available
|
||||
// (api/checkout/route.ts's own stock check stays as the
|
||||
// authoritative server-side guard). null (no cap) falls back
|
||||
// to the stepper's original fixed 1-9 range; at least 1 is
|
||||
// always offered even if maxQty is somehow lower than the
|
||||
// qty already in this line, so the remove (×) button stays
|
||||
// the only way down, never an empty <select>.
|
||||
const maxQty = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
|
||||
: product.maxQty;
|
||||
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
|
||||
return (
|
||||
<div key={product.id} className="w-full">
|
||||
<div key={lineKey} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
|
||||
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
|
||||
{/* Full-width on mobile (stacked layout) instead of the
|
||||
fixed 150px square — a small square floating above
|
||||
the text looked cramped on a narrow column that has
|
||||
the width to spare; fixed 150px square again from
|
||||
sm: once the row layout kicks in and the image sits
|
||||
beside the text instead. */}
|
||||
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
|
||||
{product.href ? (
|
||||
<Link href={product.href} aria-label={product.name} className="block w-full h-full">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
{discount !== null && (
|
||||
<span className="absolute top-2 left-2 rounded-full bg-brand px-2 py-0.5 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
@@ -122,45 +255,62 @@ export function CartContent({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
|
||||
{product.href ? (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</Link>
|
||||
) : (
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
)}
|
||||
{product.subline && <p className="text-body-sm text-text-muted">{product.subline}</p>}
|
||||
{/* Independent stacked rows, not grid siblings — no
|
||||
equal-height pressure from neighboring lines, so a
|
||||
plain conditional line is enough here. */}
|
||||
{lowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="text-label text-text-muted">Einzelpreis</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<label className="sr-only" htmlFor={`qty-${product.id}`}>
|
||||
<label className="sr-only" htmlFor={`qty-${lineKey}`}>
|
||||
Menge für {product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</label>
|
||||
<select
|
||||
id={`qty-${product.id}`}
|
||||
id={`qty-${lineKey}`}
|
||||
value={entry.qty}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
{qtyOptions.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * product.price)}
|
||||
{formatPrice(entry.qty * unitPrice)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromCart(product.id)}
|
||||
aria-label={`${product.name} entfernen`}
|
||||
onClick={() => removeFromCart(product.id, entry.variant)}
|
||||
aria-label={`${product.name}${entry.variant ? ` (${entry.variant})` : ""} entfernen`}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
|
||||
>
|
||||
×
|
||||
@@ -177,7 +327,7 @@ export function CartContent({
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<ArrowLeftIcon />
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
@@ -206,32 +356,106 @@ export function CartContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
{/* Rabattcode — manual input when nothing's applied yet AND
|
||||
Payload actually has at least one active code right now
|
||||
(showDiscountField — no point offering an open field
|
||||
that could never validate against anything); once
|
||||
active, always shows the result + "Entfernen" regardless
|
||||
of showDiscountField (also reached via a direct link
|
||||
with a prefilled code, see the useEffect above).
|
||||
/checkout mirrors this exact block, sharing state
|
||||
through lib/discount.ts's localStorage store. */}
|
||||
{discount ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDiscount}
|
||||
className="self-start text-label text-text-muted hover:text-text-primary underline transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
) : showDiscountField ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleApplyDiscount(discountInput.trim());
|
||||
}}
|
||||
className="flex flex-col gap-2 w-full"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<label className="sr-only" htmlFor="cart-discount-code">Rabattcode</label>
|
||||
<input
|
||||
id="cart-discount-code"
|
||||
type="text"
|
||||
value={discountInput}
|
||||
onChange={(e) => setDiscountInput(e.target.value)}
|
||||
placeholder="Rabattcode"
|
||||
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!discountInput.trim() || discountLoading}
|
||||
className="shrink-0 rounded-sm border border-border px-4 py-2 text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand transition-colors disabled:opacity-50"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
{discountError && <p className="text-label text-red-600">{discountError}</p>}
|
||||
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft…</p>}
|
||||
</form>
|
||||
) : (
|
||||
// No manual field to attach an error to (no active codes
|
||||
// exist at all right now) — but a ?code= URL param can
|
||||
// still trigger the auto-apply attempt above regardless
|
||||
// of showDiscountField, so its failure needs somewhere to
|
||||
// show.
|
||||
<>
|
||||
{discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
|
||||
{discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft…</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in
|
||||
the cart actually triggers shipping at all — that's a
|
||||
different state from hitting the free-shipping
|
||||
threshold, which is still a real promotional message
|
||||
worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -247,7 +471,11 @@ export function CartContent({
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
{kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
@@ -268,10 +496,22 @@ export function CartContent({
|
||||
</div>
|
||||
|
||||
{/* Title only — /checkout renders the same CartTrustBadges
|
||||
docs with description too, see CheckoutContent.tsx. */}
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
docs with description too, see CheckoutContent.tsx.
|
||||
Side by side between sm (640px) and lg (1024px) — this
|
||||
sidebar is full page width through that whole range (it
|
||||
only becomes a narrow fixed-width column at lg:, see this
|
||||
file's own lg:w-[20.625rem] above), plenty of room for 3
|
||||
short titles in a row; stacked at true mobile and again
|
||||
once the sidebar narrows at lg:. sm:w-auto with no
|
||||
flex-grow (was sm:flex-1) so each badge only takes its own
|
||||
content width instead of stretching to fill the full card
|
||||
width; sm:justify-center centers that shrink-to-fit row so
|
||||
the leftover space splits evenly left/right instead of
|
||||
collecting on the right (the default with items packed
|
||||
from the start edge). */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap lg:flex-col gap-4 sm:gap-x-8 sm:gap-y-4 lg:gap-4 items-start sm:justify-center lg:justify-start w-full">
|
||||
{trustBadges.map((b) => (
|
||||
<div key={b.id} className="flex gap-3 items-center w-full">
|
||||
<div key={b.id} className="flex gap-3 items-center w-full sm:w-auto lg:w-full">
|
||||
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
|
||||
</div>
|
||||
@@ -308,7 +548,13 @@ export function CartContent({
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
<VersandModal
|
||||
open={versandOpen}
|
||||
onClose={() => setVersandOpen(false)}
|
||||
shipping={shippingSettings}
|
||||
shippingCost={shippingCost}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { ProductCard } from "../../components/ProductCard";
|
||||
import { FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { useCart } from "../../lib/cart";
|
||||
|
||||
const DISPLAY_COUNT = 3;
|
||||
@@ -16,40 +15,44 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
// The catalog only has a handful of products — once the cart holds enough
|
||||
// distinct ones, "N recommendations that aren't already in the cart" can
|
||||
// become impossible (e.g. 4 products total, 2 already in the cart, but
|
||||
// DISPLAY_COUNT is 3 — only 2 non-cart products exist, period). Falling
|
||||
// back to re-suggesting something already in the cart (a normal "grab
|
||||
// another one" pattern) beats silently shrinking the grid below
|
||||
// DISPLAY_COUNT. Shared by both the initial pick and the swap-after-add
|
||||
// path so neither can under-fill the grid.
|
||||
function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
|
||||
// Picks up to `count` active products not already in the cart, on top of
|
||||
// whatever's already in `keep`. Deliberately does NOT fall back to
|
||||
// re-suggesting a cart item when the non-cart pool runs short (e.g. 2
|
||||
// active products total, 1 already in the cart) — the grid just renders
|
||||
// fewer, genuinely-relevant cards instead (see the centering logic in the
|
||||
// component below), rather than padding itself out with something the
|
||||
// shopper has already added.
|
||||
function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
|
||||
const missing = count - keep.length;
|
||||
if (missing <= 0) return keep;
|
||||
|
||||
let picks = pickRandom(allIds, [...excludeIds, ...keep], missing);
|
||||
if (picks.length < missing) {
|
||||
const stillMissing = missing - picks.length;
|
||||
const fallback = pickRandom(allIds, [...keep, ...picks], stillMissing);
|
||||
picks = [...picks, ...fallback];
|
||||
}
|
||||
return [...keep, ...picks];
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts() {
|
||||
export function RelatedProducts({
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
wishlistEnabled,
|
||||
}: {
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
}) {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
// Cart/checkout resolve any product regardless of `active` (see
|
||||
// Product's own comment in lib/payload.ts) — this is the one discovery
|
||||
// surface among the useProducts() consumers, so it filters here itself.
|
||||
const activeProducts = useMemo(() => products.filter((p) => p.active), [products]);
|
||||
const hasItems = cart.length > 0;
|
||||
const cartKey = cart
|
||||
.map((i) => i.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
// useMemo, not a plain .map() — .map() would return a new array
|
||||
// reference on every render regardless of whether `products` itself
|
||||
// reference on every render regardless of whether `activeProducts` itself
|
||||
// changed, which would make the effect below re-run (and re-pick) every
|
||||
// single render if `productIds` were listed as its dependency.
|
||||
const productIds = useMemo(() => products.map((p) => p.id), [products]);
|
||||
const productIds = useMemo(() => activeProducts.map((p) => p.id), [activeProducts]);
|
||||
|
||||
// Starts empty — the catalog itself is now fetched (useProducts()), so
|
||||
// there's nothing to pick a random set from until that resolves. The
|
||||
@@ -76,7 +79,7 @@ export function RelatedProducts() {
|
||||
|
||||
if (!pickedRef.current) {
|
||||
pickedRef.current = true;
|
||||
setDisplayIds(pickWithFallback(productIds, cartIds, [], DISPLAY_COUNT));
|
||||
setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,16 +91,19 @@ export function RelatedProducts() {
|
||||
swapTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayIds((prev) => {
|
||||
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
|
||||
return pickWithFallback(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
|
||||
return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
|
||||
});
|
||||
}, FEEDBACK_MS);
|
||||
}, [cartKey, productIds]);
|
||||
|
||||
const displayProducts = displayIds
|
||||
.map((id) => products.find((p) => p.id === id))
|
||||
.map((id) => activeProducts.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
||||
|
||||
if (displayProducts.length === 0) return null;
|
||||
// Section-wide gate, independent of cart contents: a "related products"
|
||||
// section makes no sense with fewer than 2 active products total to
|
||||
// ever offer, even before considering what's already in the cart.
|
||||
if (activeProducts.length < 2 || displayProducts.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)]">
|
||||
@@ -113,12 +119,7 @@ export function RelatedProducts() {
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* No separate price-disclosure footnote here — the single
|
||||
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
|
||||
the cart's own product table instead (CartContent.tsx), close
|
||||
enough on the same page view to cover these cards too.
|
||||
|
||||
Plain divs, not RevealGroup/RevealItem — this is the one grid on
|
||||
{/* Plain divs, not RevealGroup/RevealItem — this is the one grid on
|
||||
the site whose items get swapped after the initial mount (see
|
||||
the swap-in-place effect above). RevealItem has no viewport
|
||||
trigger of its own; it only ever renders visible because it
|
||||
@@ -128,32 +129,37 @@ export function RelatedProducts() {
|
||||
(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]">
|
||||
{displayProducts.map((product) => (
|
||||
<div
|
||||
{/* No items-start, and grid-auto-rows + subgrid on each card — see
|
||||
ProductGrid.tsx's own comment on why both are needed for
|
||||
ProductCard's internal rows (price, etc.) to actually line up
|
||||
across cards, not just overall card height. No RevealItem
|
||||
wrapper here (see comment above), so the subgrid classes go
|
||||
straight onto ProductCard's own className prop instead. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => (
|
||||
<ProductCard
|
||||
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"
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 320px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={product.id} />
|
||||
</div>
|
||||
</div>
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
className={
|
||||
"sm:col-span-4 [grid-row:span_6] " +
|
||||
// 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
|
||||
// explicit start column, later ones auto-flow right after
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "sm:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "sm:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+26
-9
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings } from "../lib/payload";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { hasActiveDiscountCode } from "../lib/discountServer";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
|
||||
@@ -18,10 +20,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods, shipping] = await Promise.all([
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField, wishlistEnabled] = await Promise.all([
|
||||
getCartTrustBadges(),
|
||||
getShippingMethods(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
hasActiveDiscountCode(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
|
||||
// The cart doesn't ask which shipping method the shopper wants yet
|
||||
@@ -38,13 +44,24 @@ export default async function CartPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CartContent
|
||||
trustBadges={trustBadges}
|
||||
shippingCost={defaultShipping?.price ?? 0}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
shippingSettings={shipping}
|
||||
/>
|
||||
<RelatedProducts />
|
||||
{/* Suspense required — CartContent uses useSearchParams() (?code=
|
||||
auto-apply, see its own comment) which opts any consumer into
|
||||
client-side rendering unless wrapped. fallback={null}: the cart
|
||||
itself is entirely client-rendered from localStorage anyway
|
||||
(see CartContent's own productsLoading handling), so there's no
|
||||
meaningful server-rendered content this would flash away from. */}
|
||||
<Suspense fallback={null}>
|
||||
<CartContent
|
||||
trustBadges={trustBadges}
|
||||
shippingCost={defaultShipping?.price ?? 0}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
shippingSettings={shipping}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
showDiscountField={showDiscountField}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} wishlistEnabled={wishlistEnabled} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
|
||||
|
||||
// Wraps a plain street-address input with a DHL DataFactory suggestion
|
||||
// dropdown. Completely invisible/inert when the tenant doesn't have
|
||||
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
|
||||
// route just returns an empty suggestions array in that case (see
|
||||
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
|
||||
// plain text input with no dropdown ever appearing, same "no half-built UI
|
||||
// when a feature is off" convention as WishlistButton/searchEnabled.
|
||||
//
|
||||
// Styling duplicates FormField's classes (defined locally in
|
||||
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
|
||||
// component usable on its own.
|
||||
export function AddressAutocomplete({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onSelectSuggestion,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
}: {
|
||||
label: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
|
||||
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
|
||||
setSuggestions(data.ok ? data.suggestions : []);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Delayed so a click on a suggestion below (which itself fires a
|
||||
// blur first) still registers before the dropdown unmounts.
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
|
||||
onSelectSuggestion(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
{s.street}
|
||||
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
|
||||
// Loaded once at module scope (not per-render) — same reasoning as any
|
||||
// other client-side SDK singleton. Never called at all in test mode
|
||||
// (mounted conditionally below), so an unset publishable key there is
|
||||
// harmless.
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function getStripe(): Promise<Stripe | null> {
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
clientSecret: string;
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
/** Where /checkout/verarbeitung sends the customer once payment is
|
||||
* confirmed — "checkout" (default) clears the cart/draft and lands on
|
||||
* /bestellbestaetigung, exactly like today. "account" is used by the
|
||||
* account order detail page's "Zahlungsart ändern" flow (an existing,
|
||||
* already-confirmed order — nothing to clear, and /bestellbestaetigung
|
||||
* would be the wrong destination): lands back on that same order's
|
||||
* page instead. See VerarbeitungContent.tsx's own branching on this. */
|
||||
returnContext?: "checkout" | "account";
|
||||
};
|
||||
|
||||
// Rendered by CheckoutContent once /api/checkout returns
|
||||
// `requiresPayment: true` (Kreditkarte/PayPal) — see
|
||||
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
|
||||
// this point (status 'pending_payment'); this step only collects/confirms
|
||||
// the actual payment, it doesn't create anything. Also reused as-is by
|
||||
// the account order detail page's payment-method-switch flow (see
|
||||
// returnContext above) — the Stripe collection UI itself is identical
|
||||
// either way, only the post-payment destination differs.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
|
||||
if (testMode) {
|
||||
return (
|
||||
<TestPaymentButtons
|
||||
orderNumber={orderNumber}
|
||||
orderId={orderId}
|
||||
providerReference={providerReference ?? ""}
|
||||
returnContext={returnContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handlePay(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
// Redirect-based (PayPal always redirects; cards may need a
|
||||
// 3-D-Secure redirect too) — confirmation itself is never trusted
|
||||
// client-side, see /checkout/verarbeitung's own comment. `if_required`
|
||||
// would skip the redirect for methods that don't need one, but the
|
||||
// return_url page's polling handles both cases identically either way,
|
||||
// so there's no benefit to branching here.
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
// number) — a redirect on success/pending never returns here at all.
|
||||
if (confirmError) {
|
||||
setError(confirmError.message ?? "Die Zahlung konnte nicht bestätigt werden.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handlePay} className="flex flex-col gap-4">
|
||||
<PaymentElement />
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({
|
||||
orderNumber,
|
||||
orderId,
|
||||
providerReference,
|
||||
returnContext,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
providerReference: string;
|
||||
returnContext: "checkout" | "account";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function confirm(paymentStatus: "paid" | "failed") {
|
||||
setSubmitting(paymentStatus);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/webhooks/stripe/test-confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId, providerReference, paymentStatus }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Testzahlung fehlgeschlagen.");
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-amber-500 bg-amber-50 p-4">
|
||||
<p className="text-sm font-semibold text-amber-800">PAYMENT_TEST_MODE aktiv — kein echtes Stripe-Konto verbunden.</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("paid")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-green-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "paid" ? "Wird bestätigt…" : "Testzahlung erfolgreich"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("failed")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-red-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "failed" ? "Wird bestätigt…" : "Testzahlung fehlgeschlagen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-2
@@ -2,7 +2,8 @@ import type { Metadata } from "next";
|
||||
import { CheckoutContent } from "./components/CheckoutContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings } from "../lib/payload";
|
||||
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart.
|
||||
export const metadata: Metadata = {
|
||||
@@ -15,21 +16,33 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const [shippingMethods, paymentMethods, trustBadges, shippingSettings] = await Promise.all([
|
||||
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, kleinunternehmer, session] = await Promise.all([
|
||||
getShippingMethods(),
|
||||
getShippingCountries(),
|
||||
getPaymentMethods(),
|
||||
getCartTrustBadges(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getSessionCustomer(),
|
||||
]);
|
||||
// Full profile (incl. saved address) only fetched when a session exists
|
||||
// — pre-fills Card 1 for a returning customer instead of leaving it blank.
|
||||
const profile = session ? await getCustomerProfile(session.token) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CheckoutContent
|
||||
shippingMethods={shippingMethods}
|
||||
shippingCountries={shippingCountries}
|
||||
paymentMethods={paymentMethods}
|
||||
trustBadges={trustBadges}
|
||||
shippingSettings={shippingSettings}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
customerEmail={session?.customer.email ?? null}
|
||||
savedProfile={profile}
|
||||
/>
|
||||
<TrustRow />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { clearDiscount } from "../../lib/discount";
|
||||
import { clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_TIMEOUT_MS = 15000;
|
||||
|
||||
// The Payment Element's return_url target (see PaymentStep.tsx) — reached
|
||||
// after a card confirms client-side or a PayPal redirect completes.
|
||||
// Neither of those is trustworthy proof of payment on its own (see
|
||||
// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
|
||||
// redirect looks identical to success from here) — this page polls the
|
||||
// order's actual `paymentStatus`, which only the webhook-driven
|
||||
// confirm-payment endpoint ever sets, and only promotes the pending
|
||||
// sessionStorage snapshot to the confirmed one once that's true.
|
||||
export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
// "account" — the payment-method-switch flow on an existing order's
|
||||
// account page (see PaymentStep.tsx's own returnContext comment).
|
||||
// Defaults to "checkout" for a bare/missing param, same as before this
|
||||
// branch existed.
|
||||
const isAccountContext = searchParams.get("context") === "account";
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNumber) return;
|
||||
startedAt.current = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/checkout/status?orderNumber=${encodeURIComponent(orderNumber!)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!data.ok) {
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
if (isAccountContext) {
|
||||
// Nothing to clear — this order was already placed (as
|
||||
// Überweisung) and confirmed long before this switch, there's
|
||||
// no cart/discount/draft snapshot involved. Land back on the
|
||||
// same order instead of /bestellbestaetigung, which would
|
||||
// read as a brand-new purchase.
|
||||
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
// Patch in the real instrument (Kreditkarte/PayPal) now
|
||||
// that it's known — the pending snapshot was written at
|
||||
// checkout submission time with the neutral "Online-
|
||||
// Zahlung" placeholder, before the customer had actually
|
||||
// picked one on the Payment Element.
|
||||
const snapshot = JSON.parse(pending);
|
||||
if (data.paymentMethodTitle) snapshot.paymentMethodTitle = data.paymentMethodTitle;
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Same private-browsing fallback as everywhere else this
|
||||
// sessionStorage snapshot is written — /bestellbestaetigung
|
||||
// has its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
clearCheckoutDraft();
|
||||
dispatchAuthChanged();
|
||||
router.push("/bestellbestaetigung");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "failed" || data.status === "cancelled") {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
|
||||
setState("timeout");
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col flex-1 items-center justify-center gap-6 py-24 px-[var(--layout-padding-x)] text-center">
|
||||
{state === "polling" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung wird bestätigt…
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.</p>
|
||||
</>
|
||||
)}
|
||||
{state === "timeout" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das dauert etwas länger
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail — du musst hier nicht warten.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
{isAccountContext
|
||||
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
|
||||
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
|
||||
</p>
|
||||
<Link
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Status konnte nicht geladen werden
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { VerarbeitungContent } from "./VerarbeitungContent";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /checkout itself.
|
||||
export const metadata: Metadata = {
|
||||
title: "Zahlung wird bestätigt",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function VerarbeitungPage() {
|
||||
// useSearchParams (reading ?orderNumber=) requires a Suspense boundary
|
||||
// in the App Router — this page has no meaningful loading state of its
|
||||
// own beyond what VerarbeitungContent already renders.
|
||||
return (
|
||||
<Suspense>
|
||||
<VerarbeitungContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "@einfach-produktiv/invoicing";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// @react-pdf/renderer's PDFViewer renders into an <iframe> via direct DOM
|
||||
// access — it has to be excluded from the server render pass entirely
|
||||
// (ssr: false), unlike the HTML-string email previews elsewhere in this
|
||||
// app, which can render server-side fine since they're just
|
||||
// dangerouslySetInnerHTML.
|
||||
const PDFViewer = dynamic(() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer), { ssr: false });
|
||||
|
||||
// Same useLivePreview() mechanism as LiveEmailPreviewClient.tsx — connects
|
||||
// to the Payload admin's iframe via postMessage and updates `data` as the
|
||||
// admin edits company-settings fields, no save required. Renders through
|
||||
// the exact same InvoiceDocument component the real invoice PDF uses
|
||||
// (app/lib/invoicePdf.tsx), against a fixed sample order
|
||||
// (SAMPLE_INVOICE_ORDER) — there's no "current" real order to preview
|
||||
// against generically, same reasoning as the email-templates preview's
|
||||
// own SAMPLE_ORDER.
|
||||
export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialSettings: CompanySettings }) {
|
||||
const { data } = useLivePreview<CompanySettings>({
|
||||
initialData: initialSettings,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
|
||||
{/* kleinunternehmer isn't part of InvoiceSeller (it's snapshotted
|
||||
per-order, not read live off the seller — see invoicePdf.tsx's
|
||||
own comment) — merged onto the sample order here only, so an
|
||||
admin toggling the checkbox sees the §19 notice reflected live
|
||||
without this preview needing its own separate mechanism. */}
|
||||
<InvoiceDocument order={{ ...SAMPLE_INVOICE_ORDER, kleinunternehmer: data.kleinunternehmer }} seller={data} />
|
||||
</PDFViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { draftMode } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCompanySettings, type CompanySettings } from "../lib/payload";
|
||||
import { LiveCompanySettingsPreviewClient } from "./components/LiveCompanySettingsPreviewClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Firmendaten-Vorschau",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const FALLBACK: CompanySettings = {
|
||||
sellerName: "",
|
||||
legalForm: "sole-proprietorship",
|
||||
registerCourt: null,
|
||||
registerNumber: null,
|
||||
managingDirector: null,
|
||||
shareCapital: null,
|
||||
sellerStreet: "",
|
||||
sellerZip: "",
|
||||
sellerCity: "",
|
||||
sellerCountry: "",
|
||||
sellerEmail: "",
|
||||
emailFromName: null,
|
||||
emailFromAddress: null,
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
|
||||
// gated on Draft Mode actually being enabled, unlike email-templates'
|
||||
// preview: this data includes a real bank IBAN/address once filled in,
|
||||
// not just marketing email copy, so this page must not render for an
|
||||
// unauthenticated visitor who happens to find the URL. Unlike
|
||||
// email-templates there's no draft/published distinction in the data
|
||||
// itself (company-settings has no content-versioning concept, it's just
|
||||
// the current row) — the initial fetch is the same live data
|
||||
// getCompanySettings() always returns, useLivePreview() takes over from
|
||||
// there as the admin edits fields.
|
||||
export default async function CompanySettingsPreviewPage() {
|
||||
const draft = await draftMode();
|
||||
if (!draft.isEnabled) notFound();
|
||||
const initialSettings = (await getCompanySettings()) ?? FALLBACK;
|
||||
return <LiveCompanySettingsPreviewClient initialSettings={initialSettings} />;
|
||||
}
|
||||
+41
-18
@@ -3,12 +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 md+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake). */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1_0_0] min-w-0 relative z-10">
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
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
|
||||
@@ -19,8 +27,15 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from md+, stacked with a horizontal divider below md */}
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6 md:gap-0 w-full">
|
||||
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">
|
||||
|
||||
{/* Caveat script text with signature positioned below */}
|
||||
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
|
||||
@@ -51,28 +66,31 @@ export function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider — horizontal full-width line below md, vertical
|
||||
gold line beside the author bio from md+ */}
|
||||
<div className="bg-brand w-full h-px md:w-[2px] md:h-24 md:mx-6 shrink-0" />
|
||||
{/* Divider — horizontal full-width line below lg:, vertical
|
||||
gold line beside the author bio from lg: */}
|
||||
<div className="bg-brand w-full h-px lg:w-[2px] lg:h-24 lg:mx-6 shrink-0" />
|
||||
|
||||
<div
|
||||
className="text-bg-white font-normal whitespace-nowrap md:shrink-0"
|
||||
className="text-bg-white font-normal whitespace-nowrap lg:shrink-0"
|
||||
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
|
||||
>
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
<p>Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from md+ only
|
||||
(that overlap trick has nothing to blend into once stacked);
|
||||
plain full-width photo below the text on Mobile. */}
|
||||
{/* 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.4_0_0] md:-ml-48"
|
||||
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
@@ -80,11 +98,16 @@ export function About() {
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 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 — md+ only */}
|
||||
<div className="hidden md: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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
|
||||
@@ -17,6 +18,10 @@ export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
numericId,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
@@ -24,16 +29,39 @@ export function AddToCartButton({
|
||||
* ProductSpotlight passes the actual CMS-selected spotlight product's id
|
||||
* explicitly, since that can now be a different product. */
|
||||
productId?: string;
|
||||
/** Payload's real numeric product id (`product.numericId`) — only used to
|
||||
* scope a NotifyMeForm signup once out of stock, never for the cart/
|
||||
* checkout path itself (that stays on the slug `productId` above). */
|
||||
numericId: number;
|
||||
/** Product-level — only meaningful when `variants` is empty, same split as
|
||||
* AddToCartInlineButton. */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap. */
|
||||
maxQty?: number | null;
|
||||
/** Optional — same shape/semantics as AddToCartInlineButton's own
|
||||
* `variants` prop; all three callers already fetch the full product
|
||||
* server-side, so this is just threaded straight through. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
addToCart(productId);
|
||||
if (disabled) return;
|
||||
addToCart(productId, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -55,33 +83,85 @@ export function AddToCartButton({
|
||||
// a solid bright-green button read as too loud here. `border` (width) is
|
||||
// added here too since `base` has none by default, unlike
|
||||
// AddToCartInlineButton's own base which already carries a plain border.
|
||||
const stateClasses = added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
const stateClasses = disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
// currentlyOutOfStock has no branch here — that state renders
|
||||
// NotifyMeForm instead of this button entirely (see below).
|
||||
const displayLabel = limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. */}
|
||||
<span className="relative grid">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
|
||||
</span>
|
||||
</button>
|
||||
// Low stock is deliberately NOT surfaced here as its own text line
|
||||
// (it used to be) — that made this block's height vary card-to-card
|
||||
// in every grid that renders this component, breaking equal-height
|
||||
// card alignment (ProductSpotlight's CTA row, RelatedProducts' grid).
|
||||
// The image-overlaid pill badge (ProductGrid.tsx/ProductSpotlight.tsx/
|
||||
// RelatedProducts.tsx, position: absolute, doesn't participate in
|
||||
// layout flow) is the one place this now shows, same as
|
||||
// Ausverkauft/discount already do. The variant-select suffix below is
|
||||
// unaffected — a native <select>'s own height doesn't vary with its
|
||||
// option text.
|
||||
<div className="flex flex-col gap-2">
|
||||
{variants.length > 0 && (
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
aria-label="Variante auswählen"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button — same reasoning as
|
||||
// AddToCartInlineButton's identical swap (see that file's own
|
||||
// comment on the `items-start` grid fix this relies on).
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. Now
|
||||
also reserves space for "Maximale Menge im Warenkorb" — the
|
||||
widest of the three wins regardless of which is showing. */}
|
||||
{/* whitespace-nowrap — inherited by every stacked span below. On a
|
||||
w-full button (e.g. this page's mobile layout), "Maximale Menge
|
||||
im Warenkorb" is long enough to wrap to two lines without this,
|
||||
and since every stacked span shares the same grid cell, that
|
||||
inflated the row height for whichever text is actually showing
|
||||
too (fixed 2026-07-24). */}
|
||||
<span className="relative grid whitespace-nowrap">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Maximale Menge im Warenkorb
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
// follow-up UI changes (e.g. swapping out this exact card) until after
|
||||
@@ -18,22 +19,58 @@ export const FEEDBACK_MS = 2000;
|
||||
*/
|
||||
export function AddToCartInlineButton({
|
||||
id,
|
||||
numericId,
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
id: string;
|
||||
/** Payload's real numeric product id (`product.numericId`) — only used to
|
||||
* scope a NotifyMeForm signup once out of stock, never for the cart/
|
||||
* checkout path itself (that stays on the slug `id` above). */
|
||||
numericId: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Product-level — only meaningful when `variants` is empty. A varianted
|
||||
* product's buyability is entirely per-variant instead (see below). */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap
|
||||
* (backorder allowed / inventory untracked). See lib/payload.ts's
|
||||
* maxPurchasableQty(). */
|
||||
maxQty?: number | null;
|
||||
/** Optional — products.variants (name + optional priceOverride + its own
|
||||
* outOfStock). When non-empty, a variant must be picked (defaults to the
|
||||
* first *in-stock* one, or just the first if all are out) before "add to
|
||||
* cart" is enabled — the selected variant's name is snapshotted onto the
|
||||
* cart line and, later, the order itself. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
// Whichever is actually being offered right now — the selected variant's
|
||||
// own flag if there are variants, otherwise the plain product-level one.
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
// How much of this exact (id, variant) line is already sitting in the
|
||||
// cart — capped adds mean "In den Warenkorb" must go disabled once this
|
||||
// reaches currentMaxQty, not just when the product is fully sold out.
|
||||
const qtyInCart = cart.find((i) => i.id === id && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
addToCart(id);
|
||||
if (disabled) return;
|
||||
addToCart(id, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -48,21 +85,64 @@ export function AddToCartInlineButton({
|
||||
// anymore (it's a trailing `!` now), so two conflicting utilities like
|
||||
// border-border/border-success both being present would silently race on
|
||||
// CSS source order instead of one cleanly winning.
|
||||
const stateClasses = added
|
||||
? "border-success bg-success-subtle"
|
||||
: "border-border hover:border-brand";
|
||||
const stateClasses = disabled
|
||||
? "border-border opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border-success bg-success-subtle"
|
||||
: "border-border hover:border-brand";
|
||||
|
||||
return (
|
||||
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
// Low stock isn't shown as its own text line here (see
|
||||
// AddToCartButton.tsx's identical comment on why) — the image-overlaid
|
||||
// pill badge (ProductGrid.tsx/RelatedProducts.tsx, position: absolute,
|
||||
// outside layout flow) is where this shows now, same as
|
||||
// Ausverkauft/discount already do.
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{variants.length > 0 && (
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
aria-label="Variante auswählen"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button. An out-of-stock card is taller
|
||||
// than its in-stock siblings now — ProductGrid.tsx/MerklisteGrid.tsx/
|
||||
// RelatedProducts.tsx all use `items-start` on their grid (not the
|
||||
// CSS Grid default `stretch`) specifically so that doesn't cascade
|
||||
// into pushing every other card's button down to match; an earlier
|
||||
// attempt reserved the extra height invisibly on every card
|
||||
// instead, which looked worse in practice (visible dead space
|
||||
// under in-stock cards' buttons).
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Mirror of ArrowRightIcon.tsx — same reasoning applies here: the Unicode
|
||||
// "←" (U+2190) previously used inline in CartContent.tsx's "Weiter
|
||||
// einkaufen" links isn't covered by the site's custom web fonts, so it
|
||||
// fell back to a system font whose vertical metrics sit visibly low on
|
||||
// mobile relative to the label text next to it. An SVG has no
|
||||
// font-fallback path — renders identically everywhere.
|
||||
export function ArrowLeftIcon() {
|
||||
return (
|
||||
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
|
||||
<path d="M15 6H1M1 6L4 3M1 6L4 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Replaces the Unicode "→" (U+2192) previously used inline in CTA links
|
||||
// (Tools.tsx, Blog.tsx, ProductGrid.tsx) — that glyph isn't covered by the
|
||||
// site's custom web fonts, so browsers fall back to a system font just for
|
||||
// that one character. The fallback's vertical metrics differ enough by
|
||||
// platform (confirmed: sat visibly low relative to the label text on a
|
||||
// Samsung Galaxy S22/Android Chrome, not reproducible in desktop Chromium)
|
||||
// that centering it via flex `items-center` alone isn't reliable across
|
||||
// devices. An SVG has no font-fallback path — it renders identically
|
||||
// everywhere. `currentColor` stroke follows the parent Link's own
|
||||
// text/hover color, same as every other icon in this codebase.
|
||||
// Arrowhead wings are deliberately short relative to the shaft (4.2 units
|
||||
// vs. a 14-unit shaft) — the first version used full-length 45° wings
|
||||
// (7 units), which read as a generic, oversized chevron next to the small
|
||||
// bold label text it sits beside.
|
||||
export function ArrowRightIcon() {
|
||||
return (
|
||||
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
|
||||
<path d="M1 6H15M15 6L12 3M15 6L12 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+109
-80
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getBlogPosts } from "../lib/payload";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
|
||||
export async function Blog() {
|
||||
const posts = await getBlogPosts(3);
|
||||
@@ -31,91 +32,119 @@ 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.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 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 gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.category}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
{/* Featured post — image on top through lg (1024px), side-by-side
|
||||
only from lg+ (was sm+, but that squeezed the text column too
|
||||
hard on tablet widths — same reasoning as the secondary posts'
|
||||
grid below, which already used this lg breakpoint).
|
||||
lg:min-h stretches the row taller on large screens (the image's
|
||||
lg:h-full and the text column's justify-between both follow) —
|
||||
without it the card's height was purely driven by the text
|
||||
column's content, which stays short regardless of viewport
|
||||
width. */}
|
||||
<RevealItem>
|
||||
{/* Whole card is the click target now (image + title + excerpt),
|
||||
not just the "Zum Beitrag" line — the hover-lift below already
|
||||
implied the whole card was clickable, so only the small arrow
|
||||
actually working was a mismatch. `group` drives the arrow
|
||||
row's hover color from anywhere on the card, not just when
|
||||
hovering that row itself. */}
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="group w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-12 lg:min-h-[26rem] transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-7 lg:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 lg:pr-4 lg:pl-4 lg: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.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-text-primary">
|
||||
<p
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 lg:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see Tools.tsx's comment on
|
||||
this same fix (→'s glyph baseline sits low next to text).
|
||||
Plain span now, not its own nested Link (the whole card
|
||||
is one Link already) — group-hover keeps the color
|
||||
change working from anywhere on the card. */}
|
||||
<span className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap group-hover:text-brand transition-colors">
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</RevealItem>
|
||||
|
||||
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{/* Secondary posts — image on top through lg (1024px), side-by-side
|
||||
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
|
||||
before lg, so each card's own available width through the
|
||||
640-1023px tablet range is too narrow for image+text side by
|
||||
side — was sm:grid-cols-5, squeezing the text column. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
|
||||
{secondary.map((post) => (
|
||||
<RevealItem
|
||||
key={post.id}
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 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 gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="font-semibold text-h-small leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
<RevealItem key={post.id}>
|
||||
<Link
|
||||
href={post.href}
|
||||
className="group block border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 29vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={post.href}
|
||||
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="font-semibold text-h-small leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Plain span, not its own nested Link — see the featured
|
||||
post's own comment above. */}
|
||||
<span className="flex items-center gap-1 font-bold text-body whitespace-nowrap group-hover:text-brand transition-colors">
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</RevealItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function buildCategoryHref(active: string[], category: string): string {
|
||||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||||
}
|
||||
|
||||
// Client-side navigation (not a plain <Link>) specifically so `isPending`
|
||||
// can gate the chips: rapid clicks used to fire multiple concurrent RSC
|
||||
// navigations with no guarantee they'd commit in the order they were
|
||||
// requested, so a slower, stale navigation could land after a newer one and
|
||||
// briefly show the wrong (sometimes empty) result. Disabling the chips
|
||||
// while a navigation is in flight makes that ordering race impossible —
|
||||
// only one navigation can ever be outstanding at a time.
|
||||
export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => startTransition(() => router.push(buildCategoryHref(activeCategories, category)))}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors disabled:opacity-60 disabled:pointer-events-none ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCart, pullServerCart } from "../lib/cart";
|
||||
|
||||
// Mirrors the local cart to the server whenever it changes, so a logged-in
|
||||
// customer's cart follows them across devices (see Customers.ts's `cart`
|
||||
// field). Renders nothing — mounted once in the root layout. Debounced
|
||||
// (not fired on every keystroke-equivalent quantity bump) and silently a
|
||||
// no-op when logged out — POST /api/account/cart 401s in that case, which
|
||||
// this component doesn't need to distinguish from success; there's simply
|
||||
// nothing to keep in sync yet.
|
||||
//
|
||||
// Push (local→server) and pull (server→local) are both handled here, but
|
||||
// deliberately asymmetric: push reacts to every local cart change (that's
|
||||
// this device's news to share), pull only runs on mount and on window
|
||||
// focus (see pullServerCart()'s own comment on why it's safe to call
|
||||
// repeatedly) — no polling interval, since "another device changed my
|
||||
// cart while this tab has been open and unfocused the whole time" is a
|
||||
// rare enough case not to justify a persistent timer.
|
||||
export function CartSync() {
|
||||
const cart = useCart();
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the mount-time fire — this would otherwise POST on every page
|
||||
// load even when nothing actually changed.
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fetch("/api/account/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cart }),
|
||||
}).catch(() => {
|
||||
// Best-effort — a failed sync just means the next cart change (or
|
||||
// the next login-time merge) tries again.
|
||||
});
|
||||
}, 800);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
pullServerCart();
|
||||
window.addEventListener("focus", pullServerCart);
|
||||
return () => window.removeEventListener("focus", pullServerCart);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ function StepCircle({ state, number }: { state: StepState; number: number }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
// Back to the shared border-border (reverted 2026-07-24 per feedback —
|
||||
// only the connector line below should be the darker #c4b8a0).
|
||||
<div className="flex size-9 items-center justify-center rounded-full border border-border font-bold text-body-sm text-text-muted">
|
||||
{number}
|
||||
</div>
|
||||
@@ -50,7 +52,7 @@ export function CheckoutSteps({ current }: { current: number }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{i < STEP_LABELS.length - 1 && <div className="h-px bg-border flex-1 mx-4 min-w-4" />}
|
||||
{i < STEP_LABELS.length - 1 && <div className="h-px bg-[#c4b8a0] flex-1 mx-4 min-w-4" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// A fully custom-styled dropdown — a native <select>'s trigger box can be
|
||||
// styled, but its open options popup is rendered by the browser/OS itself
|
||||
// and can't be reached with CSS at all (wrong font size, wrong colors, no
|
||||
// brand styling whatsoever). This renders both the trigger and the
|
||||
// options panel as plain HTML we control end to end. Originally built for
|
||||
// /konto/bestellungen's filters, promoted to a shared component so
|
||||
// checkout's country selects can use the same look (moved here 2026-07-30).
|
||||
export function CustomSelect({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
includeAllOption = true,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
/** Screen-reader label — also the trigger's placeholder text when
|
||||
* `includeAllOption` is true and nothing is selected. */
|
||||
label: string;
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** true (default): prepends a `{value: "", label}` "clear/show all"
|
||||
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
|
||||
* filters), where "nothing selected" is a real, meaningful state.
|
||||
* false: no pseudo-option, every real option is selectable and one is
|
||||
* always genuinely selected — the plain-select-replacement use case
|
||||
* (e.g. checkout's country picker), where there's no "clear" concept. */
|
||||
includeAllOption?: boolean;
|
||||
/** false (default): trigger shrinks to its content width from sm: up —
|
||||
* right for a row of compact filter dropdowns. true: trigger always
|
||||
* stays full width of its container — right for a form-field
|
||||
* replacement (e.g. checkout's country picker, alongside other w-full
|
||||
* inputs). */
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
allOptions.findIndex((o) => o.value === value),
|
||||
);
|
||||
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setHighlighted(selectedIndex);
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [open, highlighted]);
|
||||
|
||||
// Keyboard/focus-driven close: Tabbing (or programmatically moving
|
||||
// focus) away from the trigger+list entirely used to leave the panel
|
||||
// open forever — the mousedown-outside listener above only ever reacts
|
||||
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
|
||||
// focus is headed; null on some browsers when it lands outside the
|
||||
// document/on a non-focusable element, which should also close.
|
||||
function onBlur(e: React.FocusEvent) {
|
||||
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}
|
||||
|
||||
function select(index: number) {
|
||||
onChange(allOptions[index].value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlighted((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
select(highlighted);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex items-center justify-between gap-2 w-full ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
|
||||
fullWidth ? "px-4 py-3" : "px-3 py-2"
|
||||
} text-body-sm transition-colors outline-none ${
|
||||
value ? "border-brand text-text-primary" : "border-border text-text-muted"
|
||||
} hover:border-brand focus-visible:border-brand`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
// Without this, a mousedown on an <li> blurs the trigger button
|
||||
// first (li isn't focusable) — the resulting onBlur closes and
|
||||
// unmounts this list before the click event ever fires, so
|
||||
// nothing is ever selectable by mouse/touch.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
|
||||
>
|
||||
{allOptions.map((o, i) => (
|
||||
<li
|
||||
key={o.value || "__all__"}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => select(i)}
|
||||
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
|
||||
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
|
||||
} ${i === highlighted ? "bg-bg-muted" : ""}`}
|
||||
>
|
||||
{o.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Reveal } from "./Reveal";
|
||||
function Word({ children }: { children: string }) {
|
||||
return (
|
||||
<p
|
||||
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
|
||||
className="font-bold leading-normal text-text-primary text-[length:var(--divider-word-size)] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
@@ -28,26 +28,31 @@ export function Divider() {
|
||||
return (
|
||||
<Reveal
|
||||
delay={0.3}
|
||||
className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
|
||||
className="flex items-center justify-center flex-wrap gap-x-3 sm:gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
|
||||
>
|
||||
|
||||
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
|
||||
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
|
||||
arrow stranded alone on its own line — the arrows are always
|
||||
visible now (previously hidden below md: entirely to sidestep
|
||||
that exact problem), this fixes the root cause instead. */}
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
that exact problem), this fixes the root cause instead.
|
||||
gap-3/sm:gap-8 (not a flat gap-8): below 640px the words and
|
||||
icons already shrink via --divider-word-size/--divider-arrow-*
|
||||
(see globals.css), tightening the gaps too is what gets the
|
||||
whole phrase close to fitting on one row instead of each pair
|
||||
wrapping to its own line. */}
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Klarheit</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Fokus</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Leichtigkeit</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
the text-h2 words next to it, previously hard rem values that
|
||||
|
||||
@@ -18,8 +18,17 @@ export function Footer() {
|
||||
{/* Footer inner — max-width 1280px, centered */}
|
||||
<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 md */}
|
||||
<div className="flex flex-col md:flex-row items-center md:justify-between gap-6 md:gap-0 px-8 md:px-16 w-full">
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg:. Reverted here from an earlier sm: rename (2026-07-29's
|
||||
breakpoint migration): this row isn't the "grid arrives before
|
||||
the fluid floor" bug the sm: consolidation targets — logo +
|
||||
handle + 5 nowrap legal links (all shrink-0) simply don't fit
|
||||
in one row below ~1024px regardless of fluid scaling (measured:
|
||||
~746px combined natural width at a 666px viewport, causing a
|
||||
real horizontal page overflow, not just visual cramping).
|
||||
Same fixed-content-doesn't-fit category as Cart/Checkout/
|
||||
SectionTOC, kept at lg: for the same reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
<div className="flex items-center p-2 shrink-0">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Server Component — no "use client" needed. The consent gating itself
|
||||
// happens entirely client-side inside Klaro (KlaroConsentManager.tsx),
|
||||
// not via any React state here: Klaro's own contextual-consent DOM scan
|
||||
// (renderContextualConsentNotices, runs as part of Klaro.render()) finds
|
||||
// this iframe by its `data-name="google-maps"` attribute after mount,
|
||||
// blanks its `src` and inserts its own "Karte laden?" placeholder in
|
||||
// front of it until that service's consent is granted, then restores the
|
||||
// real `src`. See klaroConfig.ts's own comment on the "google-maps"
|
||||
// service this depends on — a `tracking-codes` row with that provider
|
||||
// must exist and be `active`, or Klaro never gates (and never un-blanks)
|
||||
// this iframe at all.
|
||||
//
|
||||
// `width`/`height` are real HTML attributes, not just the Tailwind aspect-
|
||||
// ratio wrapper below — Klaro reads `element.width`/`element.height` (the
|
||||
// DOM attributes) to size its own placeholder box before the real iframe
|
||||
// has rendered, so both need to be present even though the wrapper's
|
||||
// `aspect-[...]` class is what actually controls layout.
|
||||
export function GoogleMapsEmbed({
|
||||
src,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
/** A full Google Maps embed URL (Google Maps → Teilen → Karte einbetten
|
||||
* → "HTML kopieren" → the `src` attribute of that iframe), e.g.
|
||||
* "https://www.google.com/maps/embed?pb=...". */
|
||||
src: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative w-full aspect-[16/9] overflow-hidden rounded-md border border-border ${className ?? ""}`}>
|
||||
<iframe
|
||||
data-name="google-maps"
|
||||
src={src}
|
||||
title={title}
|
||||
width={800}
|
||||
height={450}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+124
-64
@@ -2,71 +2,138 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PopIn, Reveal } from "./Reveal";
|
||||
|
||||
// Shared between the plain (below lg:) and Reveal-wrapped (lg:+) render —
|
||||
// see the two call sites' own comment on why this needs two wrappers.
|
||||
function HeroImage() {
|
||||
return (
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is lg: (1024px) here, not the site-wide md:
|
||||
(768px) — a documented exception (see Gotcha in the figma-to-nextjs
|
||||
skill). At md:col-span-5 the text column was only ~320px at
|
||||
768-1023px viewports, too narrow for the heading/CTA/social-proof
|
||||
row (which wrapped to 3 cramped lines). Staying stacked full-width
|
||||
through the whole Tablet range and only splitting into the 5/7
|
||||
grid once there's real room (≥1024px) fixes that without touching
|
||||
the 5/7 ratio itself, which is fine once it has space. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
|
||||
{/* 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) 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 lg:order-none lg:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 lg: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 — forced break after "darf" below lg: (1024px),
|
||||
natural wrap from lg: up. Below lg: the Hero is stacked
|
||||
full-width and narrower per-viewport, where natural wrap
|
||||
produced an awkward break — force it after "darf" there via
|
||||
a responsive <br/> (visible by default, turned off at lg:+).
|
||||
From lg: up the 5/12 grid's text column wraps fine on its
|
||||
own, no forced break needed. */}
|
||||
{/* 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). 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-display">
|
||||
Produktivität darf<br className="lg:hidden" /> sich leicht anfühlen
|
||||
<span className="text-h1 lg:text-display">
|
||||
Verlier' dich nicht im mehr, verankere was zählt
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
land just after the Reveal's own 0.6s fade-up so it reads
|
||||
as a deliberate flourish, not simultaneous with the text.
|
||||
One-shot, not a looping pulse — continuous motion next to
|
||||
the primary CTA would be distracting rather than "cool". */}
|
||||
<PopIn className="text-display text-brand inline-block" delay={0.5}>
|
||||
the primary CTA would be distracting rather than "cool".
|
||||
Same text-h1 lg:text-display as the heading itself, so the
|
||||
dot scales down to match below lg:. */}
|
||||
<PopIn className="text-h1 lg:text-display text-brand inline-block" delay={0.5}>
|
||||
.
|
||||
</PopIn>
|
||||
</p>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-text-primary text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
{/* 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] 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 bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
href="https://einfach-produktiv.mk360.de/7-tage-klarheits-check"
|
||||
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"
|
||||
>
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
broken rather than intentional. Smaller fixed size below
|
||||
lg: instead, so the full phrase fits on one line within
|
||||
the column's width — text-h3's own 19px floor was still
|
||||
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">
|
||||
Jetzt starten
|
||||
</span>
|
||||
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
|
||||
<Image alt="" src="/icon-check.svg" fill sizes="26px" />
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
alongside text-h3. */}
|
||||
<div className="relative h-[0.8125rem] w-[1.0625rem] lg:h-[1.1875rem] lg:w-[1.5625rem] shrink-0">
|
||||
<Image alt="" src="/icon-check.svg" fill sizes="(min-width: 1024px) 26px, 17px" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
|
||||
{/* 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.
|
||||
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) => (
|
||||
@@ -79,46 +146,39 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body [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 lg: (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 lg:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. A small negative top margin below lg: pulls it up to
|
||||
slightly tuck under the text block (deliberately less than the
|
||||
social-proof row's height, so it never covers the avatars/text).
|
||||
Scoped to md:-only (mt-0 at base and again at lg:) — it's a
|
||||
Tablet-specific touch, not a permanent effect. No shadow: a
|
||||
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 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 sm:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 relative w-full aspect-[887/583] max-h-[16rem] md:max-h-[22rem] lg:max-h-none mt-0 md:-mt-6 lg:mt-0"
|
||||
className="hidden sm:block sm:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
<HeroImage />
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import "klaro/dist/klaro.css";
|
||||
import type { TrackingCode } from "../lib/payload";
|
||||
import { buildKlaroConfig } from "../lib/klaroConfig";
|
||||
import { loadTrackingCode } from "../lib/loadTrackingCode";
|
||||
|
||||
// Replaced a hand-rolled CookieBanner.tsx + useConsent.ts + TrackingScripts.tsx
|
||||
// with kiprotect/klaro (open source, self-hosted, npm install klaro) —
|
||||
// the custom banner only ever covered the accept/reject UI itself; Klaro
|
||||
// additionally brings a real per-service consent list, bundled German UI
|
||||
// translations, and (via `cookies`, not used here yet) cookie-deletion on
|
||||
// withdrawal — all things a hand-rolled version would have had to build
|
||||
// from scratch. See project memory for the fuller reasoning.
|
||||
//
|
||||
// Dynamically imported inside an effect (client-only, after mount) rather
|
||||
// than a static top-level import — Klaro touches `window`/`document` at
|
||||
// module-eval time in places, which isn't SSR-safe. The CSS import above
|
||||
// stays static (Next.js requires CSS imports to be static, not inside a
|
||||
// dynamic import()), paired with the "-no-css" JS build so the stylesheet
|
||||
// isn't loaded twice.
|
||||
//
|
||||
// `buildKlaroConfig`'s `styling` CSS-var overrides (brand colors, corner
|
||||
// position) only get Klaro so far — its bundled klaro.css still draws its
|
||||
// own border/shadow/spacing/typography, which read as an obviously
|
||||
// bolted-on library widget next to this site's own hand-designed
|
||||
// components (NewsletterModal.tsx, NotifyMeForm.tsx, etc.). The <style>
|
||||
// block below is a real CSS override pass against Klaro's actual DOM
|
||||
// classnames (confirmed against kiprotect/klaro's own src/scss/*.scss,
|
||||
// not guessed) — same "plain unlayered <style> tag wins the cascade"
|
||||
// approach the Payload admin's own AdminUIStyles.tsx uses, `!important`
|
||||
// added only where needed to beat klaro.css's own rules of otherwise-equal
|
||||
// specificity. Kills Klaro's default border entirely (replaced with a
|
||||
// soft shadow, matching this site's own card language) and restyles
|
||||
// every button to the site's actual rounded/weighted look instead of
|
||||
// Klaro's generic flat rectangles.
|
||||
function KlaroTheme() {
|
||||
return (
|
||||
<style>{`
|
||||
/* Blanket reset first — the previous pass only targeted
|
||||
.cookie-notice/.cookie-modal directly and a hard black border
|
||||
still showed up in production (screenshot-confirmed), so every
|
||||
descendant gets border/outline stripped here regardless of which
|
||||
specific Klaro rule was actually drawing it; the two rules below
|
||||
re-add exactly the borders this theme actually wants. */
|
||||
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
|
||||
/* Matches NewsletterModal.tsx's own backdrop color
|
||||
(bg-[rgba(134,134,134,0.9)]) instead of Klaro's default plain
|
||||
black at 50% — same dimming purpose, but consistent with how
|
||||
every other modal on this site already looks. .cm-bg is a
|
||||
*sibling* of the actual dialog box (.cm-modal.cm-klaro), both
|
||||
direct children of the full-screen .cookie-modal wrapper — see
|
||||
that wrapper's own comment below on why it must never be resized/
|
||||
transformed itself. */
|
||||
.klaro .cm-bg { background: rgba(134, 134, 134, 0.9) !important; }
|
||||
.klaro .cookie-notice, .klaro .cm-modal.cm-klaro {
|
||||
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
|
||||
border-radius: 18px !important;
|
||||
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
|
||||
}
|
||||
.klaro .cookie-notice .cn-body { padding: 22px 24px !important; }
|
||||
.klaro .cookie-notice p, .klaro .cm-modal.cm-klaro p {
|
||||
font-size: 14px !important;
|
||||
line-height: 1.6 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
.klaro h1, .klaro h2 {
|
||||
font-family: var(--font-lora), Georgia, serif !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
}
|
||||
.klaro .cm-btn {
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 10px 18px !important;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.klaro .cm-btn:hover { opacity: 0.88; }
|
||||
.klaro .cm-btn:active { transform: scale(0.97); }
|
||||
.klaro .cm-btn:focus-visible { outline: 2px solid #f6a701 !important; outline-offset: 2px; }
|
||||
/* Side-by-side by default (accept/decline in the notice, the
|
||||
modal's own bottom action row), only wrapping to a stacked
|
||||
layout once they genuinely don't fit — flex-wrap does this
|
||||
naturally at any width, no fixed breakpoint needed. Klaro's own
|
||||
.cn-ok/.cn-buttons rely on inline-block flow for the same
|
||||
result, which is fragile; this is the explicit version. */
|
||||
.klaro .cn-ok, .klaro .cn-buttons, .klaro .cm-buttons {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 10px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
.klaro .cn-buttons .cm-btn, .klaro .cm-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||||
/* The "Karte laden?" placeholder ContextualConsentNotice renders in
|
||||
place of a gated embed (GoogleMapsEmbed.tsx) — fills that embed's
|
||||
own aspect-ratio box, so this needs to look like a real card
|
||||
slot, not a bare unstyled div floating inside it. */
|
||||
.klaro.cm-as-context-notice {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.klaro .context-notice {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 4px !important;
|
||||
padding: 24px !important;
|
||||
background: #fffdf8 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.klaro .context-notice p { text-align: center !important; max-width: 32ch; }
|
||||
.klaro .context-notice .cm-buttons { display: flex !important; gap: 10px !important; margin-top: 6px !important; }
|
||||
.klaro a.cm-link, .klaro .cookie-notice a, .klaro .cookie-modal a {
|
||||
color: #a06b00 !important;
|
||||
text-decoration: underline !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.klaro select, .klaro .cm-list-input + label {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* ---- Settings modal service/purpose list — a real pass modeled on
|
||||
well-known CMPs like Cookiebot (roomier modal, clear row
|
||||
separators, bigger switches, muted-but-legible descriptions),
|
||||
not just inherited from the notice's own styling.
|
||||
|
||||
DOM structure (confirmed against kiprotect/klaro's own
|
||||
src/components/consent-modal.jsx — the FIRST version of this
|
||||
pass guessed wrong and broke the backdrop, see the incident note
|
||||
below):
|
||||
.cookie-modal full-viewport wrapper (position:
|
||||
fixed, 100%×100%) — MUST stay
|
||||
untouched; giving it its own
|
||||
transform/position (tried first)
|
||||
creates a new containing block
|
||||
for its position:fixed children,
|
||||
so .cm-bg below started
|
||||
positioning itself relative to
|
||||
THIS shrunken/centered box
|
||||
instead of the real viewport —
|
||||
the backdrop only covered a
|
||||
640px-wide column, screenshot-
|
||||
confirmed 2026-08-02.
|
||||
.cm-bg the dark backdrop (styled above)
|
||||
.cm-modal.cm-klaro the actual dialog box — every
|
||||
size/position override belongs
|
||||
HERE, not on .cookie-modal.
|
||||
.cm-header close (×) + h1.title + intro <p>
|
||||
.cm-body the service/purpose list
|
||||
.cm-footer > .cm-footer-buttons decline/accept/accept-all
|
||||
row (NOT .cm-buttons — that
|
||||
class belongs to the small
|
||||
notice/context-notice components
|
||||
only, an earlier pass wrongly
|
||||
reused it here too and the rule
|
||||
silently matched nothing). */
|
||||
.klaro .cm-modal.cm-klaro {
|
||||
position: fixed !important;
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
width: calc(100% - 40px) !important;
|
||||
max-width: 640px !important;
|
||||
max-height: 88vh !important;
|
||||
overflow: auto !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.klaro .cm-header { padding: 32px 36px 0 !important; }
|
||||
.klaro .cm-body { padding: 8px 36px !important; }
|
||||
.klaro .cm-footer { padding: 0 36px 32px !important; }
|
||||
.klaro .cm-header h1.title { font-size: 26px !important; margin-bottom: 4px !important; }
|
||||
.klaro .cm-header > p { color: #6b6b69 !important; margin-bottom: 0 !important; }
|
||||
.klaro .cm-header .hide {
|
||||
position: absolute !important;
|
||||
top: 28px !important;
|
||||
right: 28px !important;
|
||||
color: #6b6b69 !important;
|
||||
font-size: 20px !important;
|
||||
}
|
||||
.klaro .cm-header .hide:hover { color: #1a1a18 !important; }
|
||||
|
||||
/* Re-adds a deliberate row separator the blanket border-reset above
|
||||
removes — each purpose/service row genuinely benefits from one,
|
||||
unlike the notice's own outer border which just looked heavy. */
|
||||
.klaro .cm-switch-container {
|
||||
border-bottom: 1px solid #e5e0d8 !important;
|
||||
padding: 16px 4px !important;
|
||||
padding-left: 70px !important;
|
||||
}
|
||||
.klaro .cm-switch-container:last-child { border-bottom: 0 !important; }
|
||||
.klaro .cm-list-title { font-size: 15px !important; font-weight: 700 !important; }
|
||||
.klaro .cm-list-description { font-size: 13.5px !important; line-height: 1.5 !important; padding-top: 5px !important; max-width: 46ch; }
|
||||
.klaro p.purposes { font-size: 12px !important; font-weight: 600 !important; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px !important; }
|
||||
|
||||
/* Bigger, clearer toggle (44×24 vs. Klaro's cramped 50×30-but-
|
||||
visually-thin default) — brand amber when on, plain border-tone
|
||||
off, matching this site's own bg-brand/bg-border pairing. */
|
||||
.klaro .cm-switch, .klaro .cm-list-input { width: 44px !important; height: 24px !important; }
|
||||
.klaro .slider { border: 1px solid #e5e0d8 !important; }
|
||||
.klaro .slider::before { width: 18px !important; height: 18px !important; left: 3px !important; bottom: 2px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.15) !important; }
|
||||
.klaro .cm-list-input:checked + .cm-list-label .slider::before { transform: translateX(19px) !important; }
|
||||
|
||||
.klaro .cm-caret { color: #6b6b69 !important; }
|
||||
|
||||
/* Bottom action row (decline/accept/accept-all) — real classname
|
||||
is .cm-footer-buttons here, NOT .cm-buttons (see the DOM-
|
||||
structure note above); reads as a distinct footer, not just the
|
||||
last list item — separated + given the same side-by-side/wrap
|
||||
treatment as the notice's own buttons. */
|
||||
.klaro .cm-footer-buttons {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 10px !important;
|
||||
border-top: 1px solid #e5e0d8 !important;
|
||||
margin-top: 16px !important;
|
||||
padding-top: 20px !important;
|
||||
}
|
||||
.klaro .cm-footer-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||||
.klaro .cm-powered-by { display: none !important; }
|
||||
|
||||
/* Hides the "alle umschalten" toggle's own boilerplate description
|
||||
("Mit diesem Schalter können Sie alle Dienste aktivieren oder
|
||||
deaktivieren.") — redundant next to a plainly-labeled switch.
|
||||
CSS hide, not an empty translation override — Klaro's own t()
|
||||
treats an empty string as "translation missing" and renders a
|
||||
literal "[missing translation: ...]" debug string instead
|
||||
(confirmed via screenshot 2026-08-02), which is worse than the
|
||||
original text. */
|
||||
.klaro .cm-toggle-all .cm-list-description { display: none !important; }
|
||||
`}</style>
|
||||
);
|
||||
}
|
||||
|
||||
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
|
||||
// Kept in state (not just called once) so the persistent reopen button
|
||||
// below can call Klaro.show() itself, any time after the initial
|
||||
// accept/reject decision — a visitor must always be able to revisit
|
||||
// their choice, not just on first load.
|
||||
const [klaroModule, setKlaroModule] = useState<typeof import("klaro/dist/klaro-no-css") | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Nothing to ask consent for — don't even load/render Klaro. A cookie
|
||||
// banner with zero services to list would just be visual noise.
|
||||
if (codes.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
import("klaro/dist/klaro-no-css").then((Klaro) => {
|
||||
if (cancelled) return;
|
||||
const config = buildKlaroConfig(codes, loadTrackingCode);
|
||||
Klaro.setup(config);
|
||||
setKlaroModule(Klaro);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `codes` comes
|
||||
// from a server-fetched, 60s-ISR-cached layout prop; it's stable for
|
||||
// the lifetime of this component in practice, and Klaro.setup() isn't
|
||||
// meant to be called more than once per page load anyway.
|
||||
}, []);
|
||||
|
||||
if (codes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<KlaroTheme />
|
||||
{klaroModule && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => klaroModule.show()}
|
||||
aria-label="Cookie-Einstellungen öffnen"
|
||||
title="Cookie-Einstellungen"
|
||||
className="fixed bottom-5 left-5 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-bg-base shadow-md hover:border-brand transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="9" cy="9.5" r="1.1" fill="currentColor" />
|
||||
<circle cx="14" cy="8.5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="13.5" r="1.1" fill="currentColor" />
|
||||
<circle cx="10.5" cy="14.5" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="11" r="0.9" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { mapPayloadPage, type PayloadPage, type Page } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of a Pages document: the breadcrumb and the
|
||||
// layout blocks (the H1 now lives in the first block, usually a "hero" —
|
||||
// see HeroBlock.ts) — same "editable subset only" scope as LivePostContent.tsx
|
||||
// (see that file's own comment). `testimonialsRef` blocks are skipped here
|
||||
// (see renderPageBlockSync's own comment) since they need an async fetch a
|
||||
// client component can't perform inline; editing that block still shows up
|
||||
// once the page is saved and reloaded outside the preview iframe.
|
||||
export function LivePageContent({ initialPage }: { initialPage: Page }) {
|
||||
const { data } = useLivePreview<PayloadPage>({
|
||||
initialData: initialPage as unknown as PayloadPage,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const page = data?.slug ? mapPayloadPage(data) : initialPage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">{page.title}</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14 flex flex-col gap-5">
|
||||
{page.layout.map(renderPageBlockSync)}
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { RichText } from "./RichText";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Wraps just the RichText body of a legal page (/agb, /datenschutz,
|
||||
// /impressum, /widerruf) for Payload Live Preview — those 4 pages' own
|
||||
// headings/sidebars/TOC are hardcoded per page, not sourced from
|
||||
// `page.title` at all, so `content` is the only field that actually
|
||||
// benefits from real-time editing preview. Falls back to the
|
||||
// server-fetched `initialContent` until a postMessage arrives, which only
|
||||
// happens at all while this page is open inside the Payload admin's Live
|
||||
// Preview iframe — ordinary visitors never mount this differently from a
|
||||
// plain <RichText>.
|
||||
export function LiveRichText({ initialContent, quoteLabel }: { initialContent: unknown; quoteLabel?: string }) {
|
||||
const { data } = useLivePreview<{ content: unknown }>({
|
||||
initialData: { content: initialContent },
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
return <RichText content={data.content} quoteLabel={quoteLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
import { mapPayloadTestimonial, type PayloadTestimonial, type Testimonial } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live Preview is document-scoped (Payload's admin has exactly one
|
||||
// testimonial open at a time), but this page renders a *grid* of several —
|
||||
// so unlike LiveRichText/LivePostContent (which map 1:1 to a single
|
||||
// document), this only swaps in the one testimonial currently being edited
|
||||
// (matched by id) and leaves the rest of the grid as initially fetched.
|
||||
// initialData starts empty since we don't know which of the `testimonials`
|
||||
// is open until the first postMessage arrives — acceptable because this
|
||||
// component only ever mounts inside the Payload admin's own preview
|
||||
// iframe (see the `isPreview` gate in todo-cards/newsletter/challenge's
|
||||
// page.tsx), never for ordinary site visitors.
|
||||
export function LiveTestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
|
||||
const { data } = useLivePreview<Partial<PayloadTestimonial>>({
|
||||
initialData: {},
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const merged =
|
||||
data.id !== undefined
|
||||
? testimonials.map((t) => (t.id === data.id ? mapPayloadTestimonial(data as PayloadTestimonial) : t))
|
||||
: testimonials;
|
||||
|
||||
return <TestimonialsGrid testimonials={merged} />;
|
||||
}
|
||||
+403
-98
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton, SearchOverlay } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
@@ -31,16 +35,21 @@ function smoothScrollTo(targetY: number) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Über Björn", href: "#ueber-bjoern" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
];
|
||||
|
||||
const anchorIds = navLinks
|
||||
.filter((l) => l.href.startsWith("#"))
|
||||
.map((l) => l.href.slice(1));
|
||||
// "Shop" becomes an in-page anchor to the homepage's ProductSpotlight
|
||||
// section (id="spotlight") instead of a real /shop navigation whenever
|
||||
// exactly 1 product is active — same reasoning as the other anchor links,
|
||||
// #werkzeuge/#ueber-bjoern already have (a full catalog grid is
|
||||
// degenerate UX with only 1 item to show). Passed down from
|
||||
// app/layout.tsx, which is the one place already fetching the product
|
||||
// catalog for this decision.
|
||||
function getNavLinks(singleActiveProduct: boolean) {
|
||||
return [
|
||||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Über mich", href: "/ueber-mich" },
|
||||
{ label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" },
|
||||
];
|
||||
}
|
||||
|
||||
// "Werkzeuge" also covers standalone tool/product pages that live under
|
||||
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
|
||||
@@ -51,7 +60,10 @@ const anchorIds = navLinks
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
|
||||
// /der-eine and /tasse-die-pause (both bespoke product detail pages, same
|
||||
// "own route, not nested" shape as /todo-cards) added here for the same
|
||||
// reason (2026-08-25).
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/7-tage-klarheits-check", "/newsletter", "/der-eine", "/tasse-die-pause", "/lebensuhr", "/3x3-system"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -66,6 +78,129 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
// Account icon — no Figma source exists for this yet (added outside the
|
||||
// normal Figma-first workflow, see the assistant's own project notes on
|
||||
// why: without it there was no reachable way to log in at all once the
|
||||
// cart was empty and no recent order existed — /checkout's own login
|
||||
// toggle never even renders in that state, see CheckoutContent.tsx's
|
||||
// early "Warenkorb ist leer" return). Fetches auth state client-side via
|
||||
// /api/account/me rather than through the server-rendered layout — this
|
||||
// component's parent (app/layout.tsx) is otherwise static/ISR-cacheable,
|
||||
// and reading the session cookie there (next/headers' cookies()) would
|
||||
// force the entire site into per-request dynamic rendering just for this.
|
||||
// `loggedIn === null` is the brief "not checked yet" state on first paint.
|
||||
function AccountLink() {
|
||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
fetch("/api/account/me")
|
||||
.then((res) => setLoggedIn(res.ok))
|
||||
.catch(() => setLoggedIn(false));
|
||||
}
|
||||
checkAuth();
|
||||
// Navbar lives in the root layout and never unmounts across
|
||||
// navigations, so this effect only ever runs once on its own —
|
||||
// router.refresh() (called after login/logout) re-fetches Server
|
||||
// Component data but doesn't re-run an already-mounted Client
|
||||
// Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by
|
||||
// every login/logout call site (see dispatchAuthChanged() in
|
||||
// ../lib/auth) so this stays in sync without a hard reload.
|
||||
window.addEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
}, []);
|
||||
|
||||
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
{/* -translate-y-0.5 — the glyph's own bounding box centers fine
|
||||
mathematically, but the round head (light, isolated) versus the
|
||||
wide shoulders (heavier, at the bottom) reads as optically
|
||||
bottom-heavy next to the cart icon, sitting visibly lower.
|
||||
Nudged up to match (fixed 2026-07-24). */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7 text-text-primary -translate-y-0.5" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
{/* Only real "am I logged in?" signal on the site outside /konto
|
||||
itself — same brand-colored underline language as the desktop
|
||||
nav links' active-state indicator, so it reads as consistent
|
||||
rather than a new visual idiom. */}
|
||||
{loggedIn && <span aria-hidden className="absolute bottom-1 h-[2px] w-4 bg-brand rounded-full" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Wishlist icon + count badge — only rendered by the caller when
|
||||
// `wishlistEnabled` (CompanySettings). Visible at every width, alongside
|
||||
// Search/Account/Cart — all 4 stay full 44px touch targets even on the
|
||||
// smallest phones (shrinking them was tried and reverted: it made them
|
||||
// hard to hit accurately). The logo shrinks further below 375px instead
|
||||
// to make room — confirmed via an actual Playwright viewport sweep down
|
||||
// to 320px that this fits without wrapping/overflow (see git history).
|
||||
function WishlistLink() {
|
||||
const { count } = useWishlist();
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevCountRef = useRef(0);
|
||||
// Same guard as CartLink's own — useWishlist() starts from an empty
|
||||
// cached/SSR-safe list and only fills in the real count once its own
|
||||
// fetch resolves client-side, so without this the badge pulsed on every
|
||||
// page load/reload the instant that first real count arrived, not just
|
||||
// on an actual add/remove during the session.
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
prevCountRef.current = count;
|
||||
return;
|
||||
}
|
||||
if (count !== prevCountRef.current) {
|
||||
setPulse(true);
|
||||
const t = setTimeout(() => setPulse(false), 350);
|
||||
prevCountRef.current = count;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/konto/merkliste"
|
||||
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 18"
|
||||
className={"h-6 w-6 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={
|
||||
"absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white transition-transform duration-300 " +
|
||||
(pulse ? "scale-125" : "scale-100")
|
||||
}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Cart icon + count badge — traced from the Figma Navbar/Default component's
|
||||
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
|
||||
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
|
||||
@@ -77,6 +212,12 @@ function CartLink() {
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevVisibleRef = useRef(0);
|
||||
// useCartCount's getServerSnapshot is always 0 (see cart.ts) so hydration
|
||||
// always transitions 0 -> the real count on first render — without this
|
||||
// guard that transition alone satisfied "visibleCount > prevVisibleRef"
|
||||
// and pulsed the badge on every single page load/reload, not just an
|
||||
// actual in-session cart change.
|
||||
const hasMountedRef = useRef(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Held back by pendingCount while a ball is mid-flight, so the badge only
|
||||
@@ -91,6 +232,11 @@ function CartLink() {
|
||||
}, [registerCartIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
prevVisibleRef.current = visibleCount;
|
||||
return;
|
||||
}
|
||||
if (visibleCount > prevVisibleRef.current) {
|
||||
setPulse(true);
|
||||
const t = setTimeout(() => setPulse(false), 350);
|
||||
@@ -145,15 +291,30 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
export function Navbar({
|
||||
singleActiveProduct,
|
||||
wishlistEnabled,
|
||||
searchEnabled,
|
||||
}: {
|
||||
singleActiveProduct: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
searchEnabled: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [newsletterOpen, setNewsletterOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const navLinks = useMemo(() => getNavLinks(singleActiveProduct), [singleActiveProduct]);
|
||||
const anchorIds = useMemo(
|
||||
() => navLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.slice(1)),
|
||||
[navLinks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
@@ -197,7 +358,7 @@ export function Navbar() {
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname]);
|
||||
}, [pathname, anchorIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
@@ -216,7 +377,7 @@ export function Navbar() {
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [anchorIds]);
|
||||
|
||||
// Close on viewport resize past the structural breakpoint, so the drawer
|
||||
// never lingers open behind the (now visible) desktop nav. The hamburger
|
||||
@@ -264,6 +425,32 @@ export function Navbar() {
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Background scroll lock while the fullscreen panel is open — same
|
||||
// wheel/touchmove interception as NewsletterModal.tsx (see that
|
||||
// component's own comment on why this approach over overflow:hidden or
|
||||
// position:fixed on body). Needed now that the panel actually covers the
|
||||
// viewport instead of pushing page content down in normal flow.
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
|
||||
const isInsidePanel = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!panelRef.current?.contains(target);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", onWheel, { passive: false });
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
return () => {
|
||||
document.removeEventListener("wheel", onWheel);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const closeMobile = () => setMobileOpen(false);
|
||||
|
||||
return (
|
||||
@@ -282,13 +469,27 @@ export function Navbar() {
|
||||
// position itself.
|
||||
<>
|
||||
<header
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
|
||||
// The mobile panel used to be a child of this element and needed the
|
||||
// header itself to be a flexible column that could grow for it — it's
|
||||
// now a fixed-position sibling instead (see that panel's own comment
|
||||
// on why), so this is back to a plain fixed-height bar.
|
||||
//
|
||||
// onClick here closes the drawer on any click that lands on the bar
|
||||
// itself (logo/cart/account/CTAs already call closeMobile() from
|
||||
// their own onClick, so this mainly covers clicking empty space in
|
||||
// the bar) — safe as a catch-all specifically because the panel is
|
||||
// a sibling, not a descendant, so a click inside the open drawer
|
||||
// never bubbles up to this handler. The hamburger's own onClick
|
||||
// stops propagation so toggling it open doesn't immediately get
|
||||
// undone by this same handler.
|
||||
onClick={() => mobileOpen && setMobileOpen(false)}
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-bg-base/80 backdrop-blur-md"
|
||||
: "bg-bg-base"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
|
||||
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-2 min-[375px]:px-4 sm:px-8">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
|
||||
{/* Logo — real navigation to "/" from anywhere else; only when
|
||||
@@ -300,7 +501,7 @@ export function Navbar() {
|
||||
while leaving that page's content on screen. */}
|
||||
<Link
|
||||
href="/"
|
||||
className="shrink-0"
|
||||
className="shrink-0 w-[76px] min-[375px]:w-[130px] sm:w-[181px]"
|
||||
onClick={
|
||||
pathname === "/"
|
||||
? (e) => {
|
||||
@@ -312,11 +513,21 @@ export function Navbar() {
|
||||
: closeMobile
|
||||
}
|
||||
>
|
||||
{/* Fixed width/height are the real file dimensions (Next Image
|
||||
needs them for optimization/layout); the wrapping Link's own
|
||||
w-[112px] min-[375px]:w-[130px] sm:w-[181px] + h-auto here is
|
||||
what actually shrinks the rendered logo below 640px — there
|
||||
wasn't enough header width for 4 full-44px icons + hamburger
|
||||
otherwise (confirmed via an actual Playwright viewport sweep
|
||||
down to 320px, not assumed) — the icons themselves are never
|
||||
shrunk (see WishlistLink's own comment on why), so the logo
|
||||
is what gives on the very smallest phones instead. */}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="einfach produktiv"
|
||||
alt="einfach produktiv."
|
||||
width={181}
|
||||
height={61}
|
||||
className="w-full h-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
@@ -327,8 +538,27 @@ export function Navbar() {
|
||||
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. */}
|
||||
<nav className="hidden lg:flex items-center gap-12">
|
||||
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.
|
||||
|
||||
The gap itself, though, does scale fluidly — just on its own
|
||||
1024-1300px range (this nav's own floor/ceiling, only ever
|
||||
relevant while it's actually visible), not the site-wide
|
||||
640-1440px scale: clamp(1.5rem, 8.696vw - 4.065rem, 3rem) is
|
||||
gap-6 (1.5rem) at exactly 1024px, gap-12 (3rem) from 1300px
|
||||
up, and eases linearly between — the fixed 48px gap read as
|
||||
too wide right where the nav labels themselves have the
|
||||
least room (just above 1024px). */}
|
||||
<nav className="hidden lg:flex items-center gap-[clamp(1.5rem,8.696vw_-_4.065rem,3rem)]">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const underline = (
|
||||
@@ -378,8 +608,26 @@ export function Navbar() {
|
||||
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CartLink />
|
||||
<div className="flex items-center gap-1 min-[375px]:gap-2">
|
||||
{/* No gap between these — each is a full 44px touch target
|
||||
(never shrunk, even on the smallest phones — a smaller
|
||||
target was tried and reverted for being hard to tap
|
||||
accurately) with the icon centered inside, so even gap-0
|
||||
here still leaves visual space between the actual glyphs.
|
||||
All 4
|
||||
icons (Search/Account/Wishlist/Cart) are visible at every
|
||||
width, including true mobile — the 375px-and-below size
|
||||
step exists specifically so all 4 plus the hamburger fit
|
||||
without wrapping/overflow on the narrowest real phone
|
||||
viewports (checked at 320px). The outer gap-2 is what
|
||||
separates this group from the CTA-buttons/hamburger group
|
||||
that follows. */}
|
||||
<div className="flex items-center">
|
||||
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
{/* CTA buttons — inline from md (768px) up, i.e. through both
|
||||
"Collapsed-CTA" and full Desktop tiers */}
|
||||
@@ -397,10 +645,10 @@ export function Navbar() {
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -413,7 +661,10 @@ export function Navbar() {
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-panel"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileOpen((v) => !v);
|
||||
}}
|
||||
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="relative block h-4 w-6">
|
||||
@@ -438,81 +689,135 @@ export function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
|
||||
<div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
|
||||
mobileOpen ? "max-h-[30rem]" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
return link.href.startsWith("#") ? (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Fullscreen mobile panel — a sibling of <header>, deliberately NOT
|
||||
nested inside it (same reason as NewsletterModal, see the
|
||||
top-of-file comment: `mobileOpen` gives the header its own
|
||||
backdrop-blur, which would make it a new containing block for any
|
||||
`position: fixed` descendant and break the panel's fixed-to-
|
||||
viewport positioning). Circular clip-path reveal expanding from
|
||||
the hamburger's own corner (top-right) — the growing circle
|
||||
naturally sweeps toward the opposite corner (bottom-left) last,
|
||||
reading as the diagonal wipe this is going for without needing a
|
||||
literal diagonal clip polygon. `vmax` (not %) for the radius so
|
||||
full coverage holds regardless of viewport aspect ratio. */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||||
// Own (slower, gentler) transition for the exit — the shared
|
||||
// ease-out curve above is front-loaded (fast start, slow
|
||||
// finish), which reads great for the reveal but meant the
|
||||
// close shrank most of the way almost immediately, then
|
||||
// lingered on a barely-visible sliver — felt abrupt rather
|
||||
// than smooth. easeInOut plus a longer duration spreads the
|
||||
// shrink evenly instead.
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
|
||||
{navLinks.map((link, i) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
// Staggered fade+rise entrance, timed to land after the
|
||||
// clip-path reveal has visibly opened up — same
|
||||
// "fancy but restrained" register as the rest of this
|
||||
// codebase's motion usage (Reveal.tsx et al.), not a
|
||||
// separate animation language just for this panel.
|
||||
const linkMotionProps = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
|
||||
};
|
||||
return link.href.startsWith("#") ? (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h-feature font-semibold text-text-primary w-fit"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
|
||||
className="flex flex-col gap-3 px-8 pb-16 pt-10"
|
||||
>
|
||||
{/* md:hidden — these two duplicate the inline CTA pair that's
|
||||
already visible in the header itself from md (768px) up (see
|
||||
"Trailing controls" above); only genuinely missing below
|
||||
that, where the inline pair is hidden and the panel is
|
||||
these buttons' only way to reach them. No login/account CTA
|
||||
here (removed — Nutzer-Entscheidung: that's already reachable
|
||||
via the account icon in the header itself, outside this
|
||||
panel, no need to duplicate it inside). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/3x3-system"
|
||||
onClick={closeMobile}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+123
-54
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
// Same lock icon + copy as /challenge's and /newsletter's EmailCapture —
|
||||
// unified across all newsletter-signup forms instead of each having its
|
||||
@@ -28,22 +31,56 @@ type NewsletterProps = {
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
title = "Kleine Impulse – große Wirkung",
|
||||
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
<section className="py-16 w-full">
|
||||
|
||||
{/* Outer section padding — same fluid horizontal padding as all other sections */}
|
||||
<div className="px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
{/* max-w-[1280px] mx-auto — the consistent default width for this
|
||||
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
|
||||
section (same 1280px cap Footer.tsx already uses) and every
|
||||
other narrow-viewport-friendly section on the site. Previously
|
||||
unbounded here, so on wide desktop viewports this card stretched
|
||||
edge-to-edge while /lebensuhr's version stayed centered and
|
||||
noticeably narrower — reported 2026-07-29. */}
|
||||
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
|
||||
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
|
||||
relying purely on items-center + the taller column's own
|
||||
height to create top/bottom breathing room) — the form column
|
||||
(input, checkbox+label, privacy note) can be as tall as or
|
||||
taller than the copy column depending on viewport width, so
|
||||
centering alone left no slack to distribute and the input/
|
||||
"Keine Werbung" note sat flush against the card's top/bottom
|
||||
edge. */}
|
||||
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space */}
|
||||
<div className="flex gap-8 items-start w-full md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from sm+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below sm: — side by
|
||||
side they squeezed the text into a narrow column on a phone
|
||||
(icon width + gap eating most of the card's inner width),
|
||||
wrapping awkwardly, and the same squeeze reappeared through
|
||||
the whole tablet range (640-1023px) once the outer card's own
|
||||
sm:flex-row already put this copy column next to the form —
|
||||
row layout for icon+text is only comfortable once there's
|
||||
real room, i.e. lg+. Left-aligned (not centered) from sm up —
|
||||
only true mobile keeps the centered treatment. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design */}
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
fluid token (floors to 40px below 768px, see globals.css),
|
||||
so pairing w-16 with the fixed h-[3.438rem] squished the
|
||||
icon to a 40:55 box on mobile instead of the SVG's native
|
||||
64:55.0096 (it has preserveAspectRatio="none", so it
|
||||
actually stretches to whatever box it's given — fixed
|
||||
2026-07-24). */}
|
||||
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
|
||||
<div className="-rotate-4 -scale-y-100">
|
||||
<Image
|
||||
@@ -51,7 +88,7 @@ export function Newsletter({
|
||||
src="/newsletter-icon.svg"
|
||||
width={64}
|
||||
height={55}
|
||||
className="w-16 h-[3.438rem] block"
|
||||
className="w-[4rem] h-[3.438rem] block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,56 +107,88 @@ 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">
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
{/* 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">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below md, side by side from md+ */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Consent checkbox — required since this signup's legal
|
||||
basis is consent (email marketing), not the "Ich achte
|
||||
auf deine Daten" trust note alone. Same wording/pattern
|
||||
as NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
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">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
{/* 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}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
|
||||
{/* Privacy note — same icon/copy/color as the other
|
||||
newsletter forms (see /challenge's EmailCapture). */}
|
||||
<p className="flex items-center gap-1.5 text-label text-[#888] font-normal leading-normal">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
{/* Consent checkbox — required since this signup's legal
|
||||
basis is consent (email marketing), not the "Ich achte
|
||||
auf deine Daten" trust note alone. Same wording/pattern
|
||||
as NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
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">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Privacy note — same icon/copy/color as the other
|
||||
newsletter forms (see /challenge's EmailCapture). */}
|
||||
<p className="flex items-center gap-1.5 text-label text-[#888] font-normal leading-normal">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Reveal>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "/icon-sparkle-wrapper.svg",
|
||||
title: "7 Tage. Ein Fokus.",
|
||||
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-checklist.png",
|
||||
@@ -34,6 +35,8 @@ 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, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
// touch input that would cause scrolling, instead of toggling
|
||||
@@ -141,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
// Capped narrower than the Desktop 75rem through the whole
|
||||
// 640-1023px Tablet band — this dialog is a modal, not a page
|
||||
// section, so at Tablet it should read as a compact centered
|
||||
// card, not stretch to near-full-viewport-width once stacked
|
||||
// single-column (see the flex-col/flex-row split below):
|
||||
// full-bleed-width + a single stacked photo/text column made
|
||||
// the photo huge and the text below it look lost/disconnected
|
||||
// (reported 2026-07-29, after first trying a lg:-gated
|
||||
// structural stack with no width cap). max-w-[75rem] only
|
||||
// takes over once the 2-column split itself starts at lg:.
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* sticky, not absolute — the dialog itself is the scrolling
|
||||
container (overflow-y-auto above), so an absolute-positioned
|
||||
child scrolls away with the rest of the content instead of
|
||||
staying pinned to the visible top-right corner (reported
|
||||
2026-07-29). sticky top-6 keeps it fixed to the scrolled
|
||||
viewport's top edge; ml-auto pushes it to the right within
|
||||
the dialog's normal block flow (sticky positioning doesn't
|
||||
use right-* the way absolute does); -mb-6 cancels its own
|
||||
height (size-6 = 1.5rem) so it doesn't push the modal-top
|
||||
content below it down — same visual overlap as the old
|
||||
absolute positioning, just still visible after scrolling. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
>
|
||||
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||||
{/* modal-top: photo + copy/form, stacked below lg: — paired with
|
||||
the dialog's own narrower max-w-[36rem] cap through Tablet
|
||||
(see above), so the stacked photo stays a reasonably-sized
|
||||
4:3 banner instead of blowing up to near-full-viewport-width. */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
|
||||
<Image
|
||||
src="/newsletter-modal-photo.jpg"
|
||||
alt="Notizbuch mit Kaffee und Stift"
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
sizes="(min-width: 1024px) 50vw, 36rem"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. */}
|
||||
<div className="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>
|
||||
|
||||
@@ -179,46 +206,75 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||||
Kleine Impulse – große Wirkung
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-primary">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
|
||||
</p>
|
||||
|
||||
<form className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
{status === "success" ? (
|
||||
<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-2 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`w-full bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{/* 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"}
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
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">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
items-start + a fixed icon bounding box (icons have different
|
||||
native proportions, e.g. the sparkle glyph isn't square) fixes
|
||||
it without needing the flip trick. */}
|
||||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
|
||||
{/* Same lg: exception as modal-top above, for the same narrower-
|
||||
dialog-width-through-Tablet reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isValidEmail } from "../lib/email";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
|
||||
/**
|
||||
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
|
||||
* is out of stock — lets a visitor leave their email to be notified once
|
||||
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
|
||||
* back" mail. Always shows the email input + submit control directly (no
|
||||
* extra click to reveal them).
|
||||
*
|
||||
* One input with the submit control embedded inside it (absolutely
|
||||
* positioned, not a layout sibling) — not a separate stacked button below
|
||||
* the input. A first version stacked input + full-width "Benachrichtigen"
|
||||
* button, which made an out-of-stock ProductCard.tsx visibly taller than
|
||||
* its in-stock siblings (two form rows vs. one button); shrinking that
|
||||
* stacked version's own padding was tried and reverted — the actual ask
|
||||
* was to keep every control's height untouched and make the CARD match
|
||||
* instead. Embedding the submit icon inside the input keeps this whole
|
||||
* control to exactly one row, given an explicit h-14 (3.5rem/56px) to
|
||||
* match AddToCartInlineButton's own "In den Warenkorb" button's actual
|
||||
* rendered height — that button isn't 56px from its py-3 padding alone,
|
||||
* its 1.875rem cart-icon image is the tallest thing in it, so matching
|
||||
* padding here wouldn't have matched height; a sold-out card now ends up
|
||||
* exactly as tall as an in-stock one.
|
||||
*/
|
||||
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!isValidEmail(email)) {
|
||||
setError("Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setStatus("submitting");
|
||||
try {
|
||||
const res = await fetch("/api/stock-notifications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, productId, variantName }),
|
||||
});
|
||||
const data: { ok: boolean; reason?: string } = await res.json();
|
||||
if (!data.ok) {
|
||||
setStatus("error");
|
||||
setError(data.reason || "Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5 w-full">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Bei Verfügbarkeit benachrichtigen"
|
||||
aria-label="E-Mail-Adresse für Benachrichtigung, sobald das Produkt wieder verfügbar ist"
|
||||
// h-14 (3.5rem/56px), not py-3 alone — AddToCartInlineButton's
|
||||
// own button isn't 56px tall because of its py-3 padding alone,
|
||||
// it's that plus its 1.875rem/30px cart-icon image, which is
|
||||
// taller than this input's own text line-height would be at
|
||||
// that same padding. Setting the height explicitly (rather than
|
||||
// trying to reverse-engineer a padding value that happens to
|
||||
// produce 56px for text-body-sm) is what actually matches it.
|
||||
className="w-full h-14 rounded-sm border border-border pl-3 pr-12 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
aria-label="Benachrichtigen"
|
||||
title="Benachrichtigen"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex size-8 items-center justify-center rounded-sm text-text-primary hover:text-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "submitting" ? "…" : <ArrowRightIcon />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PageBlock } from "../lib/payload";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { RichText } from "./RichText";
|
||||
import { Quote } from "./Quote";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Pages document's `layout` blocks field — structurally
|
||||
// parallel to RichText.tsx's own `blocks: {...}` converter map, but one
|
||||
// level up (full page sections, not inline Lexical nodes). Each block
|
||||
// component reuses the exact same visual treatment already hand-built on
|
||||
// /lebensuhr, /3x3-system, and /7-tage-klarheits-check, rather than
|
||||
// inventing new styling — this is what those pages' repeated patterns get
|
||||
// consolidated into for CMS-driven pages going forward.
|
||||
export async function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
|
||||
const rendered = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return <TestimonialsGrid key={block.id} testimonials={testimonials} />;
|
||||
}
|
||||
return renderPageBlockSync(block);
|
||||
})
|
||||
);
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
// Every block EXCEPT testimonialsRef (needs its own async data fetch,
|
||||
// which a client component can't await — see LivePageContent.tsx, which
|
||||
// uses this directly and simply skips that one block type in preview,
|
||||
// same "live-previewable subset" scope-cut LivePostContent.tsx already
|
||||
// makes for its own author-bio/"Weiterlesen" chrome).
|
||||
export function renderPageBlockSync(block: PageBlock): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
// Same "font-playfair clamp headline" treatment every hand-built page
|
||||
// (/lebensuhr, /3x3-system, /ueber-mich) used for its own H1 before
|
||||
// this block existed — see HeroBlock.ts's own comment on why the
|
||||
// headline moved out of Pages.title and into here.
|
||||
case "hero": {
|
||||
const headline = (
|
||||
<p
|
||||
className={
|
||||
block.image
|
||||
? "font-semibold text-[clamp(2rem,1.393rem+1.5vw,2.75rem)] text-text-primary leading-[1.15]"
|
||||
: "font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
}
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{block.headline}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-4 items-start">
|
||||
{/* Optional round portrait beside the headline — e.g. /ueber-mich's
|
||||
"Hallo, ich bin Björn." size-20 (80px) read as barely-there/
|
||||
easy to miss next to a full headline — bumped to size-32
|
||||
(128px) so it actually registers as a real photo, not a
|
||||
small decorative blob. */}
|
||||
{block.image ? (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative size-32 shrink-0 rounded-full overflow-hidden ring-1 ring-border">
|
||||
<Image alt="" src={block.image} fill sizes="128px" className="object-cover" />
|
||||
</div>
|
||||
{headline}
|
||||
</div>
|
||||
) : (
|
||||
headline
|
||||
)}
|
||||
{/* text-primary + text-h-emphasis, not text-muted body — same
|
||||
weight class as the homepage Hero's own subheading
|
||||
("Ich helfe dir..."), which a plain muted text-body read too
|
||||
quiet next to. */}
|
||||
{block.subline && (
|
||||
<p className="font-semibold text-h-emphasis text-text-primary leading-[1.4]">{block.subline}</p>
|
||||
)}
|
||||
{block.ctaLabel && block.ctaHref && (
|
||||
<Link
|
||||
href={block.ctaHref}
|
||||
className="inline-flex items-center gap-2 bg-brand hover:brightness-95 active:scale-[0.97] transition-all rounded-sm px-6 py-3 font-semibold text-body text-text-primary"
|
||||
>
|
||||
{block.ctaLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "richTextSection":
|
||||
return <RichText key={block.id} content={block.content} />;
|
||||
|
||||
case "quote":
|
||||
return <Quote key={block.id} label={block.label}>{block.text}</Quote>;
|
||||
|
||||
case "icon": {
|
||||
const SymbolIcon = SYMBOLIC_ICONS[block.icon];
|
||||
return SymbolIcon ? <div key={block.id}>{SymbolIcon()}</div> : null;
|
||||
}
|
||||
|
||||
case "image":
|
||||
if (!block.image) return null;
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-xl overflow-hidden bg-bg-muted">
|
||||
<Image alt={block.caption ?? ""} src={block.image} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-contain" />
|
||||
</div>
|
||||
{block.caption && <p className="text-body-sm text-text-muted text-center">{block.caption}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "pillList":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-wrap gap-2">
|
||||
{block.items.map((item) => (
|
||||
<span key={item.id} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checklistImage":
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{block.image && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={block.image} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<div key={block.id} className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{block.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "stepRow": {
|
||||
const icons = block.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{block.items.flatMap((item, i) => [
|
||||
<div key={item.id} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < block.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "testimonialsRef":
|
||||
// Needs an async fetch — handled by PageBlocks (server) above.
|
||||
// Skipped in the client-side live-preview renderer.
|
||||
return null;
|
||||
|
||||
case "ctaCard":
|
||||
return (
|
||||
<Link
|
||||
key={block.id}
|
||||
href={block.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{block.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{block.title}</p>
|
||||
{block.description && <p className="text-body-sm text-text-muted">{block.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
|
||||
default:
|
||||
// Unknown/malformed block — same "skip rather than crash" convention
|
||||
// RichText.tsx's own blocks use.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Same checkmark used by /lebensuhr's and /7-tage-klarheits-check's own
|
||||
// checklists.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Product, ProductBlock, PageBlock } from "../lib/payload";
|
||||
import { getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getTestimonials } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import { RichText } from "./RichText";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { renderPageBlockSync } from "./PageBlocks";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
|
||||
// Renders a Payload Products document's `layout` blocks field — same
|
||||
// "page sections, not inline Lexical nodes" shape as PageBlocks.tsx, which
|
||||
// this delegates to directly for every block type the two fields share
|
||||
// (richTextSection/stepRow/quote/image/icon/pillList/checklistImage/table/
|
||||
// ctaCard are the exact same Block configs, just registered a second time
|
||||
// on Products.layout — see that field's own comment in Products.ts). Only
|
||||
// `productHero` and `productPricingPanel` are product-specific, since they
|
||||
// need the live product/shipping/tax context that a generic content page
|
||||
// doesn't have.
|
||||
export async function ProductBlocks({ product }: { product: Product }) {
|
||||
const [shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const ctx: ProductBlockContext = { product, shipping, taxRate: effectiveTaxRate(product, defaultTaxRate), kleinunternehmer };
|
||||
|
||||
const rendered = await Promise.all(
|
||||
product.layout.map(async (block) => {
|
||||
// Needs its own async data fetch — same reason PageBlocks.tsx
|
||||
// handles this one block type in its own outer async map instead of
|
||||
// the sync switch below (renderProductBlock/renderPageBlockSync).
|
||||
if (block.blockType === "testimonialsRef") {
|
||||
const testimonials = await getTestimonials(block.page);
|
||||
if (testimonials.length === 0) return null;
|
||||
return (
|
||||
<div key={block.id} className="w-full px-[var(--layout-padding-x)] py-6">
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// productHero/productPricingPanel render their own full section
|
||||
// (edge-to-edge image, own Reveal, own padding rhythm) — matching
|
||||
// the hand-coded PDPs (todo-cards etc.) precisely needed each of
|
||||
// those, unlike a generic content block, so they opt out of this
|
||||
// shared wrapper entirely rather than fighting it.
|
||||
if (block.blockType === "productHero" || block.blockType === "productPricingPanel") {
|
||||
return <div key={block.id}>{renderProductBlock(block, ctx)}</div>;
|
||||
}
|
||||
return (
|
||||
<Reveal key={block.id} className="w-full px-[var(--layout-padding-x)] py-8 sm:py-12">
|
||||
{renderProductBlock(block, ctx)}
|
||||
</Reveal>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return <>{rendered}</>;
|
||||
}
|
||||
|
||||
type ProductBlockContext = {
|
||||
product: Product;
|
||||
shipping: Awaited<ReturnType<typeof getShippingSettings>>;
|
||||
taxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
};
|
||||
|
||||
function renderProductBlock(block: ProductBlock, ctx: ProductBlockContext): React.ReactNode {
|
||||
switch (block.blockType) {
|
||||
case "productHero":
|
||||
return <ProductHero {...ctx} body={block.body} />;
|
||||
case "productPricingPanel":
|
||||
return <ProductPricingPanel {...ctx} />;
|
||||
// Every other block type is identical to Pages.layout's own — same
|
||||
// Block config, reused a second time (see this file's own comment).
|
||||
default:
|
||||
return renderPageBlockSync(block as PageBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Matches der-eine/todo-cards' own hand-coded Hero.tsx exactly — same
|
||||
// section/grid/Reveal/padding structure, same edge-to-edge image with
|
||||
// hover-scale — so a block-driven PDP hero doesn't visually stand apart
|
||||
// from its hand-coded siblings. Only the body prose differs: it's this
|
||||
// block's own `body` richText instead of hardcoded JSX.
|
||||
function ProductHero({ product, shipping, taxRate, kleinunternehmer, body }: ProductBlockContext & { body: unknown }) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">
|
||||
<ProductName name={product.name} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p className="font-semibold text-h-page text-text-primary" style={{ fontFamily: "var(--font-playfair)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && (
|
||||
<p className="font-semibold text-h3 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.subline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{body ? <RichText content={body} /> : null}
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
</div>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-8 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[3/2] rounded-md overflow-hidden"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Matches todo-cards' own hand-coded Pricing.tsx exactly — see that
|
||||
// file's own comment on why this closing panel looks the way it does.
|
||||
function ProductPricingPanel({ product, shipping, taxRate, kleinunternehmer }: ProductBlockContext) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const fullyOutOfStock =
|
||||
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
|
||||
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
|
||||
<div className="group relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 410px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
{product.subline && <p className="text-body-sm text-text-primary">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
numericId={product.numericId}
|
||||
outOfStock={fullyOutOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
import { AddToCartInlineButton } from "./AddToCartInlineButton";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { ProductName } from "./ProductName";
|
||||
import type { Product } from "../lib/payload";
|
||||
|
||||
// Single shared card markup for every product grid (ProductGrid,
|
||||
// RelatedProducts, MerklisteGrid) — these three used to each duplicate
|
||||
// this JSX independently and had drifted (different image aspect ratios,
|
||||
// a "Mehr erfahren" link present on some but not others, purchased/
|
||||
// out-of-stock badges only on some). The title itself is now the card's
|
||||
// only link (product.href, when set) — no separate "Mehr erfahren" CTA
|
||||
// line, which is also what makes the card more compact than before.
|
||||
// No "use client" — plain enough (no hooks/browser APIs of its own) to
|
||||
// render from both ProductGrid's Server Component and RelatedProducts'/
|
||||
// MerklisteGrid's Client Components.
|
||||
export function ProductCard({
|
||||
product,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
wishlistEnabled,
|
||||
wishlistRevealOnHover = false,
|
||||
topLeftBadge,
|
||||
belowPrice,
|
||||
className = "",
|
||||
}: {
|
||||
product: Product;
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
/** See WishlistButton's own doc: true for any grid where an unprompted
|
||||
* heart on every card would read as noise (ProductGrid, RelatedProducts);
|
||||
* false (default) for /konto/merkliste, where every card is already
|
||||
* wishlisted. */
|
||||
wishlistRevealOnHover?: boolean;
|
||||
/** Overrides the default Ausverkauft/discount pill — used by
|
||||
* MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render
|
||||
* no badge at all. */
|
||||
topLeftBadge?: ReactNode;
|
||||
/** Rendered directly under the price — e.g. ProductGrid's delivery-time
|
||||
* line. Omitted entirely by grids that don't have anything to say there
|
||||
* (RelatedProducts, MerklisteGrid), rather than every card carrying a
|
||||
* fixed slot for content only one of the three actually has. */
|
||||
belowPrice?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
|
||||
|
||||
return (
|
||||
// 6 direct grid rows (image / header / price / belowPrice / lowstock /
|
||||
// button) instead of the old flex column — a plain flex-1 spacer only
|
||||
// pins the button to the bottom of THIS card, it can't make the price
|
||||
// row start at the same Y as a row sibling whose header block is a
|
||||
// different height (e.g. a 2-line vs 1-line subline). grid-template-
|
||||
// rows: subgrid pulls in the row-tracks each caller's grid container
|
||||
// already declares (see ProductGrid.tsx's own comment) so every row is
|
||||
// genuinely shared/max'd across the row of cards — dynamic, no
|
||||
// line-clamp/truncation needed on the subline. gap-3 is used for every
|
||||
// row gap including after the image (was pt-4/1rem there before —
|
||||
// 0.25rem less, accepted for one consistent grid gap instead of mixed
|
||||
// margin/gap spacing).
|
||||
<div
|
||||
className={`group bg-bg-base border border-border rounded-md overflow-hidden grid [grid-template-rows:subgrid] [grid-row:span_6] gap-3 transition-transform duration-300 hover:-translate-y-1 ${className}`}
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
{/* Link wraps only the image, not the whole header — WishlistButton
|
||||
below is its own interactive element and sits as a sibling, not
|
||||
nested inside this Link (nested interactive elements are both
|
||||
invalid HTML and would fire navigation on a wishlist click). */}
|
||||
{product.href ? (
|
||||
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
)}
|
||||
{topLeftBadge !== undefined ? (
|
||||
topLeftBadge
|
||||
) : fullyOutOfStock ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 items-start w-full px-5">
|
||||
{product.categories.length > 0 && (
|
||||
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
|
||||
)}
|
||||
{product.href ? (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
<ProductName name={product.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
<ProductName name={product.name} />
|
||||
</p>
|
||||
)}
|
||||
{product.subline && <p className="text-body-sm text-text-muted w-full">{product.subline}</p>}
|
||||
</div>
|
||||
|
||||
<p className="flex items-baseline gap-1.5 px-5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
|
||||
<div className="px-5">{belowPrice}</div>
|
||||
|
||||
{/* Always rendered, text conditional — an empty row still occupies
|
||||
its shared track (0-height unless a row sibling needs it), same
|
||||
reasoning as the price/header rows above. */}
|
||||
<p className="text-label font-bold text-warning px-5">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
|
||||
|
||||
{/* self-end pins the button to the bottom of this row even though
|
||||
the row itself is minmax(0,1fr) (see grid-auto-rows) and can be
|
||||
taller than the button — replaces the old flex-1 spacer. */}
|
||||
<div className="self-end px-5 pb-5 w-full">
|
||||
<AddToCartInlineButton
|
||||
id={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.variants}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const SWIPE_THRESHOLD_PX = 40;
|
||||
|
||||
/**
|
||||
* Main image + thumbnail strip, swappable on click. `image` is always
|
||||
* slide zero, `gallery` (Products.gallery, optional/empty for most
|
||||
* products) fills in the rest. Renders as a single plain image with no
|
||||
* thumbnail row at all when `gallery` is empty — the common case, and
|
||||
* exactly today's pre-gallery appearance, no behavior change for any
|
||||
* product that hasn't opted in. Plain divs/buttons, no carousel package —
|
||||
* same "no charting/UI-library dependency for a simple case" reasoning as
|
||||
* OrderQueueWidget.tsx's own OrderSparkline.
|
||||
*/
|
||||
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
|
||||
const slides = [image, ...gallery];
|
||||
const [current, setCurrent] = useState(0);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
|
||||
if (slides.length <= 1) {
|
||||
return (
|
||||
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
|
||||
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function next() {
|
||||
setCurrent((c) => (c + 1) % slides.length);
|
||||
}
|
||||
function prev() {
|
||||
setCurrent((c) => (c - 1 + slides.length) % slides.length);
|
||||
}
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}
|
||||
function handleTouchEnd(e: React.TouchEvent) {
|
||||
if (touchStartX.current === null) return;
|
||||
const delta = e.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (delta <= -SWIPE_THRESHOLD_PX) next();
|
||||
else if (delta >= SWIPE_THRESHOLD_PX) prev();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3.5 w-full">
|
||||
<div
|
||||
className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base touch-pan-y"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
aria-label="Vorheriges Bild"
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
aria-label="Nächstes Bild"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="absolute bottom-3.5 right-3.5 rounded-full bg-text-primary/60 px-2.5 py-1 text-label font-semibold text-white tabular-nums">
|
||||
{current + 1} / {slides.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
{slides.map((src, i) => (
|
||||
<button
|
||||
key={src + i}
|
||||
type="button"
|
||||
onClick={() => setCurrent(i)}
|
||||
aria-label={`Bild ${i + 1} anzeigen`}
|
||||
aria-current={i === current}
|
||||
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
|
||||
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Image src={src} alt="" fill sizes="76px" className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Every hand-written PDP headline styles a trailing "." in brand color
|
||||
// (e.g. der-eine/Pricing.tsx's "Der Eine<span className="text-brand">.</span>"),
|
||||
// matching how every product's own Payload `name` is actually written
|
||||
// ("Der Eine.", "Die Pause.", "Einfach anfangen."). Anywhere `product.name`
|
||||
// is rendered as plain text instead (cards, cart line items, the one
|
||||
// Payload-driven PDP hero) silently lost that styling — this centralizes
|
||||
// the same trailing-period split so it can't drift per call site again.
|
||||
export function ProductName({ name }: { name: string }) {
|
||||
if (!name.endsWith(".")) return <>{name}</>;
|
||||
return (
|
||||
<>
|
||||
{name.slice(0, -1)}
|
||||
<span className="text-brand">.</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,59 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getSpotlightProduct, getShippingSettings } from "../lib/payload";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
type LexicalTextNode = { type: "text"; text: string; format?: number };
|
||||
type LexicalNode = { type: string; text?: string; format?: number; children?: LexicalNode[] };
|
||||
|
||||
// Minimal Lexical JSON → JSX converter for spotlightText — deliberately NOT
|
||||
// the full block-oriented RichText.tsx (Posts/LegalPages' renderer, with
|
||||
// its own heading/paragraph spacing and image/quote blocks). This teaser
|
||||
// only ever needs paragraphs of plain text with optional bold spans
|
||||
// (Lexical's text `format` bitmask: 1 = bold), rendered inline to match
|
||||
// this section's own <p className="text-body ..."> treatment rather than
|
||||
// picking up article-level block styling.
|
||||
function renderInline(node: LexicalNode, key: number): ReactNode {
|
||||
if (node.type === "text") {
|
||||
const textNode = node as LexicalTextNode;
|
||||
const format = typeof textNode.format === "number" ? textNode.format : 0;
|
||||
const bold = (format & 1) === 1;
|
||||
// 2 = italic in Lexical's format bitmask — was silently dropped here,
|
||||
// so italic spans (e.g. Tasse "Die Pause"'s spotlightText) rendered as
|
||||
// plain text on the homepage despite showing correctly in the admin's
|
||||
// richText editor.
|
||||
const italic = (format & 2) === 2;
|
||||
let el: ReactNode = textNode.text;
|
||||
if (italic) el = <em>{el}</em>;
|
||||
if (bold) el = <strong>{el}</strong>;
|
||||
return <Fragment key={key}>{el}</Fragment>;
|
||||
}
|
||||
if (node.type === "linebreak") return <br key={key} />;
|
||||
// Any other inline node (link, etc.) — render its text children without
|
||||
// the wrapper itself; this teaser has no use for an actual <a> here.
|
||||
return (node.children ?? []).map((child, i) => renderInline(child, i));
|
||||
}
|
||||
|
||||
function SpotlightText({ content, fallback }: { content: unknown; fallback: string }) {
|
||||
const root = (content as { root?: { children?: LexicalNode[] } } | null)?.root;
|
||||
if (!root?.children?.length) {
|
||||
return <p className="text-body text-text-body whitespace-pre-line">{fallback}</p>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{root.children.map((paragraph, i) => (
|
||||
<p key={i} className="text-body text-text-body">
|
||||
{(paragraph.children ?? []).map((child, j) => renderInline(child, j))}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product teaser for whichever product is marked `spotlight` in Payload
|
||||
@@ -22,27 +72,57 @@ import { formatPrice, discountPercent } from "../lib/format";
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const [product, shipping] = await Promise.all([getSpotlightProduct(), getShippingSettings()]);
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
const image = product.spotlightImage || product.image;
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// Same "any vs. every" split as ProductGrid.tsx.
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
|
||||
|
||||
return (
|
||||
<section 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">
|
||||
// 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 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
|
||||
at 640px, but a 380px-wide image + text squeezed into the rest of
|
||||
a tablet-width viewport (768-1023px) read as cramped/too wide.
|
||||
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
|
||||
and lg keeps that stacked card centered and narrower than the
|
||||
tablet viewport instead of stretching to fill it — true mobile
|
||||
(below sm) stays unconstrained since the viewport itself is
|
||||
already narrow there. */}
|
||||
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
|
||||
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
{fullyOutOfStock ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && product.spotlightShowWishlist && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,27 +134,46 @@ export async function ProductSpotlight() {
|
||||
>
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
{product.spotlightText || product.description}
|
||||
</p>
|
||||
<SpotlightText content={product.spotlightText} fallback={product.descriptionText} />
|
||||
{/* MwSt./Versand disclosure on its own line, not crammed into
|
||||
the price row itself — same reasoning as todo-cards'
|
||||
Pricing.tsx (identical text, same narrow-column risk). */}
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
a plain conditional line instead of a reserved-height slot. */}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
{/* items-start at sm: — without it, the default cross-axis
|
||||
stretch makes "Mehr erfahren" grow to match
|
||||
AddToCartButton's own height whenever that one gets taller
|
||||
(e.g. the low-stock hint line pushing its content down), so
|
||||
a plain text link visibly ends up "fatter" than the actual
|
||||
button next to it. */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-start gap-3 w-full sm:w-auto">
|
||||
{/* No className override — the section's bg is bg-bg-base now
|
||||
(matches Tools/Blog above/below), same as AddToCartButton's
|
||||
own default styling/ring-offset, so no override is needed
|
||||
here. */}
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} />
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
|
||||
// Same visual language as RichText.tsx's Quote converter (Caveat script +
|
||||
// brand divider) — extracted here so PageBlocks.tsx (and any future hand-
|
||||
// written page) can reuse it instead of each page defining its own copy,
|
||||
// which is what /lebensuhr, /3x3-system, and /ueber-mich each did before
|
||||
// this existed. `label` was dropped in that extraction (this component had
|
||||
// no prop for it at all) — QuoteBlock.label was set in the admin but never
|
||||
// actually reached the page, unlike RichText.tsx's own local Quote, which
|
||||
// this now matches.
|
||||
export function Quote({ label, children }: { label?: string | null; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-2">
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p className="text-text-primary text-[1.75rem] leading-[1.1] flex-1" style={{ fontFamily: "var(--font-caveat)" }}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// Native size of the hand-drawn underline texture (icon-merke-dir-underline.png,
|
||||
// exported from the Figma "label-underline" node) — used as the SSR/pre-hydration
|
||||
// fallback width before the label's actual rendered width is measured.
|
||||
const UNDERLINE_NATIVE_WIDTH = 136;
|
||||
|
||||
// Blockquote "Merke dir:" label + sparkle icon + underline (see RichText.tsx's
|
||||
// "quote" case). Split out as its own client component because the underline
|
||||
// needs to be stretched to match the label's actual rendered width — a fixed
|
||||
// width only ever matched the one label length it was eyeballed against,
|
||||
// leaving the underline too short (overflowing labels) or too long (sparse-
|
||||
// looking short labels) for anything else.
|
||||
export function QuoteLabel({ label }: { label: string }) {
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const [underlineWidth, setUnderlineWidth] = useState(UNDERLINE_NATIVE_WIDTH);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = labelRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setUnderlineWidth(el.offsetWidth);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* object-fill (not cover) — the box's height stays fixed, only the
|
||||
width tracks the label, so the texture stretches horizontally to
|
||||
match rather than getting cropped. */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={UNDERLINE_NATIVE_WIDTH}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] h-[1.4375rem] object-fill pointer-events-none"
|
||||
style={{ width: underlineWidth }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,14 @@
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
// Plain fade, no y-translate — fixed 2026-07-24. Used to animate opacity
|
||||
// 0→1 *and* y 28→0 together ("fade up"), which read as the whole section
|
||||
// visibly hopping/jumping into place on top of the fade — one motion cue
|
||||
// too many. The fade alone is already a clear enough "this just appeared"
|
||||
// signal without the extra jump.
|
||||
const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
@@ -16,7 +21,7 @@ type RevealProps = {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
/** Fades a section up into place once, the first time it scrolls into view. */
|
||||
/** Fades a section into view once, the first time it scrolls into view. */
|
||||
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -25,7 +30,7 @@ export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={fadeUp}
|
||||
variants={fadeIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
{children}
|
||||
@@ -53,10 +58,10 @@ export function RevealGroup({ children, className }: { children: ReactNode; clas
|
||||
);
|
||||
}
|
||||
|
||||
/** Child item for use inside a RevealGroup — same fade-up motion, driven by the parent's stagger. */
|
||||
/** Child item for use inside a RevealGroup — same fade motion, driven by the parent's stagger. */
|
||||
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div className={className} variants={fadeUp}>
|
||||
<motion.div className={className} variants={fadeIn}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -115,8 +120,17 @@ export function PopIn({ children, className, delay = 0 }: RevealProps) {
|
||||
<motion.span
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
// animate, not whileInView — its only caller (Hero.tsx's brand dot)
|
||||
// sits above the fold, already visible on load, so there's no real
|
||||
// "scrolls into view" moment to gate on. whileInView's -80px viewport
|
||||
// margin also broke on some phones: the popIn variant's own "hidden"
|
||||
// state translates x:+140, and on a narrow mobile viewport that could
|
||||
// push the dot's pre-animation bounding box past the right edge —
|
||||
// IntersectionObserver then never reports it as visible, so
|
||||
// whileInView never fires and the dot stays stuck off-screen
|
||||
// (reported 2026-07-24: dot invisible on a real phone). Firing on
|
||||
// mount sidesteps that geometry entirely.
|
||||
animate="show"
|
||||
variants={popIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
|
||||
+336
-122
@@ -1,31 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
import { StepArrow } from "./StepArrow";
|
||||
import { STEP_ICONS, SYMBOLIC_ICONS } from "./icons/StepIcons";
|
||||
|
||||
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
|
||||
// Deliberately small and dependency-free (matches the project's existing
|
||||
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
|
||||
// side) rather than pulling in @payloadcms/richtext-lexical's full React
|
||||
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
|
||||
// the node types real content actually uses; add more only when a page
|
||||
// genuinely needs them.
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
// once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/
|
||||
// Video/Zitat, see payload/src/collections/Posts.ts), which the old
|
||||
// hand-rolled switch had no case for at all. extractHeadings()/headingId()
|
||||
// below are kept as an independent, minimal walk over the raw JSON (same
|
||||
// as before) — they only ever need to find h2 headings for SectionTOC and
|
||||
// never touch Blocks, no reason to route that through the new renderer too.
|
||||
|
||||
type LexicalNode = {
|
||||
type: string;
|
||||
children?: LexicalNode[];
|
||||
text?: string;
|
||||
format?: number;
|
||||
tag?: string;
|
||||
listType?: "bullet" | "number";
|
||||
fields?: { url?: string };
|
||||
};
|
||||
|
||||
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
|
||||
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
|
||||
const BOLD = 1;
|
||||
const ITALIC = 2;
|
||||
const UNDERLINE = 8;
|
||||
|
||||
function plainText(node: LexicalNode): string {
|
||||
if (node.type === "text") return node.text ?? "";
|
||||
return (node.children ?? []).map(plainText).join("");
|
||||
@@ -35,7 +31,13 @@ function plainText(node: LexicalNode): string {
|
||||
// "section-1" id from the leading number — immune to copy edits changing
|
||||
// the heading text later, unlike a text-derived slug. Anything else
|
||||
// (headings with no leading number) falls back to a plain slugify.
|
||||
function headingId(text: string): string {
|
||||
// Exported — the Impressum page renders some of its own headings outside
|
||||
// this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/
|
||||
// "Verantwortlich für den Inhalt" sections come straight from
|
||||
// company-settings, not the richText field, see app/impressum/page.tsx)
|
||||
// and needs the exact same id-assignment logic so its SectionTOC entries
|
||||
// actually match the ids those headings render with.
|
||||
export function headingId(text: string): string {
|
||||
const numbered = text.match(/^(\d+)\./);
|
||||
if (numbered) return `section-${numbered[1]}`;
|
||||
return text
|
||||
@@ -64,139 +66,351 @@ export function extractHeadings(content: unknown): TOCSection[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
|
||||
// Payload upload relations resolve to the full media doc when fetched at
|
||||
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
|
||||
// already uses depth >= 2), or fall back to a bare id if not — only
|
||||
// render when actually populated.
|
||||
type MediaRef = { url?: string | null } | number | null | undefined;
|
||||
function mediaUrl(ref: MediaRef): string | null {
|
||||
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
switch (node.type) {
|
||||
case "linebreak":
|
||||
return <br key={key} />;
|
||||
case "text": {
|
||||
let el: ReactNode = node.text;
|
||||
const format = node.format ?? 0;
|
||||
if (format & BOLD) el = <strong key={key}>{el}</strong>;
|
||||
if (format & ITALIC) el = <em key={key}>{el}</em>;
|
||||
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node);
|
||||
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
|
||||
// link edge cases) — good enough for a "paste a link" editor field; a
|
||||
// URL that doesn't match either pattern just doesn't render rather than
|
||||
// guessing wrong.
|
||||
function toEmbedUrl(url: string): string | null {
|
||||
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
|
||||
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
|
||||
const vimeo = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
type IconBlockFields = { icon: string };
|
||||
type PillListBlockFields = { items: { id?: string | null; label: string }[] };
|
||||
type ChecklistImageBlockFields = { image: MediaRef; items: { id?: string | null; text: string }[] };
|
||||
type TableBlockFields = { labelHeader: string; valueHeader: string; rows: { id?: string | null; label: string; value: string }[] };
|
||||
type StepRowBlockFields = {
|
||||
items: { id?: string | null; icon: string; title: string; subtitle?: string | null; description: string }[];
|
||||
};
|
||||
type CtaCardBlockFields = { eyebrow: string; title: string; description?: string | null; href: string };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same checkmark as PageBlocks.tsx's own — see that file's comment.
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-6">
|
||||
{/* Label/icon/underline are optional — if empty, only the divider +
|
||||
quote text render. The quote itself is never optional, just this
|
||||
framing around it. */}
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A factory, not a module-level constant — needs to close over each
|
||||
// call's own `quoteLabel` (the native "quote" converter reads it). Server
|
||||
// Components can render multiple posts concurrently in the same process,
|
||||
// so a shared module-level variable set right before rendering would be
|
||||
// a real race condition, not just a style choice.
|
||||
function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
return ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => (
|
||||
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
|
||||
),
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node as unknown as LexicalNode);
|
||||
return (
|
||||
<Tag
|
||||
key={key}
|
||||
id={Tag === "h2" ? headingId(text) : undefined}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
},
|
||||
list: ({ node, nodesToJSX }) => {
|
||||
const ListTag = node.listType === "number" ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag
|
||||
key={key}
|
||||
className={
|
||||
"flex flex-col gap-2 text-body text-text-body " +
|
||||
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
||||
}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key)}
|
||||
</p>
|
||||
);
|
||||
// Lexical's default blockquote feature — used sitewide as a "Merke
|
||||
// dir:" pull-quote callout, per page-blog-detail's actual built Figma
|
||||
// frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
|
||||
// background card (an earlier version of this guessed one; the real
|
||||
// design has no background or padding at all, just a plain 3-column
|
||||
// row: label+underline, a full-height divider rule, then the quote
|
||||
// lines). Icon is the actual exported sparkle asset from that node
|
||||
// (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
|
||||
// "Merke dir:" label itself is generic/hardcoded here rather than
|
||||
// content-authored, since a blog post's own body text drives which
|
||||
// lines get quoted, not the label framing them — legal pages never
|
||||
// use blockquotes, so this styling is effectively blog-only in
|
||||
// practice despite living in the shared renderer.
|
||||
case "quote":
|
||||
return (
|
||||
<div key={key} className="relative flex items-start gap-6 w-full my-6">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
Merke dir:
|
||||
</span>
|
||||
},
|
||||
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
|
||||
link: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical auto-detects a typed-out URL/email as its own "autolink" node,
|
||||
// distinct from an editor-inserted "link" node — falls through to
|
||||
// Payload's unstyled default converter without this, which is why the
|
||||
// Impressum/AGB's typed-in-place mailto addresses rendered as plain
|
||||
// black text instead of matching every editor-inserted link.
|
||||
autolink: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical's native blockquote feature — used by every post written
|
||||
// before Blocks existed. Kept working exactly as before (own comment on
|
||||
// Posts.ts's `content` field editor config on why this stays enabled
|
||||
// alongside the new QuoteBlock) rather than migrating old content.
|
||||
quote: ({ node, nodesToJSX }) => (
|
||||
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
|
||||
),
|
||||
blocks: {
|
||||
image: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
{/* Hand-drawn underline image, not a plain bar — exported
|
||||
straight from the Figma node (label-underline). */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={136}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
{/* Lexical's real QuoteNode holds flat text/linebreak children
|
||||
directly, NOT nested paragraphs — pressing Enter inside a
|
||||
blockquote in the editor exits it into a new paragraph
|
||||
rather than adding a line within it (confirmed by reading
|
||||
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
|
||||
version of this case assumed nested-paragraph children,
|
||||
which only happened to work for this session's own
|
||||
hand-authored seed JSON — any blockquote actually typed in
|
||||
the CMS (Shift+Enter for a soft line break) rendered blank,
|
||||
since child.children was undefined on a plain text node. */}
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
);
|
||||
},
|
||||
imageGallery: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageGalleryBlockFields;
|
||||
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{images.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image
|
||||
alt=""
|
||||
src={mediaUrl(row.image)!}
|
||||
fill
|
||||
sizes="(min-width: 768px) 24rem, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={row.caption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as VideoEmbedBlockFields;
|
||||
const embedUrl = toEmbedUrl(fields.url);
|
||||
if (!embedUrl) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={fields.caption ?? "Video"}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
|
||||
// every native blockquote in the post) — new quotes going forward use
|
||||
// this instead of the native blockquote feature.
|
||||
quote: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as QuoteBlockFields;
|
||||
const lines = fields.text.split("\n");
|
||||
return (
|
||||
<Quote label={fields.label ?? undefined}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
// Same page-builder blocks as PageBlocks.tsx (Pages.layout) — see
|
||||
// that file's own comment on each block's visual treatment, mirrored
|
||||
// here field-for-field since it's the exact same Block config
|
||||
// registered a second time (Posts.content's BlocksFeature) so blog
|
||||
// posts can use them mid-article too. `testimonialsRef` is the one
|
||||
// page-builder block deliberately NOT included: it needs an async
|
||||
// data fetch, and this converter set is shared with LiveRichText's
|
||||
// client-side live-preview rendering (see blog's LivePostContent.tsx),
|
||||
// where an async component would break — PageBlocks.tsx only gets
|
||||
// away with it by awaiting outside the sync per-block switch, in its
|
||||
// own async server component.
|
||||
icon: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as IconBlockFields;
|
||||
const SymbolIcon = SYMBOLIC_ICONS[fields.icon];
|
||||
return SymbolIcon ? <div>{SymbolIcon()}</div> : null;
|
||||
},
|
||||
pillList: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as PillListBlockFields;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.items.map((item, i) => (
|
||||
<span key={item.id ?? i} className="text-body-sm text-text-muted bg-bg-muted rounded-full px-3 py-1">
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
checklistImage: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ChecklistImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-center w-full">
|
||||
{url && (
|
||||
<div className="relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden bg-bg-muted" style={{ minHeight: "16rem" }}>
|
||||
<Image alt="" src={url} fill sizes="(min-width: 1024px) 44vw, 100vw" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 w-full flex flex-col gap-4">
|
||||
{fields.items.map((item, i) => (
|
||||
<li key={item.id ?? i} className="flex items-start gap-3">
|
||||
<CheckIcon />
|
||||
<p className="text-body text-text-body">{item.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as TableBlockFields;
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-xl overflow-hidden w-full">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-brand">
|
||||
<th className="py-3 pl-6 pr-4 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.labelHeader}</th>
|
||||
<th className="py-3 pr-6 font-semibold text-body-sm text-text-primary uppercase tracking-wide">{fields.valueHeader}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.rows.map((row, i) => (
|
||||
<tr key={row.id ?? i} className={i % 2 === 1 ? "bg-bg-base/60" : undefined}>
|
||||
<td className="py-2.5 pl-6 pr-4 text-body text-text-body">{row.label}</td>
|
||||
<td className="py-2.5 pr-6 font-semibold text-body text-text-primary">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
stepRow: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as StepRowBlockFields;
|
||||
const icons = fields.items.map((item) => STEP_ICONS[item.icon]);
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{fields.items.flatMap((item, i) => [
|
||||
<div key={item.id ?? i} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{icons[i]?.()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-text-primary text-[1rem]">{item.title}</p>
|
||||
{item.subtitle && <p className="font-semibold text-[0.75rem] text-brand">{item.subtitle}</p>}
|
||||
<p className="text-[0.875rem] text-text-muted leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
</div>,
|
||||
i < fields.items.length - 1 ? (
|
||||
<div key={`arrow-${item.id ?? i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ctaCard: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as CtaCardBlockFields;
|
||||
return (
|
||||
<Link
|
||||
href={fields.href}
|
||||
className="group flex items-center justify-between gap-4 border border-border rounded-md px-6 py-5 hover:border-brand transition-colors"
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key);
|
||||
}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">{fields.eyebrow}</p>
|
||||
<p className="font-semibold text-body text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>{fields.title}</p>
|
||||
{fields.description && <p className="text-body-sm text-text-muted">{fields.description}</p>}
|
||||
</div>
|
||||
<svg viewBox="0 0 20 20" className="size-4 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1" fill="none" aria-hidden="true">
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function RichText({ content }: { content: unknown }) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
export function RichText({
|
||||
content,
|
||||
quoteLabel = "Merke dir:",
|
||||
}: {
|
||||
content: unknown;
|
||||
/** Label for any native blockquote's callout — defaults to "Merke dir:"
|
||||
* for callers that don't pass one (legal pages never use blockquotes,
|
||||
* so this only actually matters for blog posts). Pass "" to hide the
|
||||
* label/icon/underline for every native blockquote here. New content
|
||||
* should use the Zitat block instead, which carries its own label. */
|
||||
quoteLabel?: string;
|
||||
}) {
|
||||
const root = (content as { root?: { children?: unknown[] } })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root")}
|
||||
<LexicalRichText
|
||||
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
|
||||
converters={buildConverters(quoteLabel)}
|
||||
disableContainer
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { SearchResult } from "../api/search/route";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
|
||||
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
|
||||
// rendered as a <header> *sibling* instead of a descendant. Rendering it
|
||||
// inside <header> put it under the header's conditional `backdrop-blur-md`
|
||||
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
|
||||
// `backdrop-filter` makes its element a new containing block for
|
||||
// `position: fixed` descendants — the overlay's `fixed inset-0` then
|
||||
// resolved against the ~100px header instead of the viewport, clipping its
|
||||
// opaque background to that band while the input/results overflowed past
|
||||
// it, letting the page content underneath show through.
|
||||
export function SearchButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label="Suche öffnen"
|
||||
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
|
||||
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
|
||||
setResults(data.results ?? []);
|
||||
setLoading(false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const products = results.filter((r) => r.type === "product");
|
||||
const posts = results.filter((r) => r.type === "post");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
|
||||
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Produkte, Blogbeiträge…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
{/* X icon, not the old "Esc" text — the keyboard shortcut still
|
||||
works (see the Escape keydown handler above), this button is
|
||||
just the mouse/touch affordance, and a close icon reads
|
||||
faster than a text label at a glance. */}
|
||||
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 h-8 w-8 flex items-center justify-center text-text-muted hover:text-brand transition-colors">
|
||||
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
|
||||
<path d="M2 2L14 14M14 2L2 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
|
||||
{loading && <p className="text-body-sm text-text-muted">Suche…</p>}
|
||||
{!loading && query.trim().length >= 2 && results.length === 0 && (
|
||||
<p className="text-body-sm text-text-muted">Keine Treffer für „{query}“.</p>
|
||||
)}
|
||||
|
||||
{products.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
|
||||
{products.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
|
||||
{posts.map((r) => (
|
||||
<SearchResultRow key={r.id} result={r} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
href={result.href}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
|
||||
</div>
|
||||
<span className="text-body text-text-primary">{result.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -10,24 +10,12 @@ export type TOCSection = { id: string; title: string };
|
||||
// active state away from what was actually clicked.
|
||||
const CLICK_OVERRIDE_MS = 1000;
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — Impressum/Datenschutz put an extra card below this
|
||||
// in the same sidebar column, and if only this <nav> were sticky, the
|
||||
// card (a plain-flow sibling) would scroll away independently instead of
|
||||
// travelling with it. The caller wraps whatever the sidebar column
|
||||
// contains (this alone, or this + more) in `lg:sticky lg:top-32
|
||||
// lg:self-start` so the whole column moves as one unit.
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
// Shared between SectionTOC (desktop sidebar nav) and MobileSectionTOC
|
||||
// (below lg: collapsible accordion, added 2026-07-24) — both need the same
|
||||
// scroll-spy "active" state and click-override handling, just render it
|
||||
// completely differently, so the logic lives here once instead of being
|
||||
// duplicated per component.
|
||||
function useActiveSection(sections: TOCSection[]) {
|
||||
const [active, setActive] = useState<string>(sections[0]?.id ?? "");
|
||||
// Not state — read inside the IntersectionObserver callback without
|
||||
// needing to re-subscribe it on every click, and cleared by its own
|
||||
@@ -67,6 +55,29 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
}, CLICK_OVERRIDE_MS);
|
||||
}
|
||||
|
||||
return { active, handleClick };
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — every caller wraps this in its own `hidden lg:flex
|
||||
// ... lg:sticky lg:top-32 lg:self-start` div (Impressum/Datenschutz also
|
||||
// stack a second "Nachhaltigkeit" card below this in that same wrapper, so
|
||||
// the sticky behavior has to live on the wrapper for the two to travel
|
||||
// together as one unit — putting it on this <nav> instead would leave
|
||||
// that card behind as a plain-flow sibling scrolling past a now-fixed nav).
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -92,3 +103,44 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Below lg: only — a collapsible accordion instead of the sidebar nav
|
||||
// (which is `hidden` entirely below lg:, see SectionTOC's own comment on
|
||||
// why a real 2-column split doesn't fit there). Added 2026-07-24: these
|
||||
// legal pages had no on-page navigation aid at all on Mobile/Tablet, which
|
||||
// is exactly where scanning a long legal document by scrolling is hardest.
|
||||
// Native <details>/<summary> — no extra open/close state needed, and it
|
||||
// stays open after a click so jumping between sections doesn't require
|
||||
// reopening it each time. Render this as its own element in the page
|
||||
// (typically right after the heading, before the two-column content row),
|
||||
// not nested inside a parent that's itself `hidden lg:...` — that would
|
||||
// hide this too regardless of its own lg:hidden class.
|
||||
export function MobileSectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details className="lg:hidden w-full bg-bg-base border border-border rounded-md p-4 open:pb-2">
|
||||
<summary className="text-label font-semibold text-text-muted uppercase tracking-wide cursor-pointer select-none">
|
||||
Inhaltsübersicht
|
||||
</summary>
|
||||
<div className="flex flex-col gap-1 mt-3">
|
||||
{sections.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
onClick={() => handleClick(id)}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Shared "how it works" step connector — used by Challenge's, /todo-cards's,
|
||||
// and /newsletter's ("Impulse & Tipps") step sections. Used to be
|
||||
// /icon-arrow-connector.svg (a thin gray line+chevron) loaded via next/image;
|
||||
// replaced 2026-07-24 for two reasons that both needed an inline SVG to fix:
|
||||
// 1. It read as a faint gray line, not a real arrow, even after the
|
||||
// object-contain aspect-ratio fix — too thin/subtle at these sizes.
|
||||
// 2. Its color lives in a `var(--stroke-0, #C9C9C9)` CSS custom property
|
||||
// that's scoped to the SVG file's own document when loaded via <img
|
||||
// src>/next/image — un-recolorable from the host page's CSS. Inline SVG
|
||||
// sidesteps that entirely. Stroke color: tried brand orange, then
|
||||
// near-black, settled on the same light gray (#C9C9C9) the original
|
||||
// asset's own fallback used, per feedback the same day — just bolder
|
||||
// (strokeWidth 2.5 vs. the original's thin 1.5) and better-shaped.
|
||||
export function StepArrow({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" fill="none" aria-hidden="true" className={className}>
|
||||
<path
|
||||
d="M1 8H33M26 14.5L34.5 8L26 1.5"
|
||||
stroke="#C9C9C9"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import type { Testimonial } from "../lib/payload";
|
||||
|
||||
// Shared by /todo-cards, /newsletter, and /challenge — all three were
|
||||
// already pixel-identical (bg-muted filled card, no border, decorative
|
||||
// quote-mark, quote on top with flex-1 pushing the avatar/name row to the
|
||||
// bottom, hover-lift + avatar-scale), kept in sync deliberately as one
|
||||
// visual pattern across pages rather than each page's own (differing)
|
||||
// Figma spec for this one section. max-w-[1600px] matches Challenge's
|
||||
// testimonial container cap, the widest of the three original values —
|
||||
// a deliberate compromise so all three read consistently across viewports
|
||||
// instead of one looking narrower than the others past ~1440px.
|
||||
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 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"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<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 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
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
|
||||
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
|
||||
<p className="text-body-sm text-text-muted">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+38
-13
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { ArrowRightIcon } from "./ArrowRightIcon";
|
||||
import { getWerkzeugeCards } from "../lib/payload";
|
||||
|
||||
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
|
||||
@@ -20,27 +21,47 @@ export async function Tools() {
|
||||
{/* Section header */}
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Meine Werkzeuge
|
||||
Werkzeuge
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-text-primary text-h-section"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Werkzeuge für einen leichteren Alltag.
|
||||
Für mehr Orientierung im Alltag
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
|
||||
stays horizontal at every size, only the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
|
||||
arrives where the fluid floor also sits), stacked below sm;
|
||||
internal icon+text layout stays horizontal at every size, only
|
||||
the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 gap-y-14 sm:grid-cols-12 sm:gap-y-10 gap-x-10 sm:gap-x-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{tools.map((tool) => (
|
||||
<RevealItem
|
||||
key={tool.id}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
// Icon above text below lg (1024px) — a 3-up grid from sm
|
||||
// (640px) leaves each card too narrow for icon+text side by
|
||||
// side through the whole 640-1023px range. Centered on true
|
||||
// mobile (matches the rest of this stacked-card pattern
|
||||
// site-wide), left-aligned from sm up once there's a real
|
||||
// single-column card width to left-align within.
|
||||
className="sm:col-span-4 flex flex-col items-center text-center gap-4 sm:items-start sm:text-left lg:flex-row lg:gap-8 rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon — uniform box, pre-flipped/rotated source asset */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="56px" className="object-contain" />
|
||||
{/* 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 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 — 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>
|
||||
|
||||
{/* Card content — self-stretch + h-full + justify-between so
|
||||
@@ -55,8 +76,8 @@ export async function Tools() {
|
||||
minimum text→CTA gap even for the tallest card (the one
|
||||
that defines the row height, and so has ~zero leftover
|
||||
space for justify-between to distribute on its own). */}
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-center text-center sm:items-start sm:text-left gap-4 min-w-0 text-text-primary [word-break:break-word]">
|
||||
<div className="flex flex-col gap-4 items-center sm:items-start w-full">
|
||||
<p
|
||||
className="font-semibold leading-normal text-h-section w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -67,11 +88,15 @@ export async function Tools() {
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
{/* SVG arrow, not a Unicode "→" character — see
|
||||
ArrowRightIcon.tsx's own comment on why (font-fallback
|
||||
vertical-metrics mismatch, platform-dependent). */}
|
||||
<Link
|
||||
href={tool.ctaHref}
|
||||
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ {tool.ctaLabel}
|
||||
<ArrowRightIcon />
|
||||
<span>{tool.ctaLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
+49
-12
@@ -11,21 +11,58 @@ export async function TrustRow() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
|
||||
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
|
||||
not a fixed min-height guess — so every badge's "icon+title" box
|
||||
shares the SAME row track height (auto-sized to whichever
|
||||
badge's title actually needs 2 lines), and every description
|
||||
starts exactly at that row's bottom edge regardless of how many
|
||||
lines its own title happens to wrap to. Icon beside text (the
|
||||
lg: layout below) is what made a full row of 3 badges too wide
|
||||
below ~1024px in the first place (see git history: originally
|
||||
lg:-gated for exactly this, then briefly tried flex-wrap, which
|
||||
put an odd 3rd item alone on its own wrapped line and read as
|
||||
disorganized) — icon above text instead shrinks each badge down
|
||||
to just its text column's width, letting a real row of 3 fit
|
||||
without wrapping at all. Two structurally different layouts
|
||||
(icon-above-title here vs. icon-beside-a-title/description-
|
||||
stack at lg:) don't share one flexible markup shape cleanly, so
|
||||
this renders as two separate blocks (lg:hidden / hidden lg:flex)
|
||||
rather than fighting one shape across both breakpoints. */}
|
||||
<div
|
||||
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
|
||||
<div className="hidden lg:flex justify-center gap-12 items-center">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-12">
|
||||
{i > 0 && <div className="h-10 w-px bg-border" />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { formatPrice } from "../lib/format";
|
||||
import type { TaxBreakdownGroup } from "@einfach-produktiv/invoicing";
|
||||
|
||||
// The actual amount of VAT included in a total — not just a disclosure
|
||||
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
|
||||
// "which %" shown next to each line item elsewhere). One line per rate
|
||||
// when a cart/order spans more than one; a single line otherwise.
|
||||
//
|
||||
// Same row shape as the Gesamtsumme total line right above this
|
||||
// (`flex w-full` + a `flex-1` spacer): a label on the left, flush with
|
||||
// "Gesamtsumme", and the rate/amount pushed flush right so they land
|
||||
// directly under the total's own € amount — not tucked in right next to
|
||||
// the label. The rate itself gets a fixed-width right-aligned column
|
||||
// (`w-8`, `tabular-nums`) so a single-digit rate ("7%") still lines up
|
||||
// under a two-digit one ("19%") across rows instead of shifting the
|
||||
// amount that follows it. Only the first row carries the "enthält
|
||||
// MwSt.:" label; further rates repeat just the rate/amount pair.
|
||||
export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) {
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
{groups.map((g, i) => (
|
||||
<div key={g.rate} className="flex items-baseline w-full">
|
||||
<span className="text-label text-text-muted">
|
||||
{groups.length === 1 ? `enthält ${g.rate}% MwSt.` : i === 0 ? "enthält MwSt.:" : ""}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{groups.length > 1 && (
|
||||
<span className="w-8 shrink-0 text-right text-label text-text-muted tabular-nums">{g.rate}%</span>
|
||||
)}
|
||||
<span className="ml-1.5 text-label text-text-muted tabular-nums">{formatPrice(g.tax)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,14 @@ export function VersandModal({
|
||||
open,
|
||||
onClose,
|
||||
shipping,
|
||||
shippingCost,
|
||||
freeShippingThreshold,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shipping: ShippingSettings;
|
||||
shippingCost: number;
|
||||
freeShippingThreshold: number | null;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -109,7 +113,7 @@ export function VersandModal({
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 pb-8">
|
||||
<VersandSections shipping={shipping} />
|
||||
<VersandSections shipping={shipping} shippingCost={shippingCost} freeShippingThreshold={freeShippingThreshold} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user