Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
-6
@@ -8,7 +8,9 @@ import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { formatMonthYear } from "../lib/format";
|
||||
import { VertragspartnerBlock, vertragspartnerHeadings } from "./components/VertragspartnerBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AGB",
|
||||
@@ -18,8 +20,16 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function AgbPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("agb", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
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 (
|
||||
<>
|
||||
@@ -36,7 +46,7 @@ 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
|
||||
@@ -68,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 ? (
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <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>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,9 @@ export async function GET() {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Lets a logged-in customer move an existing, still-unpaid Überweisung
|
||||
// order onto a Stripe PaymentIntent instead of waiting on their own bank
|
||||
// transfer — see the backend's switchPaymentToStripe.ts for the matching
|
||||
// endpoint and why this needs a dedicated backend route rather than the
|
||||
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
|
||||
// are system fields a customer JWT can never touch).
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Same eligibility the backend endpoint re-checks authoritatively —
|
||||
// checked here too for a friendly error instead of a bare 409. An
|
||||
// explicit allowlist ('pending'/'not_applicable'), not a
|
||||
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
|
||||
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
|
||||
// meaningful states to switch from either.
|
||||
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
|
||||
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only offer this when a real Stripe payment method is actually active
|
||||
// — same "Online-Zahlung" grouping the checkout itself uses, so this
|
||||
// never presents an option that checkout wouldn't currently accept either.
|
||||
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
|
||||
const hasStripeOption = methods.some((m) => m.provider === "stripe");
|
||||
if (!hasStripeOption) {
|
||||
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
|
||||
}
|
||||
|
||||
let intent: { clientSecret: string; providerReference: string };
|
||||
try {
|
||||
intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(order.total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: order.customerEmail,
|
||||
description: `Bestellung ${order.orderNumber}`,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
|
||||
body: JSON.stringify({ providerReference: intent.providerReference }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Best-effort, same as checkout's own call — a failure here doesn't
|
||||
// block the payment itself, only the confirm-payment webhook's metadata
|
||||
// lookup, which the frontend's own webhook route already alerts on.
|
||||
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
clientSecret: intent.clientSecret,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
testMode: isPaymentTestMode,
|
||||
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
@@ -13,13 +13,36 @@ export async function PATCH(request: Request) {
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
street,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName,
|
||||
vatId,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName,
|
||||
shippingLastName,
|
||||
shippingCompanyName,
|
||||
shippingDeliveryMethod,
|
||||
shippingStreet,
|
||||
shippingPackstationNumber,
|
||||
shippingPostNumber,
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
} = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
typeof lastName !== "string" ||
|
||||
!lastName ||
|
||||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
|
||||
typeof street !== "string" ||
|
||||
!street ||
|
||||
typeof zip !== "string" ||
|
||||
!zip ||
|
||||
typeof city !== "string" ||
|
||||
@@ -29,12 +52,6 @@ export async function PATCH(request: Request) {
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "address" && !street) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
|
||||
}
|
||||
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Both independently optional (see Customers.ts's own comment) — only
|
||||
// format-checked when actually provided, same as the backend field itself.
|
||||
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
|
||||
@@ -42,18 +59,56 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Same shape as the checkout's own shipping-address-override validation
|
||||
// (api/checkout/route.ts) — required fields only apply when the toggle
|
||||
// is actually on, since this whole block is optional otherwise.
|
||||
if (hasDifferentShippingAddress) {
|
||||
if (
|
||||
typeof shippingFirstName !== "string" ||
|
||||
!shippingFirstName ||
|
||||
typeof shippingLastName !== "string" ||
|
||||
!shippingLastName ||
|
||||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
|
||||
typeof shippingZip !== "string" ||
|
||||
!shippingZip ||
|
||||
typeof shippingCity !== "string" ||
|
||||
!shippingCity ||
|
||||
typeof shippingCountry !== "string" ||
|
||||
!shippingCountry
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "address" && !shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
deliveryMethod,
|
||||
deliveryMethod: "address",
|
||||
street,
|
||||
packstationNumber,
|
||||
postNumber,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
|
||||
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
|
||||
const items = await getWishlist(session.token, session.customer.id);
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const productId = Number(body?.productId);
|
||||
const variant = typeof body?.variant === "string" ? body.variant : "";
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await toggleWishlistItem(session.token, 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 });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
@@ -8,8 +8,8 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
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";
|
||||
@@ -54,6 +54,9 @@ type CheckoutBody = {
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
shippingCompanyName?: string;
|
||||
shippingContactEmail?: string;
|
||||
shippingContactPhone?: string;
|
||||
newsletterOptIn: boolean;
|
||||
};
|
||||
|
||||
@@ -126,7 +129,7 @@ export async function POST(request: Request) {
|
||||
customer = session.customer;
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
|
||||
}
|
||||
const result = await registerCustomer({
|
||||
firstName: body.firstName,
|
||||
@@ -160,6 +163,7 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
sku: string | null;
|
||||
}[] = [];
|
||||
for (const line of body.cart) {
|
||||
const product = productsBySlug.get(line.id);
|
||||
@@ -168,7 +172,7 @@ export async function POST(request: Request) {
|
||||
// requested variant that no longer exists on this product (removed,
|
||||
// or never existed — a tampered request) fails the whole checkout
|
||||
// rather than silently falling back to the base product/price.
|
||||
let variant: { name: string; priceOverride: number | null } | null = null;
|
||||
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
|
||||
if (line.variant) {
|
||||
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
|
||||
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
|
||||
@@ -195,15 +199,25 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
// Variant sku takes precedence over the product's own — same
|
||||
// "variant overrides product" precedence unitPrice already uses.
|
||||
sku: variant?.sku ?? product.sku ?? null,
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
// Products.noShippingCost — a cart made up entirely of items that opt
|
||||
// out of shipping costs (e.g. purely digital downloads) never gets
|
||||
// charged shipping at all, regardless of the free-shipping threshold.
|
||||
// A single item WITHOUT the flag still triggers normal shipping for the
|
||||
// whole order — this only exempts a product, never the whole cart just
|
||||
// because it contains an exempt item.
|
||||
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
|
||||
|
||||
const shippingMethods = await getShippingMethods();
|
||||
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
|
||||
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
|
||||
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
|
||||
const shippingCost = freeShipping ? 0 : shippingMethod.price;
|
||||
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
|
||||
|
||||
const paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
@@ -338,11 +352,15 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
shippingMethod: shippingMethod.id,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
@@ -422,6 +440,7 @@ export async function POST(request: Request) {
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
@@ -429,6 +448,8 @@ export async function POST(request: Request) {
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
|
||||
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
@@ -438,12 +459,17 @@ export async function POST(request: Request) {
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
sku: i.sku,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
// Only meaningful for the Vorkasse notice above — whether a
|
||||
// switch to Kreditkarte/PayPal is even worth mentioning right now.
|
||||
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
|
||||
|
||||
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
|
||||
// delivery). Proxied through this Next.js route rather than fetched
|
||||
// directly from the client the way VIES is (see validate-vat/route.ts) —
|
||||
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
|
||||
// public EU endpoint, so the browser must never call Payload's DHL
|
||||
// endpoint (or hold its own copy of tenant credentials) directly.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
|
||||
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
|
||||
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
|
||||
if (!postNumber || !firstName || !lastName) {
|
||||
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../../lib/vies";
|
||||
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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getCompanySettings } from "../../lib/payload";
|
||||
|
||||
// react-pdf's default hyphenation would otherwise auto-split long words at
|
||||
// syllable boundaries to fit the line — including inside the seller's own
|
||||
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
|
||||
// "einfach-produk-tiv.com"), which must never be broken mid-word.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
// Generated on-the-fly from live company-settings instead of a static
|
||||
// uploaded PDF (see /widerruf's own download link) — the "An:"
|
||||
// address used to be baked into a hand-crafted PDF and silently went
|
||||
// stale the moment an admin updated the Impressum's Anbieterdaten without
|
||||
// remembering to also re-export/re-upload this file by hand (exactly what
|
||||
// happened 2026-07-29: the address changed in company-settings, the PDF
|
||||
// still had the old one). Same source data as AnbieterAngaben.tsx's
|
||||
// Impressum block, so the two can never drift apart again.
|
||||
const BRAND = "#f6a701";
|
||||
const DARK = "#1b1b1a";
|
||||
const GREY = "#737371";
|
||||
const RULE = "#d1cec4";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 10.5,
|
||||
color: DARK,
|
||||
paddingTop: 78,
|
||||
paddingBottom: 64,
|
||||
paddingHorizontal: 64,
|
||||
},
|
||||
topBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
backgroundColor: BRAND,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: "Times-Bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
accent: {
|
||||
width: 32,
|
||||
height: 2.2,
|
||||
backgroundColor: BRAND,
|
||||
marginBottom: 14,
|
||||
},
|
||||
note: {
|
||||
fontSize: 9.5,
|
||||
color: GREY,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraph: {
|
||||
lineHeight: 1.45,
|
||||
marginBottom: 4,
|
||||
},
|
||||
paragraphSpaced: {
|
||||
lineHeight: 1.45,
|
||||
marginTop: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8.5,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: GREY,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionRule: {
|
||||
borderBottomWidth: 0.75,
|
||||
borderBottomColor: RULE,
|
||||
},
|
||||
footerNote: {
|
||||
fontSize: 8.5,
|
||||
color: GREY,
|
||||
marginTop: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const SECTION_HEADERS = [
|
||||
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
|
||||
"BESTELLT AM (*)",
|
||||
"ERHALTEN AM (*)",
|
||||
"NAME DES/DER VERBRAUCHER(S)",
|
||||
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
|
||||
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
|
||||
"DATUM",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const seller = await getCompanySettings();
|
||||
|
||||
const addressLine = seller
|
||||
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
|
||||
: "einfach produktiv.";
|
||||
|
||||
const doc = React.createElement(
|
||||
Document,
|
||||
{ title: "Muster-Widerrufsformular - einfach produktiv." },
|
||||
React.createElement(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
React.createElement(View, { style: styles.topBar }),
|
||||
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
|
||||
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
|
||||
React.createElement(View, { style: styles.accent }),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.note },
|
||||
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
|
||||
),
|
||||
React.createElement(Text, { style: styles.label }, "An:"),
|
||||
React.createElement(Text, { style: styles.paragraph }, addressLine),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: styles.paragraphSpaced },
|
||||
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
|
||||
),
|
||||
...SECTION_HEADERS.map((header) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ key: header, style: styles.section },
|
||||
React.createElement(Text, { style: styles.sectionLabel }, header),
|
||||
React.createElement(View, { style: styles.sectionRule }),
|
||||
),
|
||||
),
|
||||
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
|
||||
),
|
||||
);
|
||||
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,15 @@ export async function POST(req: Request) {
|
||||
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
|
||||
const result = await upsertNewsletterContact(email, source);
|
||||
if (!result.ok) {
|
||||
// The customer-facing message stays generic on purpose (never leak
|
||||
// Brevo's internal error text to a customer) — but the real reason
|
||||
// was previously discarded entirely, which cost real debugging time
|
||||
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
|
||||
// every failure looked identical from the outside, whether it was a
|
||||
// bad env var, a Brevo outage, or something else. Logged here so it's
|
||||
// at least diagnosable from the container's own logs going forward.
|
||||
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
|
||||
export type SearchResult = {
|
||||
type: "product" | "post";
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// Lightweight instant search — plain `where[...][contains]` queries
|
||||
// against Payload (Postgres ILIKE under the hood) rather than a real
|
||||
// search index (Meilisearch/Algolia). Fine at this catalog size (a
|
||||
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
|
||||
// own "search becomes necessary past ~20 products" note) — worth
|
||||
// upgrading only once the catalog actually grows into that range, this
|
||||
// route can be swapped out later without touching the frontend overlay.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
if (q.length < 2) return NextResponse.json({ results: [] });
|
||||
|
||||
const productParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[active][equals]": "true",
|
||||
"where[name][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
const postParams = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[status][equals]": "published",
|
||||
"where[title][contains]": q,
|
||||
depth: "1",
|
||||
limit: "6",
|
||||
});
|
||||
|
||||
const [productsRes, postsRes] = await Promise.all([
|
||||
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
|
||||
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
|
||||
]);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (productsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
|
||||
await productsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "product",
|
||||
id: `product-${doc.id}`,
|
||||
title: doc.name,
|
||||
href: doc.detailHref || "/shop",
|
||||
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (postsRes.ok) {
|
||||
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
|
||||
for (const doc of data.docs ?? []) {
|
||||
results.push({
|
||||
type: "post",
|
||||
id: `post-${doc.id}`,
|
||||
title: doc.title,
|
||||
href: `/blog/${doc.slug}`,
|
||||
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -183,9 +183,23 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
</Reveal>
|
||||
|
||||
{!productsLoading && (
|
||||
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
|
||||
// sidebar, L297 below — same fixed-width-block category as Tier C
|
||||
// exceptions elsewhere), while the inner order-meta/items row
|
||||
// already switches at sm: — intentional, not an inconsistent
|
||||
// leftover: the inner row has no fixed-width sidebar to fight for
|
||||
// room against, just two flexible columns.
|
||||
//
|
||||
// max-w-[36rem] between sm and lg (same fix/value as
|
||||
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
|
||||
// doesn't actually constrain anything below that viewport width,
|
||||
// so at real Tablet widths (640-1023px) this card stretched to
|
||||
// fill the full page width instead of reading as a compact,
|
||||
// centered card. Widens to the real 56rem cap only once lg:'s
|
||||
// sidebar split kicks in and needs the room.
|
||||
<Reveal
|
||||
delay={0.05}
|
||||
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
|
||||
>
|
||||
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
|
||||
{/* Order meta */}
|
||||
@@ -328,8 +342,8 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
identical height regardless of how many lines the quote wraps
|
||||
to (a min-height alone lets whichever column has more content
|
||||
stretch the row past what the other side visually fills). */}
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full md:w-[45%] shrink-0">
|
||||
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
|
||||
<div className="relative w-full sm:w-[45%] shrink-0">
|
||||
{/* -inset-1, not inset-0 — this section fades in via Reveal's
|
||||
y:28→0 transform; a plain inset-0 image can leave a
|
||||
hairline gap at the top edge while that's still settling
|
||||
@@ -343,10 +357,10 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
|
||||
src="/bestellbestaetigung-testimonial-photo.jpg"
|
||||
width={366}
|
||||
height={126}
|
||||
sizes="(min-width: 768px) 45vw, 100vw"
|
||||
sizes="(min-width: 640px) 45vw, 100vw"
|
||||
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
|
||||
/>
|
||||
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
|
||||
<p
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../.
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of the blog detail page: title/category/readTime/
|
||||
// Live-previewable subset of the blog detail page: title/categories/readTime/
|
||||
// excerpt/byline, the thumbnail, and the RichText body — the fields an
|
||||
// editor actually watches update while typing. The author bio card,
|
||||
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
|
||||
@@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { buildArticleSchema } from "../../lib/structuredData";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -61,9 +62,12 @@ export default async function BlogDetailPage({
|
||||
// showing a fake/duplicate card when this is the only post.
|
||||
const otherPosts = await getBlogPosts(4);
|
||||
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
|
||||
const seller = await getCompanySettings();
|
||||
const articleSchema = buildArticleSchema(post, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
@@ -71,7 +75,7 @@ export default async function BlogDetailPage({
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
@@ -217,7 +221,7 @@ export default async function BlogDetailPage({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{nextPost.category}</span>
|
||||
<span>{nextPost.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
|
||||
+99
-23
@@ -4,7 +4,8 @@ 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 = {
|
||||
@@ -20,9 +21,38 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -30,8 +60,18 @@ export default async function BlogOverviewPage() {
|
||||
{/* Header — copy left, photo bleeds to the viewport edge on the
|
||||
right with a left-edge fade into bg-base, matching Figma's
|
||||
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
|
||||
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
|
||||
<Reveal className="relative flex flex-col sm:flex-row items-center w-full min-h-[20rem] sm:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 sm:py-0 w-full sm:w-auto sm:max-w-[26rem]">
|
||||
{/* Breadcrumb + text-h-feature heading — same treatment as
|
||||
/shop's ShopHeader.tsx and every legal page, this hero was
|
||||
the one page missing both (its own bespoke photo-bleed
|
||||
layout predates that convention being established
|
||||
elsewhere). */}
|
||||
<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)" }}
|
||||
@@ -41,35 +81,55 @@ export default async function BlogOverviewPage() {
|
||||
<p className="text-body text-text-muted">
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
{/* Moved into the hero's own text column (was a separate full-
|
||||
width bar below the entire hero, including the photo) — on
|
||||
mobile especially (text stacks above the photo here), that
|
||||
pushed the filters below a full extra screen's worth of
|
||||
hero image before they were even visible. Living right
|
||||
under the description keeps them in view immediately, on
|
||||
every width, no scrolling past the photo needed. */}
|
||||
{allCategories.length > 1 && (
|
||||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
|
||||
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="relative w-full sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
|
||||
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
|
||||
<div className="hidden sm:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{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="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||||
<Link
|
||||
href={`/blog/${featured.slug}`}
|
||||
className="group grid grid-cols-1 md:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 40rem, 100vw" className="object-cover" />
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-3 p-8 md:p-12 min-w-0">
|
||||
<div className="flex 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"
|
||||
@@ -95,7 +155,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">
|
||||
@@ -105,12 +181,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"
|
||||
|
||||
@@ -7,12 +7,13 @@ import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
|
||||
import { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
@@ -77,7 +78,7 @@ export function CartContent({
|
||||
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
@@ -163,22 +164,28 @@ 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);
|
||||
@@ -221,13 +228,25 @@ export function CartContent({
|
||||
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">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 640px) 150px, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{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}%
|
||||
@@ -235,13 +254,24 @@ 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}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</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)" }}
|
||||
>
|
||||
{product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</Link>
|
||||
) : (
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
)}
|
||||
{/* Independent stacked rows, not grid siblings — no
|
||||
equal-height pressure from neighboring lines, so a
|
||||
plain conditional line is enough here. */}
|
||||
@@ -296,7 +326,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>
|
||||
@@ -391,31 +421,40 @@ export function CartContent({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in
|
||||
the cart actually triggers shipping at all — that's a
|
||||
different state from hitting the free-shipping
|
||||
threshold, which is still a real promotional message
|
||||
worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -456,10 +495,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>
|
||||
@@ -496,7 +547,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,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
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;
|
||||
@@ -30,7 +28,15 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
|
||||
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
|
||||
@@ -123,18 +129,17 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
(opacity: 0) — invisible. Not worth chasing a fix for a
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => {
|
||||
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 (
|
||||
<div
|
||||
<div className="grid items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayProducts.map((product, i) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled={wishlistEnabled}
|
||||
wishlistRevealOnHover
|
||||
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 " +
|
||||
"sm:col-span-4 " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
@@ -142,67 +147,14 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "md:col-start-5"
|
||||
? "sm:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "md:col-start-3"
|
||||
? "sm:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{/* Same top-left pill pattern as ProductGrid.tsx/
|
||||
ProductSpotlight.tsx — position: absolute, so it never
|
||||
affects this card's height. Only the discount/Ausverkauft
|
||||
pill lives here now; the low-stock hint moved to a
|
||||
reserved-height text line below (see the min-h paragraph
|
||||
under the price) — plain conditional text here is what
|
||||
broke equal card heights in this grid before. */}
|
||||
{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-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="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
{/* Always rendered, text conditional — min-h reserves this
|
||||
line's height in both states so cards in the same row
|
||||
stay equal height regardless of low-stock status; this
|
||||
component has no h-full/flex-1 spacer trick like
|
||||
ProductGrid.tsx to absorb a variable-height line instead. */}
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">
|
||||
{anyLowStock ? "Nur noch wenige verfügbar" : null}
|
||||
</p>
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } 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
|
||||
@@ -20,13 +20,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = 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
|
||||
@@ -60,7 +61,7 @@ export default async function CartPage() {
|
||||
showDiscountField={showDiscountField}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -7,18 +7,20 @@ import { useRouter } from "next/navigation";
|
||||
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CustomSelect } from "../../components/CustomSelect";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { AddressAutocomplete } from "./AddressAutocomplete";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
import { PaymentStep } from "./PaymentStep";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { normalizeVatId, isValidVatId, isValidPlz, plzInputPattern } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { validateEmailFormat } from "../../lib/email";
|
||||
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
@@ -35,8 +37,7 @@ import type { CustomerProfile } from "../../lib/customerAuth";
|
||||
// here. `?? 4` only matters if a country somehow isn't in the map at all
|
||||
// (shouldn't happen — the <select> options are built from the same list).
|
||||
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return `\\d{${digits}}`;
|
||||
return plzInputPattern(plzDigitsMap[country] ?? 4);
|
||||
}
|
||||
|
||||
// Same rules as the pattern/required attributes each field already
|
||||
@@ -50,7 +51,7 @@ function validateRequired(label: string, value: string): string {
|
||||
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
|
||||
if (!value.trim()) return "PLZ ist erforderlich.";
|
||||
const digits = plzDigitsMap[country] ?? 4;
|
||||
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
return isValidPlz(value, digits) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
|
||||
}
|
||||
|
||||
function validatePackstationNumber(value: string): string {
|
||||
@@ -243,19 +244,32 @@ export function CheckoutContent({
|
||||
const [zip, setZip] = useState(savedProfile?.zip ?? "");
|
||||
const [city, setCity] = useState(savedProfile?.city ?? "");
|
||||
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
|
||||
// Optional package destination distinct from the billing address above —
|
||||
// no savedProfile fallback (a customer's saved profile has only ever had
|
||||
// one address), just an empty draft-only section.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState("");
|
||||
const [shippingLastName, setShippingLastName] = useState("");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address");
|
||||
const [shippingStreet, setShippingStreet] = useState("");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState("");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState("");
|
||||
const [shippingZip, setShippingZip] = useState("");
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
const [shippingCountry, setShippingCountry] = useState("Deutschland");
|
||||
// Optional package destination distinct from the billing address above.
|
||||
// Seeded from savedProfile's own "Lieferadresse" tab (Customers.ts),
|
||||
// same precedence as Card 1's billing fields above: draft (if any)
|
||||
// overwrites this in the hydration effect below, savedProfile is only
|
||||
// the pre-hydration/SSR-safe fallback. Unlike Card 1, savedProfile *can*
|
||||
// supply these now (see Customers.ts's "Lieferadresse" tab) — this used
|
||||
// to always start empty since the saved profile had only one address.
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(savedProfile?.hasDifferentShippingAddress ?? false);
|
||||
const [shippingFirstName, setShippingFirstName] = useState(savedProfile?.shippingFirstName ?? "");
|
||||
const [shippingLastName, setShippingLastName] = useState(savedProfile?.shippingLastName ?? "");
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
savedProfile?.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [shippingStreet, setShippingStreet] = useState(savedProfile?.shippingStreet ?? "");
|
||||
const [shippingPackstationNumber, setShippingPackstationNumber] = useState(savedProfile?.shippingPackstationNumber ?? "");
|
||||
const [shippingPostNumber, setShippingPostNumber] = useState(savedProfile?.shippingPostNumber ?? "");
|
||||
const [shippingZip, setShippingZip] = useState(savedProfile?.shippingZip ?? "");
|
||||
const [shippingCity, setShippingCity] = useState(savedProfile?.shippingCity ?? "");
|
||||
const [shippingCountry, setShippingCountry] = useState(savedProfile?.shippingCountry ?? "Deutschland");
|
||||
// Optional, mirrors companyName above — no shipping-side vatId though,
|
||||
// a VAT ID is a billing/invoice concept, not a shipping one. Contact
|
||||
// email/phone have no billing-side equivalent at all: they're handed to
|
||||
// the shipping carrier, not used for any customer communication.
|
||||
const [shippingCompanyName, setShippingCompanyName] = useState(savedProfile?.shippingCompanyName ?? "");
|
||||
const [shippingContactEmail, setShippingContactEmail] = useState(savedProfile?.shippingContactEmail ?? "");
|
||||
const [shippingContactPhone, setShippingContactPhone] = useState(savedProfile?.shippingContactPhone ?? "");
|
||||
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
|
||||
// Live VIES status for the USt-IdNr. field — only meaningful once the
|
||||
// goods' destination (shipping override country when set, billing
|
||||
@@ -265,6 +279,7 @@ export function CheckoutContent({
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -301,6 +316,9 @@ export function CheckoutContent({
|
||||
if (draft.shippingZip) setShippingZip(draft.shippingZip);
|
||||
if (draft.shippingCity) setShippingCity(draft.shippingCity);
|
||||
if (draft.shippingCountry) setShippingCountry(draft.shippingCountry);
|
||||
if (draft.shippingCompanyName) setShippingCompanyName(draft.shippingCompanyName);
|
||||
if (draft.shippingContactEmail) setShippingContactEmail(draft.shippingContactEmail);
|
||||
if (draft.shippingContactPhone) setShippingContactPhone(draft.shippingContactPhone);
|
||||
if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn);
|
||||
if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId);
|
||||
if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId);
|
||||
@@ -331,6 +349,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -356,6 +377,9 @@ export function CheckoutContent({
|
||||
shippingZip,
|
||||
shippingCity,
|
||||
shippingCountry,
|
||||
shippingCompanyName,
|
||||
shippingContactEmail,
|
||||
shippingContactPhone,
|
||||
newsletterOptIn,
|
||||
shippingMethodId,
|
||||
paymentMethodId,
|
||||
@@ -372,7 +396,12 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
// For VersandModal — same "lowest freeShippingThreshold among active
|
||||
// methods" rule as CartContent.tsx/getTrustBadges(), independent of
|
||||
// whichever method is currently selected in the radio list above.
|
||||
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
|
||||
const lowestFreeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
@@ -449,6 +478,42 @@ export function CheckoutContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Live-checks the Packstation Postnummer against DHL, mirroring
|
||||
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
|
||||
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
|
||||
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
|
||||
// common case for shops without a DHL integration, so it silently falls
|
||||
// back to idle (format-only, already checked above) rather than showing
|
||||
// an alarming "currently unavailable" message for a feature that was
|
||||
// simply never turned on.
|
||||
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const input = e.target;
|
||||
const value = input.value;
|
||||
const formatError = validatePostNumber(value);
|
||||
setFieldError("shippingPostNumber", formatError, input);
|
||||
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
|
||||
if (!data.valid) input.focus();
|
||||
} catch {
|
||||
setDhlPostNumberStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -558,6 +623,9 @@ export function CheckoutContent({
|
||||
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? shippingCompanyName || undefined : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? shippingContactEmail || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? shippingContactPhone || undefined : undefined,
|
||||
newsletterOptIn,
|
||||
};
|
||||
|
||||
@@ -911,7 +979,7 @@ export function CheckoutContent({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
|
||||
<FormField
|
||||
label="Passwort (für dein neues Konto)"
|
||||
label="Passwort (für dein Konto)"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Mind. 8 Zeichen"
|
||||
@@ -951,13 +1019,16 @@ export function CheckoutContent({
|
||||
Rechnungsadresse (an invoice needs a real postal address).
|
||||
Packstation is only ever offered below, in the optional
|
||||
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onChange={setStreet}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setZip(s.zip);
|
||||
setCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
@@ -996,17 +1067,19 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
name="country"
|
||||
{/* CustomSelect, not a native <select> — consistent with the
|
||||
filter dropdowns elsewhere (native options popups can't be
|
||||
styled at all). includeAllOption={false}: every country is
|
||||
a real, selectable choice, there's no "clear" concept
|
||||
here — "country" is always genuinely set to something. */}
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setCountry}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
@@ -1048,6 +1121,15 @@ export function CheckoutContent({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Firma (optional)"
|
||||
type="text"
|
||||
value={shippingCompanyName}
|
||||
onChange={(e) => setShippingCompanyName(e.target.value)}
|
||||
placeholder="Muster GmbH"
|
||||
autoComplete="off"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full rounded-sm border border-border overflow-hidden">
|
||||
@@ -1078,12 +1160,15 @@ export function CheckoutContent({
|
||||
</div>
|
||||
</div>
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onChange={setShippingStreet}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setShippingZip(s.zip);
|
||||
setShippingCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
@@ -1107,21 +1192,34 @@ export function CheckoutContent({
|
||||
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1–999)."
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={handlePostNumberBlur}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Silent when idle — covers both "not yet checked" and
|
||||
"this shop has no DHL Postnummer-validation active",
|
||||
same reasoning as handlePostNumberBlur's own comment. */}
|
||||
<p className="text-label text-text-muted min-h-[1.05rem]">
|
||||
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
|
||||
{dhlPostNumberStatus === "valid" && <span className="text-success">✓ Postnummer bestätigt</span>}
|
||||
{dhlPostNumberStatus === "invalid" && (
|
||||
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
@@ -1154,17 +1252,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
<CustomSelect
|
||||
label="Land wählen"
|
||||
includeAllOption={false}
|
||||
fullWidth
|
||||
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
|
||||
value={shippingCountry}
|
||||
onChange={(e) => setShippingCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setShippingCountry}
|
||||
/>
|
||||
</label>
|
||||
{/* Both optional and independent — handed to the shipping
|
||||
carrier, not used for any customer communication (that
|
||||
stays the account email above). Useful when the
|
||||
recipient at this address isn't reachable via the
|
||||
account holder's own email/phone. */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
type="email"
|
||||
value={shippingContactEmail}
|
||||
onChange={(e) => setShippingContactEmail(e.target.value)}
|
||||
placeholder="empfang@beispiel.de"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormField
|
||||
label="Telefonnummer (optional)"
|
||||
type="tel"
|
||||
value={shippingContactPhone}
|
||||
onChange={(e) => setShippingContactPhone(e.target.value)}
|
||||
placeholder="+49 30 123456"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1337,33 +1457,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in the
|
||||
cart actually triggers shipping at all — a different state
|
||||
from hitting the free-shipping threshold, which is still a
|
||||
real promotional message worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -1430,7 +1556,13 @@ export function CheckoutContent({
|
||||
</Reveal>
|
||||
</form>
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
<VersandModal
|
||||
open={versandOpen}
|
||||
onClose={() => setVersandOpen(false)}
|
||||
shipping={shippingSettings}
|
||||
shippingCost={shippingMethods[0]?.price ?? 0}
|
||||
freeShippingThreshold={lowestFreeShippingThreshold}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,25 +24,43 @@ type Props = {
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
/** Where /checkout/verarbeitung sends the customer once payment is
|
||||
* confirmed — "checkout" (default) clears the cart/draft and lands on
|
||||
* /bestellbestaetigung, exactly like today. "account" is used by the
|
||||
* account order detail page's "Zahlungsart ändern" flow (an existing,
|
||||
* already-confirmed order — nothing to clear, and /bestellbestaetigung
|
||||
* would be the wrong destination): lands back on that same order's
|
||||
* page instead. See VerarbeitungContent.tsx's own branching on this. */
|
||||
returnContext?: "checkout" | "account";
|
||||
};
|
||||
|
||||
// Rendered by CheckoutContent once /api/checkout returns
|
||||
// `requiresPayment: true` (Kreditkarte/PayPal) — see
|
||||
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
|
||||
// this point (status 'pending_payment'); this step only collects/confirms
|
||||
// the actual payment, it doesn't create anything.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
|
||||
// the actual payment, it doesn't create anything. Also reused as-is by
|
||||
// the account order detail page's payment-method-switch flow (see
|
||||
// returnContext above) — the Stripe collection UI itself is identical
|
||||
// either way, only the post-payment destination differs.
|
||||
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
|
||||
if (testMode) {
|
||||
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
|
||||
return (
|
||||
<TestPaymentButtons
|
||||
orderNumber={orderNumber}
|
||||
orderId={orderId}
|
||||
providerReference={providerReference ?? ""}
|
||||
returnContext={returnContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} />
|
||||
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -62,7 +80,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
@@ -80,7 +98,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
|
||||
className="rounded-full bg-brand px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
@@ -88,7 +106,17 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
|
||||
function TestPaymentButtons({
|
||||
orderNumber,
|
||||
orderId,
|
||||
providerReference,
|
||||
returnContext,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
providerReference: string;
|
||||
returnContext: "checkout" | "account";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -108,7 +136,7 @@ function TestPaymentButtons({ orderNumber, orderId, providerReference }: { order
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
|
||||
@@ -24,6 +24,11 @@ export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
// "account" — the payment-method-switch flow on an existing order's
|
||||
// account page (see PaymentStep.tsx's own returnContext comment).
|
||||
// Defaults to "checkout" for a bare/missing param, same as before this
|
||||
// branch existed.
|
||||
const isAccountContext = searchParams.get("context") === "account";
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
@@ -42,6 +47,15 @@ export function VerarbeitungContent() {
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
if (isAccountContext) {
|
||||
// Nothing to clear — this order was already placed (as
|
||||
// Überweisung) and confirmed long before this switch, there's
|
||||
// no cart/discount/draft snapshot involved. Land back on the
|
||||
// same order instead of /bestellbestaetigung, which would
|
||||
// read as a brand-new purchase.
|
||||
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
@@ -114,13 +128,15 @@ export function VerarbeitungContent() {
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
{isAccountContext
|
||||
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
|
||||
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -133,10 +149,10 @@ export function VerarbeitungContent() {
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
|
||||
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
Zurück zum Checkout
|
||||
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,11 +21,14 @@ const FALLBACK: CompanySettings = {
|
||||
sellerCity: "",
|
||||
sellerCountry: "",
|
||||
sellerEmail: "",
|
||||
emailFromName: null,
|
||||
emailFromAddress: null,
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
|
||||
+33
-24
@@ -3,19 +3,20 @@ import { Reveal } from "./Reveal";
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col sm:flex-row sm:items-stretch w-full">
|
||||
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
md:flex-[1.4_0_0] lg:flex-[1_0_0] — at Tablet the text column got
|
||||
the narrower 1:1.4 share meant for Desktop's overlap layout,
|
||||
leaving it too cramped for the fixed-width statement + quote/bio
|
||||
row. Widened at Tablet (text gets the bigger share, image the
|
||||
smaller one, no overlap yet) and reverted to the original ratio
|
||||
from lg: up, where the overlap trick actually needs the image to
|
||||
have more room. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1.4_0_0] lg:flex-[1_0_0] min-w-0 relative z-10">
|
||||
The 1:1.4 text:image ratio (text gets the narrower share) applies
|
||||
from sm: up unchanged — the photo needs the room, the text
|
||||
column doesn't need to be wide (the inner quote/bio row stays
|
||||
stacked at Tablet regardless, see below, so a wider text column
|
||||
doesn't even help it go inline; it just steals space the photo
|
||||
should have — reported 2026-07-29). Only the -ml-48 overlap
|
||||
itself (see the photo below) is gated to lg:, since that trick
|
||||
needs more absolute room than Tablet has to not look cramped. */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 sm:flex-[1_0_0] min-w-0 relative z-10">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
@@ -26,11 +27,13 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from lg: (was md:) — even with the text column's wider Tablet
|
||||
share above, quote + divider + the whitespace-nowrap bio ("Gründer
|
||||
von einfach-produktiv.") together still needed more room than
|
||||
Tablet's ~384px column has. Stacked with a horizontal divider
|
||||
through the whole Tablet range instead, side-by-side (vertical
|
||||
only from lg: (1024px), a deliberate exception to the site's
|
||||
sm: (640px) structural consolidation: quote + divider + the
|
||||
whitespace-nowrap bio ("Gründer von einfach produktiv.")
|
||||
together need more room than the sm:-anchored text column has
|
||||
through the whole 640-1023px range, independent of the outer
|
||||
text/image ratio above. Stacked with a horizontal divider
|
||||
through that whole range instead, side-by-side (vertical
|
||||
divider) only once there's real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
|
||||
|
||||
@@ -74,19 +77,20 @@ export function About() {
|
||||
<p>Björn.</p>
|
||||
<p>Führungskraft.</p>
|
||||
<p>Familienmensch.</p>
|
||||
<p>Gründer von einfach-produktiv.</p>
|
||||
<p>Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from lg+ only
|
||||
(that overlap trick has nothing to blend into once stacked, and
|
||||
at Tablet it would eat back into the extra width the text column
|
||||
above just gained); plain full-width photo below the text on
|
||||
Mobile, plain side-by-side (no overlap) at Tablet. */}
|
||||
{/* Author photo — gets the wider 1.4 share from sm: up already (see
|
||||
the text column's own comment above); plain, no overlap at
|
||||
Tablet. Overlaps the text column via -ml-48 only from lg+, where
|
||||
there's real absolute room for the photo to bleed under it
|
||||
without looking cramped. Plain full-width photo below the text
|
||||
on true Mobile. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
|
||||
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
@@ -94,11 +98,16 @@ export function About() {
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — lg+ only */}
|
||||
<div className="hidden lg:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
{/* Left gradient — from sm: up, not just lg:. Even without the
|
||||
-ml-48 overlap at Tablet, the photo sits directly against the
|
||||
dark text column with no transition, reading as a hard vertical
|
||||
seam (reported 2026-07-29). A narrower fade (w-16) softens just
|
||||
that seam at Tablet; widens to w-72 at lg: to also cover the
|
||||
deeper -ml-48 overlap once that kicks in. */}
|
||||
<div className="hidden sm:block absolute inset-y-0 left-0 w-16 lg:w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
|
||||
@@ -17,6 +18,7 @@ export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
numericId,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
@@ -27,6 +29,10 @@ 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;
|
||||
@@ -82,7 +88,9 @@ export function AddToCartButton({
|
||||
: added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
// 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 (
|
||||
// Low stock is deliberately NOT surfaced here as its own text line
|
||||
@@ -111,46 +119,49 @@ export function AddToCartButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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 "Ausverkauft"/"Maximale Menge im
|
||||
Warenkorb" — the widest of the four 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 — "Ausverkauft" rendered with a tall empty gap underneath it
|
||||
(fixed 2026-07-24). */}
|
||||
<span className="relative grid whitespace-nowrap">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
{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>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Ausverkauft
|
||||
</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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
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,6 +19,7 @@ export const FEEDBACK_MS = 2000;
|
||||
*/
|
||||
export function AddToCartInlineButton({
|
||||
id,
|
||||
numericId,
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
@@ -25,6 +27,10 @@ export function AddToCartInlineButton({
|
||||
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
|
||||
@@ -107,23 +113,36 @@ export function AddToCartInlineButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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")
|
||||
}
|
||||
{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}`}
|
||||
>
|
||||
{currentlyOutOfStock ? "Ausverkauft" : 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+22
-17
@@ -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,23 +32,23 @@ export async function Blog() {
|
||||
{/* Posts grid */}
|
||||
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
|
||||
{/* Featured post — image on top on Mobile, side-by-side from sm+ */}
|
||||
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 sm:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
|
||||
<div className="relative w-full aspect-video sm:aspect-auto sm:col-span-7 sm:h-[220px] bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={featured.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
|
||||
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm:col-span-5 min-w-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{featured.category}</span>
|
||||
<span>{featured.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
</div>
|
||||
@@ -58,7 +59,7 @@ export async function Blog() {
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
|
||||
<p className="font-normal text-body leading-6 line-clamp-2 sm:line-clamp-none">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,34 +70,38 @@ export async function Blog() {
|
||||
href={featured.href}
|
||||
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{/* Secondary posts — image on top through lg (1024px), side-by-side
|
||||
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
|
||||
before lg, so each card's own available width through the
|
||||
640-1023px tablet range is too narrow for image+text side by
|
||||
side — was sm:grid-cols-5, squeezing the text column. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
|
||||
{secondary.map((post) => (
|
||||
<RevealItem
|
||||
key={post.id}
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
|
||||
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image
|
||||
alt=""
|
||||
src={post.thumbnail}
|
||||
fill
|
||||
sizes="(min-width: 768px) 29vw, 100vw"
|
||||
sizes="(min-width: 1024px) 29vw, 100vw"
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
|
||||
<span>{post.category}</span>
|
||||
<span>{post.categories.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
@@ -107,7 +112,7 @@ export async function Blog() {
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
|
||||
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
@@ -118,7 +123,7 @@ export async function Blog() {
|
||||
href={post.href}
|
||||
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<ArrowRightIcon />
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCart } from "../lib/cart";
|
||||
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`
|
||||
@@ -10,6 +10,14 @@ import { useCart } from "../lib/cart";
|
||||
// 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);
|
||||
@@ -34,5 +42,11 @@ export function CartSync() {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
pullServerCart();
|
||||
window.addEventListener("focus", pullServerCart);
|
||||
return () => window.removeEventListener("focus", pullServerCart);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function Divider() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
<Word>Leichtigkeit</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
the text-h2 words next to it, previously hard rem values that
|
||||
|
||||
@@ -19,10 +19,15 @@ export function Footer() {
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg: (was md:). Logo + handle + 5 legal links all side by side
|
||||
with justify-between read too cramped on Tablet — stacked
|
||||
through that range instead, side by side again once there's
|
||||
real room at lg:. */}
|
||||
lg:. Reverted here from an earlier sm: rename (2026-07-29's
|
||||
breakpoint migration): this row isn't the "grid arrives before
|
||||
the fluid floor" bug the sm: consolidation targets — logo +
|
||||
handle + 5 nowrap legal links (all shrink-0) simply don't fit
|
||||
in one row below ~1024px regardless of fluid scaling (measured:
|
||||
~746px combined natural width at a 666px viewport, causing a
|
||||
real horizontal page overflow, not just visual cramping).
|
||||
Same fixed-content-doesn't-fit category as Cart/Checkout/
|
||||
SectionTOC, kept at lg: for the same reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+68
-48
@@ -11,7 +11,7 @@ function HeroImage() {
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
sizes="(min-width: 640px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
@@ -28,44 +28,56 @@ function HeroImage() {
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is md: (768px) for the GRID only — the text
|
||||
column stays ~283-320px wide through the whole 768-1023px Tablet
|
||||
{/* Structural breakpoint is sm: (640px, moved down from the old md:
|
||||
768px so the grid arrives exactly where the fluid token floor
|
||||
also sits — see fluid.ts/globals.css) for the GRID only — the
|
||||
text column stays narrow through the whole 640-1023px Tablet
|
||||
range regardless. Below, every piece of *content* inside the text
|
||||
column (heading/subtitle/CTA/social-proof) keeps its smaller,
|
||||
fixed-below-lg: sizing all the way through Tablet too, not just
|
||||
true Mobile — reusing the full fluid-token sizes at md: (as a
|
||||
first pass 2026-07-24 briefly did) put the original ~19-44px
|
||||
fluid floors right back in that narrow column, recreating the
|
||||
exact 3-line-wrap problem the old `lg:` structural exception
|
||||
existed to avoid. Splitting "grid at md:" from "full-size content
|
||||
at lg:" gets both: Tablet shows the real 5/7 grid, but with
|
||||
content sized for its column's actual width, not the column
|
||||
width `lg:` was designed for. */}
|
||||
<div className="flex flex-col md:grid md:grid-cols-12 md:items-center gap-8 md:gap-[var(--layout-grid-gap)] pt-10 md:pt-0">
|
||||
column (heading/subtitle/CTA/social-proof) deliberately keeps its
|
||||
smaller, fixed-below-lg: sizing all the way through Tablet too,
|
||||
not just true Mobile — reusing the full fluid-token sizes that
|
||||
early put the original ~19-44px fluid floors right back in that
|
||||
narrow column, recreating the exact 3-line-wrap problem the old
|
||||
`lg:` structural exception existed to avoid. This is a narrower,
|
||||
intentional exception to the site-wide sm: consolidation — the
|
||||
column's real width at sm: hasn't been visually verified yet
|
||||
(no browser in this environment), so full-size content stays
|
||||
gated behind lg: as a fast-follow rather than a guess. Splitting
|
||||
"grid at sm:" from "full-size content at lg:" gets both: Tablet
|
||||
shows the real 5/7 grid, but with content sized for its column's
|
||||
actual width, not the column width `lg:` was designed for. */}
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-12 sm:items-center gap-8 sm:gap-[var(--layout-grid-gap)] pt-10 sm:pt-0">
|
||||
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 md:order-none md:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 md:pr-0">
|
||||
viewport — this doubles as the page's entrance animation.
|
||||
Centered below sm: (true Mobile) — the CTA/social-proof below
|
||||
were already centered there, left-aligned heading/subheading
|
||||
above them read inconsistent (reported 2026-07-29). Left-
|
||||
aligned again from sm: up, matching the rest of the column. */}
|
||||
<Reveal className="order-1 sm:order-none sm:col-span-5 flex flex-col gap-7 items-center sm:items-start pl-[var(--layout-padding-x)] pr-10 sm:pr-0">
|
||||
|
||||
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
|
||||
real paired font-size+line-height token, not an arbitrary
|
||||
value) — text-display's own 44px floor wraps very heavily in
|
||||
a ~283-320px Tablet column (even a single word can approach
|
||||
that width). Forced break after "darf" only in the sm-md
|
||||
tablet range (natural wrap there landed awkwardly); removed
|
||||
at true mobile widths (below sm:) 2026-07-24 — narrower
|
||||
still, natural wrap reads fine there, and the forced break
|
||||
made "darf" the whole first line. Full text-display only
|
||||
from lg: up, where the column has real room again. */}
|
||||
that width). The old forced break after "darf" only existed
|
||||
for a "wide single-column, not yet grid" band (640-767px
|
||||
under the old md: 768px grid switch) that no longer exists
|
||||
now the grid itself starts at sm: (640px) — below sm: the
|
||||
stack is narrower than that old band ever was, where natural
|
||||
wrap already reads fine (confirmed 2026-07-24), so the break
|
||||
is removed rather than re-anchored to a new range. Full
|
||||
text-display only from lg: up, where the column has real
|
||||
room again. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary text-center sm:text-left"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-h1 lg:text-display">
|
||||
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
|
||||
Verlier' dich nicht im mehr, verankere was zählt
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
@@ -83,14 +95,16 @@ export function Hero() {
|
||||
{/* Subheading — smaller fixed size below lg:, text-h-emphasis's
|
||||
own 20px floor read too large next to the now-smaller CTA
|
||||
text. leading shrinks to match, not just font-size. */}
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
|
||||
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] text-center sm:text-left [word-break:break-word] not-italic">
|
||||
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
{/* CTA — centered below sm: (reads better against the centered
|
||||
image/banner above it on true Mobile), left-aligned with the
|
||||
rest of the text column again from sm: up. */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
href="/lebensuhr"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all self-center sm:self-auto"
|
||||
>
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
@@ -100,7 +114,7 @@ export function Hero() {
|
||||
too wide for that, both on a 375px phone AND in the
|
||||
~283-320px Tablet grid column. */}
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
Jetzt starten
|
||||
</span>
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
@@ -112,8 +126,14 @@ export function Hero() {
|
||||
|
||||
{/* Social proof — always avatars-then-text on two lines below
|
||||
lg: (not just when it happens to overflow), single row again
|
||||
from lg: up where the real column width fits it fine. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center justify-center overflow-clip shrink-0 w-full">
|
||||
from lg: up where the real column width fits it fine.
|
||||
Centered on true Mobile (<640px, matches the CTA above it,
|
||||
see self-center there); left-aligned in the 640-1023px
|
||||
Tablet band instead, matching the left-aligned heading/
|
||||
subheading — plain centered there read as floating/
|
||||
disconnected from that column (reported 2026-07-29); back to
|
||||
a centered row at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center sm:items-start lg:items-center justify-center sm:justify-start lg:justify-center overflow-clip shrink-0 w-full">
|
||||
{/* Avatars — gap 2px, not overlapping */}
|
||||
<div className="flex gap-[0.125rem] items-center shrink-0">
|
||||
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
|
||||
@@ -126,36 +146,36 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center [word-break:break-word]">
|
||||
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center sm:text-left [word-break:break-word]">
|
||||
1.120+ Menschen vertrauen <span className="whitespace-nowrap">einfach produktiv.</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below md: (stacked layout) the full 887:583 aspect
|
||||
ratio at 100vw would make the image ~600-900px tall and
|
||||
dominate the page, so height is capped and object-cover crops
|
||||
it into a supporting banner instead; at md:+ (grid, image only
|
||||
58% width) the full aspect ratio looks right again, so the cap
|
||||
is lifted. No shadow: a plain box-shadow reads as a hard
|
||||
rectangular edge against the existing corner/right/bottom
|
||||
mask-gradient fade below, which looked worse than no shadow at
|
||||
all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below md: — whileInView's -80px
|
||||
padded). Below sm: (stacked layout, moved down from the old
|
||||
md: 768px) the full 887:583 aspect ratio at 100vw would make
|
||||
the image ~600-900px tall and dominate the page, so height is
|
||||
capped and object-cover crops it into a supporting banner
|
||||
instead; at sm:+ (grid, image only 58% width) the full aspect
|
||||
ratio looks right again, so the cap is lifted. No shadow: a
|
||||
plain box-shadow reads as a hard rectangular edge against the
|
||||
existing corner/right/bottom mask-gradient fade below, which
|
||||
looked worse than no shadow at all — tried and reverted. */}
|
||||
{/* No Reveal (fade-in-on-scroll) below sm: — whileInView's -80px
|
||||
viewport margin means the image doesn't fade in until scrolled
|
||||
that much further into view; on a short mobile viewport this
|
||||
image sits right at the initial fold, so it stayed at
|
||||
opacity:0 (a white gap, matching the section's own bg-bg-base)
|
||||
above the fold until the user scrolled (reported 2026-07-24).
|
||||
Plain, always-visible image below md: instead; Reveal's fade
|
||||
kept from md: up, where the image is beside the text with
|
||||
Plain, always-visible image below sm: instead; Reveal's fade
|
||||
kept from sm: up, where the image is beside the text with
|
||||
plenty of room and this was never an issue. */}
|
||||
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<div className="order-2 sm:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
|
||||
className="hidden sm:block sm:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<HeroImage />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+168
-21
@@ -6,6 +6,8 @@ import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton, SearchOverlay } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -58,7 +60,10 @@ function getNavLinks(singleActiveProduct: boolean) {
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
|
||||
// /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", "/lebensuhr", "/newsletter", "/der-eine", "/tasse-die-pause"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -131,6 +136,71 @@ function AccountLink() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -142,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
|
||||
@@ -156,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);
|
||||
@@ -210,12 +291,21 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
|
||||
export function Navbar({
|
||||
singleActiveProduct,
|
||||
wishlistEnabled,
|
||||
searchEnabled,
|
||||
}: {
|
||||
singleActiveProduct: boolean;
|
||||
wishlistEnabled: boolean;
|
||||
searchEnabled: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
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);
|
||||
|
||||
@@ -383,13 +473,23 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
// header itself to be a flexible column that could grow for it — it's
|
||||
// now a fixed-position sibling instead (see that panel's own comment
|
||||
// on why), so this is back to a plain fixed-height bar.
|
||||
//
|
||||
// onClick here closes the drawer on any click that lands on the bar
|
||||
// itself (logo/cart/account/CTAs already call closeMobile() from
|
||||
// their own onClick, so this mainly covers clicking empty space in
|
||||
// the bar) — safe as a catch-all specifically because the panel is
|
||||
// a sibling, not a descendant, so a click inside the open drawer
|
||||
// never bubbles up to this handler. The hamburger's own onClick
|
||||
// stops propagation so toggling it open doesn't immediately get
|
||||
// undone by this same handler.
|
||||
onClick={() => mobileOpen && setMobileOpen(false)}
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-bg-base/80 backdrop-blur-md"
|
||||
: "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
|
||||
@@ -401,7 +501,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
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) => {
|
||||
@@ -413,11 +513,21 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
: 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"
|
||||
width={181}
|
||||
height={61}
|
||||
className="w-full h-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
@@ -428,8 +538,27 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Figma's Navbar-Mobile component set exactly, see the plan.
|
||||
Matches the Figma NavLink component's hover: text never
|
||||
changes color, an underline (brand, 2px x 40px) fades in on
|
||||
hover and stays on for the active section. */}
|
||||
<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 = (
|
||||
@@ -479,17 +608,24 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
{/* 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">
|
||||
{/* No gap between these two — each is already a 44px touch
|
||||
target with the icon centered inside, so even gap-0 here
|
||||
still leaves ~20px of visual space between the actual
|
||||
glyphs. The outer gap-2 is what separates this pair from
|
||||
the CTA-buttons/hamburger group that follows, and stays
|
||||
untouched. Fixed 2026-07-24: gap-2 here on top of that
|
||||
built-in padding read as too much space on mobile, where
|
||||
these two icons are the only always-visible controls. */}
|
||||
<div className="flex items-center 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>
|
||||
|
||||
@@ -509,10 +645,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +661,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-panel"
|
||||
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileOpen((v) => !v);
|
||||
}}
|
||||
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
|
||||
>
|
||||
<span className="relative block h-4 w-6">
|
||||
@@ -571,7 +710,14 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
// Own (slower, gentler) transition for the exit — the shared
|
||||
// ease-out curve above is front-loaded (fast start, slow
|
||||
// finish), which reads great for the reveal but meant the
|
||||
// close shrank most of the way almost immediately, then
|
||||
// lingered on a barely-visible sliver — felt abrupt rather
|
||||
// than smooth. easeInOut plus a longer duration spreads the
|
||||
// shrink evenly instead.
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
@@ -658,11 +804,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
href="/3x3-system"
|
||||
onClick={closeMobile}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
Mein 3x3-System
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -671,6 +817,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ type NewsletterProps = {
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
title = "Kleine Impulse – große Wirkung",
|
||||
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
@@ -43,17 +43,35 @@ export function Newsletter({
|
||||
{/* Outer section padding — same fluid horizontal padding as all other sections */}
|
||||
<div className="px-[var(--layout-padding-x)] w-full">
|
||||
|
||||
{/* Rounded card: cream bg, stacks below md */}
|
||||
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
|
||||
{/* max-w-[1280px] mx-auto — the consistent default width for this
|
||||
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
|
||||
section (same 1280px cap Footer.tsx already uses) and every
|
||||
other narrow-viewport-friendly section on the site. Previously
|
||||
unbounded here, so on wide desktop viewports this card stretched
|
||||
edge-to-edge while /lebensuhr's version stayed centered and
|
||||
noticeably narrower — reported 2026-07-29. */}
|
||||
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
|
||||
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
|
||||
relying purely on items-center + the taller column's own
|
||||
height to create top/bottom breathing room) — the form column
|
||||
(input, checkbox+label, privacy note) can be as tall as or
|
||||
taller than the copy column depending on viewport width, so
|
||||
centering alone left no slack to distribute and the input/
|
||||
"Keine Werbung" note sat flush against the card's top/bottom
|
||||
edge. */}
|
||||
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below md: — side by
|
||||
side they squeezed the text into a ~164px column on a 375px
|
||||
phone (icon width + gap eating most of the card's inner
|
||||
width), wrapping awkwardly. Row layout with the icon beside
|
||||
the text is fine again from md+, where the fixed copy-column
|
||||
width leaves real room. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full md:flex-row md:items-start md:gap-8 md:text-left md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from sm+ so the form always gets the remaining space.
|
||||
Icon+text stacked (icon on top, centered) below sm: — side by
|
||||
side they squeezed the text into a narrow column on a phone
|
||||
(icon width + gap eating most of the card's inner width),
|
||||
wrapping awkwardly, and the same squeeze reappeared through
|
||||
the whole tablet range (640-1023px) once the outer card's own
|
||||
sm:flex-row already put this copy column next to the form —
|
||||
row layout for icon+text is only comfortable once there's
|
||||
real room, i.e. lg+. Left-aligned (not centered) from sm up —
|
||||
only true mobile keeps the centered treatment. */}
|
||||
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
@@ -89,22 +107,24 @@ export function Newsletter({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
{/* Right: form — takes remaining space, centered vertically from sm+ */}
|
||||
<div className="flex w-full sm:flex-1 items-center sm:self-stretch min-w-0">
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below lg: (was md:).
|
||||
The card above already goes side-by-side at md: with a
|
||||
fixed-width copy column (--newsletter-copy-width), which
|
||||
only leaves ~200px for this form column at 768px — not
|
||||
enough room for input+button side by side. Stacked
|
||||
through the whole Tablet range instead, side by side
|
||||
again once the form column has real room at lg:. */}
|
||||
{/* Input + submit button — stacked below lg:, a deliberate
|
||||
exception to the site's sm: (640px) structural
|
||||
consolidation, not a leftover of it. The card above
|
||||
already goes side-by-side at sm: with a fixed-width
|
||||
copy column (--newsletter-copy-width), which only
|
||||
leaves ~128px for this form column at 640px and
|
||||
~197px at 768px — not enough room for input+button
|
||||
side by side even at the new, lower floor. Stacked
|
||||
through the whole 640-1023px range instead, side by
|
||||
side again once the form column has real room (≈333px)
|
||||
at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
@@ -140,7 +160,7 @@ export function Newsletter({
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
|
||||
@@ -10,7 +10,7 @@ const features = [
|
||||
{
|
||||
icon: "/icon-sparkle-wrapper.svg",
|
||||
title: "7 Tage. Ein Fokus.",
|
||||
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-checklist.png",
|
||||
@@ -35,7 +35,7 @@ const features = [
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
@@ -144,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
// Capped narrower than the Desktop 75rem through the whole
|
||||
// 640-1023px Tablet band — this dialog is a modal, not a page
|
||||
// section, so at Tablet it should read as a compact centered
|
||||
// card, not stretch to near-full-viewport-width once stacked
|
||||
// single-column (see the flex-col/flex-row split below):
|
||||
// full-bleed-width + a single stacked photo/text column made
|
||||
// the photo huge and the text below it look lost/disconnected
|
||||
// (reported 2026-07-29, after first trying a lg:-gated
|
||||
// structural stack with no width cap). max-w-[75rem] only
|
||||
// takes over once the 2-column split itself starts at lg:.
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* sticky, not absolute — the dialog itself is the scrolling
|
||||
container (overflow-y-auto above), so an absolute-positioned
|
||||
child scrolls away with the rest of the content instead of
|
||||
staying pinned to the visible top-right corner (reported
|
||||
2026-07-29). sticky top-6 keeps it fixed to the scrolled
|
||||
viewport's top edge; ml-auto pushes it to the right within
|
||||
the dialog's normal block flow (sticky positioning doesn't
|
||||
use right-* the way absolute does); -mb-6 cancels its own
|
||||
height (size-6 = 1.5rem) so it doesn't push the modal-top
|
||||
content below it down — same visual overlap as the old
|
||||
absolute positioning, just still visible after scrolling. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
>
|
||||
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||||
{/* modal-top: photo + copy/form, stacked below lg: — paired with
|
||||
the dialog's own narrower max-w-[36rem] cap through Tablet
|
||||
(see above), so the stacked photo stays a reasonably-sized
|
||||
4:3 banner instead of blowing up to near-full-viewport-width. */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
|
||||
<Image
|
||||
src="/newsletter-modal-photo.jpg"
|
||||
alt="Notizbuch mit Kaffee und Stift"
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
sizes="(min-width: 1024px) 50vw, 36rem"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -172,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden md:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
flipped. Hidden below lg: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden lg:block w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
@@ -182,20 +206,18 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||||
Kleine Impulse – große Wirkung
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-primary">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
|
||||
</p>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
@@ -209,9 +231,13 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
{/* Always rendered (min-h reserves one line's worth of
|
||||
space) rather than conditionally mounted — this sits
|
||||
inside the same row the photo on the left stretches
|
||||
to match (items-stretch, md:aspect-auto), so an error
|
||||
popping in and out used to grow/shrink the whole
|
||||
modal, visibly resizing the photo along with it. */}
|
||||
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
@@ -225,7 +251,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
@@ -241,9 +267,12 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
{/* Same reserved-space fix as emailError above — this is
|
||||
the "already subscribed" message, the one that actually
|
||||
prompted it. */}
|
||||
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
|
||||
{status === "error" ? error : ""}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
@@ -258,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
items-start + a fixed icon bounding box (icons have different
|
||||
native proportions, e.g. the sparkle glyph isn't square) fixes
|
||||
it without needing the flip trick. */}
|
||||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
|
||||
{/* Same lg: exception as modal-top above, for the same narrower-
|
||||
dialog-width-through-Tablet reason. */}
|
||||
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
|
||||
@@ -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,153 @@
|
||||
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 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 (
|
||||
<div
|
||||
className={`group bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full 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-3 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<div className="flex flex-col gap-1 items-start w-full">
|
||||
{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)" }}
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{product.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
{belowPrice}
|
||||
{/* Always rendered, text conditional — reserved height keeps every
|
||||
card in a row equal height regardless of low-stock status. An
|
||||
earlier version omitted this for fully-out-of-stock products
|
||||
(this line can never apply there), on the theory that it was
|
||||
dead space — but NotifyMeForm's single-row redesign (see its
|
||||
own comment) now matches AddToCartInlineButton's button height
|
||||
exactly, which made THIS line the only remaining source of a
|
||||
mismatch: omitting it made the sold-out card shorter than its
|
||||
siblings instead of taller. Keeping it unconditional is what
|
||||
actually gets an exact match now. */}
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
|
||||
|
||||
{/* flex-1 spacer — pins every card's button to the same Y
|
||||
regardless of whether the title/category line wraps. */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,51 @@
|
||||
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, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { WishlistButton } from "./WishlistButton";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
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 bold = typeof textNode.format === "number" && (textNode.format & 1) === 1;
|
||||
return bold ? <strong key={key}>{textNode.text}</strong> : <Fragment key={key}>{textNode.text}</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
|
||||
* (defaults to none — the section just doesn't render until one is set),
|
||||
@@ -23,11 +63,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getWishlistEnabled(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
@@ -42,14 +83,22 @@ export async function ProductSpotlight() {
|
||||
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
|
||||
// section instead of navigating to /shop whenever exactly 1 product is
|
||||
// active (see Navbar.tsx/layout.tsx).
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
|
||||
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<section id="spotlight" className="w-full bg-bg-base py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
|
||||
at 640px, but a 380px-wide image + text squeezed into the rest of
|
||||
a tablet-width viewport (768-1023px) read as cramped/too wide.
|
||||
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
|
||||
and lg keeps that stacked card centered and narrower than the
|
||||
tablet viewport instead of stretching to fill it — true mobile
|
||||
(below sm) stays unconstrained since the viewport itself is
|
||||
already narrow there. */}
|
||||
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
|
||||
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
@@ -63,6 +112,9 @@ export async function ProductSpotlight() {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{wishlistEnabled && product.spotlightShowWishlist && (
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
|
||||
@@ -73,9 +125,7 @@ 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.description} />
|
||||
{/* 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). */}
|
||||
@@ -86,10 +136,18 @@ export async function ProductSpotlight() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
{kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? "Keine Versandkosten"
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? "– keine Versandkosten" : "zzgl. Versand"}`}
|
||||
</p>
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
@@ -106,7 +164,7 @@ export async function ProductSpotlight() {
|
||||
(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} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
<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}
|
||||
|
||||
@@ -160,6 +160,16 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
{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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +61,9 @@ function useActiveSection(sections: TOCSection[]) {
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
// even the site's 640px structural floor, so sm: wouldn't leave room for
|
||||
// a real 2-column split at Tablet widths — a deliberate exception to the
|
||||
// site-wide sm: consolidation, not a leftover of it.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
|
||||
@@ -15,7 +15,7 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
if (testimonials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
@@ -24,11 +24,11 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.id}
|
||||
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group relative sm:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
+30
-20
@@ -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,33 +21,45 @@ export async function Tools() {
|
||||
{/* Section header */}
|
||||
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
|
||||
<p className="font-bold text-brand text-h-small">
|
||||
Meine Werkzeuge
|
||||
Werkzeuge
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-text-primary text-h-section"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Werkzeuge für einen leichteren Alltag.
|
||||
Für mehr Orientierung im Alltag
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
|
||||
stays horizontal at every size, only the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
|
||||
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
|
||||
arrives where the fluid floor also sits), stacked below sm;
|
||||
internal icon+text layout stays horizontal at every size, only
|
||||
the outer span changes */}
|
||||
<RevealGroup className="grid grid-cols-1 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.
|
||||
size-14 (56px) is a fixed value at every width (14 isn't
|
||||
one of this project's fluid spacing-scale steps) — smaller
|
||||
below md: so it doesn't dwarf the title/description text,
|
||||
below lg: so it doesn't dwarf the title/description text,
|
||||
which does shrink toward its own fluid floor there. Full
|
||||
56px only from lg: up — at md: (Tablet, where this grid
|
||||
already switches to 3-up) the title/description are still
|
||||
fairly close to their own fluid floor, so the full-size
|
||||
icon read as too big next to them too. */}
|
||||
56px only from lg: up — a deliberate exception to the
|
||||
site's sm: (640px) structural consolidation: the grid now
|
||||
switches to 3-up at sm:, but title/description are still
|
||||
fairly close to their own fluid floor through the whole
|
||||
640-1023px range, so the full-size icon still reads too big
|
||||
next to them there. Not re-verified visually — narrower
|
||||
exception kept as-is, same reasoning as Hero's content
|
||||
sizing. */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
|
||||
</div>
|
||||
@@ -63,8 +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)" }}
|
||||
@@ -75,17 +88,14 @@ export async function Tools() {
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
{/* flex items-center + arrow as its own span, not inline text
|
||||
— the → glyph sits low relative to the surrounding text's
|
||||
cap-height in the font used here, off-center against the
|
||||
label if it's just part of the same text node (fixed
|
||||
2026-07-24, same pattern ProductGrid.tsx's "Mehr
|
||||
erfahren" link already uses). */}
|
||||
{/* 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="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span>
|
||||
<ArrowRightIcon />
|
||||
<span>{tool.ctaLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+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-start md:items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
|
||||
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
|
||||
not a fixed min-height guess — so every badge's "icon+title" box
|
||||
shares the SAME row track height (auto-sized to whichever
|
||||
badge's title actually needs 2 lines), and every description
|
||||
starts exactly at that row's bottom edge regardless of how many
|
||||
lines its own title happens to wrap to. Icon beside text (the
|
||||
lg: layout below) is what made a full row of 3 badges too wide
|
||||
below ~1024px in the first place (see git history: originally
|
||||
lg:-gated for exactly this, then briefly tried flex-wrap, which
|
||||
put an odd 3rd item alone on its own wrapped line and read as
|
||||
disorganized) — icon above text instead shrinks each badge down
|
||||
to just its text column's width, letting a real row of 3 fit
|
||||
without wrapping at all. Two structurally different layouts
|
||||
(icon-above-title here vs. icon-beside-a-title/description-
|
||||
stack at lg:) don't share one flexible markup shape cleanly, so
|
||||
this renders as two separate blocks (lg:hidden / hidden lg:flex)
|
||||
rather than fighting one shape across both breakpoints. */}
|
||||
<div
|
||||
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
|
||||
<div className="hidden lg:flex justify-center gap-12 items-center">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-12">
|
||||
{i > 0 && <div className="h-10 w-px bg-border" />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
|
||||
// Heart-toggle for a product card/detail page. Login-gated (unlike the
|
||||
// cart, which works for guests) — a logged-out click redirects to
|
||||
// /konto/login?redirect=<back-here> instead of silently failing, since
|
||||
// there's no local-storage fallback that would make sense for a wishlist
|
||||
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
|
||||
// guest-friendly pattern).
|
||||
export function WishlistButton({
|
||||
productId,
|
||||
variant = "",
|
||||
className = "",
|
||||
revealOnHover = false,
|
||||
}: {
|
||||
productId: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
/** false (default): always visible — right for /konto/merkliste (every
|
||||
* card there is already-wishlisted, so hover-reveal would be pointless)
|
||||
* and the product detail page. true: invisible until the card is
|
||||
* hovered/focused, unless the product is already wishlisted (a filled
|
||||
* heart stays as a permanent status indicator) — right for any grid or
|
||||
* card-like feature (ProductGrid.tsx, ProductSpotlight.tsx) where a
|
||||
* heart sitting there unprompted reads as visual noise. Relies on the
|
||||
* parent card already carrying `group`/`focus-within` (see
|
||||
* ProductGrid.tsx/ProductSpotlight.tsx).
|
||||
*
|
||||
* On coarse-pointer (touch) devices, hover-reveal can't work at all —
|
||||
* touch has no persistent `:hover`, so a tap on the card never reliably
|
||||
* reveals a `group-hover`-gated element the way a mouse hover does.
|
||||
* Rather than falling back to "always visible at full size" (which
|
||||
* recreates the exact "a heart on every card" visual noise this mode
|
||||
* exists to avoid), touch devices get a smaller variant with a lighter
|
||||
* (not removed — fully transparent made it invisible against some
|
||||
* product photos) background instead — present and tappable everywhere,
|
||||
* but visually quieter than the full-size default. Applies to
|
||||
* already-wishlisted hearts too (not just the hover-revealed ones) —
|
||||
* consistent sizing across every heart on a touch device, rather than
|
||||
* only the not-yet-wishlisted ones shrinking. This smaller touch sizing
|
||||
* is applied regardless of `revealOnHover` (see the className below) —
|
||||
* a heart reading visually "quieter" on a touch screen is a general
|
||||
* touch-device trait, not specific to the multi-card-grid use case it
|
||||
* was first built for. */
|
||||
revealOnHover?: boolean;
|
||||
}) {
|
||||
const { isWishlisted, toggle } = useWishlist();
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const wishlisted = isWishlisted(productId, variant);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pending) return;
|
||||
|
||||
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
|
||||
if (res.status === 401) {
|
||||
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
await toggle(productId, variant);
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
|
||||
aria-pressed={wishlisted}
|
||||
disabled={pending}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 pointer-coarse:h-7 pointer-coarse:w-7 pointer-coarse:bg-bg-base/60 ${
|
||||
revealOnHover && !wishlisted
|
||||
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
||||
: ""
|
||||
} ${className}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="18"
|
||||
viewBox="0 0 20 18"
|
||||
fill={wishlisted ? "currentColor" : "none"}
|
||||
className={`${wishlisted ? "text-brand" : "text-text-primary"} pointer-coarse:scale-75`}
|
||||
>
|
||||
<path
|
||||
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { headingId } from "../../components/RichText";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
import type { TOCSection } from "../../components/SectionTOC";
|
||||
|
||||
// Renders "1. Verantwortlicher" straight from company-settings, same
|
||||
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx — this
|
||||
// used to be hand-typed name/address/email baked into the Datenschutz
|
||||
// richText (seed-datenschutz.ts on the Payload side), silently out of
|
||||
// date the moment company-settings changed without a matching manual
|
||||
// edit here too. The heading itself stays numbered "1." (headingId's own
|
||||
// `^(\d+)\.` match turns that into "section-1", exactly the id this
|
||||
// section's TOC entry already had before the RichText stopped rendering
|
||||
// it — no anchor breakage).
|
||||
export function verantwortlicherHeadings(): TOCSection[] {
|
||||
return [{ id: headingId("1. Verantwortlicher"), title: "1. Verantwortlicher" }];
|
||||
}
|
||||
|
||||
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 VerantwortlicherBlock({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>1. Verantwortlicher</Heading>
|
||||
<P>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</P>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.managingDirector || seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
|
||||
</P>
|
||||
</div>
|
||||
<P>Ein gesetzlich vorgeschriebener Datenschutzbeauftragter ist für unser Unternehmen aufgrund seiner Größe derzeit nicht erforderlich.</P>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { formatMonthYear } from "../lib/format";
|
||||
import { VerantwortlicherBlock, verantwortlicherHeadings } from "./components/VerantwortlicherBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Datenschutzerklärung",
|
||||
@@ -17,8 +19,11 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("datenschutz", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
const [page, seller] = await Promise.all([getLegalPage("datenschutz", { draft: isPreview }), getCompanySettings()]);
|
||||
// "1. Verantwortlicher" headings first — that block renders above the
|
||||
// CMS content below (same reasoning as Impressum's own headings
|
||||
// composition, see AnbieterAngaben.tsx).
|
||||
const headings = [...verantwortlicherHeadings(), ...(page ? extractHeadings(page.content) : [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,7 +40,7 @@ export default async function DatenschutzPage() {
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</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
|
||||
@@ -68,7 +73,11 @@ export default async function DatenschutzPage() {
|
||||
</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">
|
||||
{/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht
|
||||
aus der CMS-Richtext unten — single-sourced, gleiche
|
||||
Begründung wie Impressum's AnbieterAngaben.tsx. */}
|
||||
{seller && <VerantwortlicherBlock seller={seller} />}
|
||||
{page ? (
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
// No circle badge, thicker uniform-weight black stroke, no fill — matching
|
||||
// the site's established illustrative icon language (public/icon-trust-*.png,
|
||||
// public/icon-step-*.png): bold outline glyphs standing on their own, not
|
||||
// boxed into a badge shape. Replaces the earlier thin-stroke circled
|
||||
// version, which didn't match anything else on the site.
|
||||
function IconBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" stroke="#231f20" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="h-16 w-auto shrink-0">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMetal() {
|
||||
// Pen/nib glyph — stands in for the metal body itself.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M10 22l2.5-0.8L23 10.7l-2.7-2.7L9.8 19.5 9 22z" />
|
||||
<path d="M19 9l3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSparkle() {
|
||||
// Engraving/laser mark — four-point sparkle, same glyph as before, just
|
||||
// heavier stroke and unboxed to match the rest of the set.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M16 7v5M16 20v5M7 16h5M20 16h5M10.5 10.5l3 3M18.5 18.5l3 3M21.5 10.5l-3 3M13.5 18.5l-3 3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCompact() {
|
||||
// Four corner brackets pointing inward — standard "minimize/fits
|
||||
// anywhere" glyph, reads as compact more directly than a shield ever did.
|
||||
return (
|
||||
<IconBadge>
|
||||
<path d="M11 8H8v3M21 8h3v3M11 24H8v-3M21 24h3v-3" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEveryday() {
|
||||
// Sun — everyday/daily use.
|
||||
return (
|
||||
<IconBadge>
|
||||
<circle cx="16" cy="16" r="6" />
|
||||
<path d="M16 4v3M16 25v3M4 16h3M25 16h3M7.5 7.5l2 2M22.5 22.5l2 2M24.5 7.5l-2 2M9.5 22.5l-2 2" />
|
||||
</IconBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// "Was kann er?" section's full four-item breakdown (build, engraving,
|
||||
// size, use case). Same card shape as todo-cards/components/HowItWorks.tsx
|
||||
// — icon on top, centered, RevealGroup/RevealItem stagger, identical
|
||||
// title/description typography — reused for visual consistency with the
|
||||
// rest of the site rather than a one-off layout. 4 cards instead of
|
||||
// HowItWorks' 3 numbered steps, no connecting arrows (independent facts,
|
||||
// not a sequence).
|
||||
const items = [
|
||||
{ icon: IconMetal, title: "Metallgehäuse", desc: "Robust, wertig und angenehm in der Hand." },
|
||||
{ icon: IconSparkle, title: "Lasergravur", desc: "„Der Eine.“ auf der einen Seite. „einfach produktiv.“ auf der anderen." },
|
||||
{ icon: IconCompact, title: "Kompakt", desc: "Passt ans Notizbuch, auf den Schreibtisch oder in die Tasche." },
|
||||
{ icon: IconEveryday, title: "Für jeden Tag", desc: "Für Notizen, Gedanken, Listen und alles, was du lieber aufschreibst, bevor du es wieder vergisst." },
|
||||
];
|
||||
|
||||
export function Focus() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center mb-10">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was kann er?
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-[42rem] w-full text-justify md:text-center">
|
||||
Schreiben. Und das ziemlich gut. Metallgehäuse, angenehmes Gewicht und eine klassische Kugelschreibermine — kein besonderes Produktivitäts-Gadget, sondern einfach ein guter Stift, den du gern zur Hand nimmst.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-start sm:items-start w-full max-w-[75rem] mx-auto">
|
||||
{items.map(({ icon: Icon, title, desc }) => (
|
||||
<RevealItem key={title} className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none mx-auto">
|
||||
<Icon />
|
||||
<p className="font-semibold text-body text-text-primary">{title}</p>
|
||||
<p className="text-body-sm text-text-primary w-full text-justify md:text-center">{desc}</p>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { ProductGallery } from "../../components/ProductGallery";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
// The one bullet list on this page — after feedback that three separate
|
||||
// lists across Hero/Focus/Pricing was too much, this is the single place
|
||||
// features get enumerated, everywhere else stays prose. A subset of the
|
||||
// "Was kann er?" section's four cards (Focus.tsx) — "Für jeden Tag" is a
|
||||
// use case, not a build fact, so it's covered by the body copy instead.
|
||||
const features = ["Massives Metallgehäuse", "Lasergravur: „Der Eine.“ & „einfach produktiv.“", "Kompakt genug für jede Tasche"];
|
||||
|
||||
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
|
||||
// fill can't be recolored from outside the SVG when loaded via <img
|
||||
// src>/next/image, so this stays an inline stroke path per page.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same "compact early teaser + delivery-time repeated next to every buy
|
||||
// button" reasoning as TodoKartenHero.tsx.
|
||||
export async function Hero() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
|
||||
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
|
||||
const anyLowStock = product
|
||||
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
|
||||
: false;
|
||||
|
||||
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">Der Eine</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)" }}
|
||||
>
|
||||
Der Eine<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Für die eine Sache, die gerade zählt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Manchmal reicht ein Stift und ein Stück Papier. Der Eine. ist ein schlichter Kugelschreiber aus Metall — angenehm in der Hand, klein genug für die Tasche und gemacht für alles, was kurz raus aus dem Kopf und irgendwo hin muss.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{features.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{product && (
|
||||
<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>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton
|
||||
label="Der Eine. bestellen"
|
||||
productId={product.id}
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* max-w cap, not the full lg:col-span-7 width — the source photos
|
||||
are only 338×338px (checked directly against the Payload media
|
||||
API, no larger size exists), so letting ProductGallery stretch
|
||||
to the full column blows them up hard. Capping the display
|
||||
width keeps the upscale factor small enough that it doesn't
|
||||
look broken; it can't fix the underlying resolution, only hide
|
||||
it. Real fix is new source photos, not a layout tweak. */}
|
||||
{product && product.gallery.length > 0 && (
|
||||
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center px-[var(--layout-padding-x)] lg:px-0" delay={0.15}>
|
||||
<div className="w-full max-w-[28rem] mx-auto lg:mx-0">
|
||||
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug } from "../../lib/payload";
|
||||
|
||||
// Replaces the page's final price/buy bar entirely (explicit choice —
|
||||
// Hero.tsx already has the primary buy CTA, this page doesn't need a
|
||||
// second one at the very bottom too). Same "Passend dazu" card markup as
|
||||
// blog/[slug]/page.tsx's own related-product card. Driven by
|
||||
// Products.relatedProduct (2026-08-25, same field/pattern as
|
||||
// Posts.relatedProduct, just product->product instead of post->product) —
|
||||
// reusable on any product page by passing that page's own product slug,
|
||||
// not hardcoded to "todo-karten" like the first version of this component.
|
||||
export async function PasstDazu({ productSlug }: { productSlug: string }) {
|
||||
const product = await getProductBySlug(productSlug);
|
||||
const related = product?.relatedProduct;
|
||||
if (!related?.href) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-12 sm:py-16">
|
||||
<Reveal className="max-w-[48rem] mx-auto">
|
||||
<Link
|
||||
href={related.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={related.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passt dazu:</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
|
||||
<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)" }}
|
||||
>
|
||||
{related.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{related.description}</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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { PasstDazu } from "./components/PasstDazu";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Eine. – Für die eine Sache, die gerade zählt.";
|
||||
const description =
|
||||
"Der Eine. ist ein schlichter Kugelschreiber aus Metall. Angenehm in der Hand, klein genug für die Tasche und gemacht für alles, was kurz raus aus dem Kopf und irgendwo hin muss.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/der-eine",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/der-eine",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function DerEinePage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product
|
||||
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-eine", seller)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<Focus />
|
||||
<PasstDazu productSlug="stift-kugelschreiber" />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
// No product photo exists for this page (unlike /todo-cards, /lebensuhr) —
|
||||
// this is a free monthly ritual, not something with packaging to shoot.
|
||||
// Text + a Caveat-font quote carries the hero instead, same treatment
|
||||
// RichText.tsx's Quote component already uses for a callout line.
|
||||
export function DieSiebenHero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full">
|
||||
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] pt-10 pb-12 sm:pt-12 sm:pb-16 max-w-[46rem] mx-auto text-center sm:items-center">
|
||||
{/* Breadcrumb — left-aligned regardless of the centered content
|
||||
below, same "not part of the centered block" split as
|
||||
/todo-cards's and /lebensuhr's own hero. */}
|
||||
<p className="self-start 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">Die Sieben</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Die Sieben<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p className="font-semibold text-h4 text-text-muted" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das Monatsritual von einfach produktiv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body max-w-[32rem]">
|
||||
Sieben kleine Dinge, die den Monat ein bisschen reicher machen.
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.2] mt-2"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
„Nicht alles, was zählt, steht auf einer ToDo-Liste.“
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
export function Philosophie() {
|
||||
return (
|
||||
<section className="w-full bg-bg-muted">
|
||||
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] py-14 sm:py-16 max-w-[42rem] mx-auto">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Worum es geht
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Am Ende eines Monats weiß ich meistens ziemlich genau, was ich erledigt habe. Rechnungen
|
||||
raus, Elternabend überstanden, drei Umzugskartons endlich ausgepackt, die seit Ostern im
|
||||
Flur standen. Was ich in dieser Zeit eigentlich erlebt habe, kann ich dagegen kaum noch
|
||||
erzählen. Irgendwann saß ich abends auf dem Sofa und mir fiel nichts ein, worüber ich
|
||||
mich in den letzten vier Wochen wirklich gefreut hatte. Nicht, weil nichts Schönes
|
||||
passiert wäre. Sondern weil ich es im Vorbeigehen nicht bemerkt hatte.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Daraus ist Die Sieben entstanden. Jeden Monat gibt es sieben kleine Einladungen – nichts
|
||||
Kompliziertes, kein Kurs, keine App, die man täglich öffnen muss. Mal ist es die Idee,
|
||||
jemandem eine Sprachnachricht zu schicken statt einer Textnachricht. Mal geht es darum,
|
||||
einmal in der Woche ohne Handy zu frühstücken, oder einen Ort in der eigenen Stadt zu
|
||||
besuchen, an dem man noch nie war. Klein genug, dass man es wirklich macht. Konkret genug,
|
||||
dass es nicht bei der guten Absicht steckenbleibt.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Man nimmt sich, was gerade passt. Eine Einladung, drei oder alle sieben – es gibt kein
|
||||
Richtig und kein Falsch dabei, keine Haken, die gesetzt werden müssen. Was nicht passt,
|
||||
lässt man liegen, ohne schlechtes Gewissen. Die Sieben will nicht, dass du produktiver
|
||||
wirst. Sie will, dass du am Ende des Monats mehr zu erzählen hast als nur, was fertig
|
||||
geworden ist.
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary border-l-2 border-brand pl-4"
|
||||
>
|
||||
Es geht nicht darum, möglichst viel zu erleben, sondern die Dinge bewusster
|
||||
wahrzunehmen, die ohnehin schon da sind.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Fragment } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Hand-drawn inline SVGs, brand-orange stroke — same convention as
|
||||
// /lebensuhr's own icon set (IconEnvelope etc.), used here instead of a
|
||||
// photo since there's no product to shoot for a free monthly ritual.
|
||||
function IconHand() {
|
||||
return (
|
||||
<svg width="55" height="48" viewBox="0 0 60 52" fill="none">
|
||||
<rect x="38" y="12" width="6" height="18" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="30" y="6" width="6" height="24" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="22" y="10" width="6" height="20" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="14" y="16" width="6" height="14" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="12" y="28" width="34" height="18" rx="9" stroke="#f6a701" strokeWidth="2" />
|
||||
<rect x="2" y="30" width="14" height="8" rx="4" stroke="#f6a701" strokeWidth="2" transform="rotate(-25 9 34)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEye() {
|
||||
return (
|
||||
<svg width="52" height="34" viewBox="0 0 52 34" fill="none">
|
||||
<path
|
||||
d="M2 17S12 2 26 2s24 15 24 15-10 15-24 15S2 17 2 17Z"
|
||||
stroke="#f6a701"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="26" cy="17" r="7" stroke="#f6a701" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconShare() {
|
||||
return (
|
||||
<svg width="44" height="48" viewBox="0 0 44 48" fill="none">
|
||||
<circle cx="8" cy="24" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="36" cy="8" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<circle cx="36" cy="40" r="6" stroke="#f6a701" strokeWidth="2" />
|
||||
<path d="M13.5 21 30.5 11M13.5 27l17 10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const punkte = [
|
||||
{
|
||||
icon: <IconHand />,
|
||||
title: "Nimm dir, was passt",
|
||||
desc: "Eine Einladung, drei oder alle sieben – es gibt kein Richtig oder Falsch dabei.",
|
||||
},
|
||||
{
|
||||
icon: <IconEye />,
|
||||
title: "Nichts zum Abhaken",
|
||||
desc: "Die Sieben ist kein Programm. Es reicht, die Dinge bewusst wahrzunehmen.",
|
||||
},
|
||||
{
|
||||
icon: <IconShare />,
|
||||
title: "Teilen, wenn du magst",
|
||||
desc: "Manche erzählen anderen von ihren Sieben – nicht um sich zu messen, sondern um sich zu erinnern.",
|
||||
},
|
||||
];
|
||||
|
||||
export function SoFunktionierts() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-14 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
So läuft es ab
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Jeden Monat neu. Ohne Verpflichtung.</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
|
||||
{punkte.map((punkt, i) => (
|
||||
<Fragment key={punkt.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
<div className="flex items-center justify-center h-14 transition-transform duration-300 group-hover:scale-110">
|
||||
{punkt.icon}
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary">{punkt.title}</p>
|
||||
<p className="text-body-sm text-text-primary text-center">{punkt.desc}</p>
|
||||
</RevealItem>
|
||||
{i < punkte.length - 1 && (
|
||||
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DieSiebenHero } from "./components/DieSiebenHero";
|
||||
import { Philosophie } from "./components/Philosophie";
|
||||
import { SoFunktionierts } from "./components/SoFunktionierts";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
|
||||
const title = "Die Sieben – Das Monatsritual von einfach produktiv";
|
||||
const description =
|
||||
"Sieben kleine Dinge, die den Monat ein bisschen reicher machen. Kein Programm, keine Pflicht – nur eine Einladung, bewusster wahrzunehmen, was ohnehin schon da ist.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/die-sieben",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/die-sieben",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
// Rebuilt from the old WordPress page of the same name — a free monthly
|
||||
// ritual, not a product, so unlike /todo-cards or /lebensuhr there's no
|
||||
// photo asset and no pricing/testimonials section here. The WordPress
|
||||
// original also linked out to a monthly-changing "aktuelle Ausgabe" card,
|
||||
// a printable template, and an archive of past months — none of that has
|
||||
// a home in this codebase yet (no CMS collection backs it), so this build
|
||||
// is the evergreen concept page only. Newsletter signup below stands in as
|
||||
// the "stay in the loop" mechanism until/unless a real "monthly edition"
|
||||
// content model gets built.
|
||||
export default function DieSiebenPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<DieSiebenHero />
|
||||
<Philosophie />
|
||||
<SoFunktionierts />
|
||||
<Newsletter
|
||||
title="Nicht verpassen, wenn eine neue Sieben erscheint"
|
||||
description="Ich schreibe dir, sobald es die Sieben für den nächsten Monat gibt – ohne Spam, jederzeit abbestellbar."
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import {
|
||||
renderOrderConfirmationHtml,
|
||||
renderPasswordResetHtml,
|
||||
renderOrderStatusHtml,
|
||||
renderBackInStockHtml,
|
||||
ORDER_STATUS_EMAIL_ICON,
|
||||
SAMPLE_ORDER,
|
||||
SAMPLE_ORDER_MANUAL,
|
||||
type EmailTemplateContent,
|
||||
} from "../../../lib/emailTemplates";
|
||||
import type { EmailTemplateType } from "../../../lib/payload";
|
||||
@@ -34,26 +37,87 @@ export function LiveEmailPreviewClient({
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
// Only order-confirmation has two meaningfully different rendered
|
||||
// states (isManualPayment true/false change which blocks show at all,
|
||||
// not just text) — every other type has one sample and no toggle.
|
||||
const [sampleVariant, setSampleVariant] = useState<"paid" | "manual">("paid");
|
||||
const orderSample = sampleVariant === "paid" ? SAMPLE_ORDER : SAMPLE_ORDER_MANUAL;
|
||||
|
||||
// No real company-settings fetch in this preview context — passing null
|
||||
// falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
|
||||
// inside buildLegalFooterLines(), same shape as the real send just with
|
||||
// placeholder business data.
|
||||
const html =
|
||||
type === "order-confirmation"
|
||||
? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
|
||||
? renderOrderConfirmationHtml(data, orderSample, null)
|
||||
: type === "password-reset"
|
||||
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
|
||||
: renderOrderStatusHtml(
|
||||
data,
|
||||
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
|
||||
SAMPLE_ORDER.orderNumber,
|
||||
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
|
||||
null,
|
||||
);
|
||||
: type === "back-in-stock"
|
||||
? renderBackInStockHtml(
|
||||
data,
|
||||
"ToDo-Karten – Set",
|
||||
"https://einfach-produktiv.mk360.de/todo-cards",
|
||||
null,
|
||||
"https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||||
)
|
||||
: renderOrderStatusHtml(
|
||||
data,
|
||||
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
|
||||
SAMPLE_ORDER.orderNumber,
|
||||
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{type === "order-confirmation" && (
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("paid")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "paid" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Online bezahlt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSampleVariant("manual")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #d1cec4",
|
||||
background: sampleVariant === "manual" ? "#f6a701" : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Vorkasse (Überweisung)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* An iframe, not dangerouslySetInnerHTML into a plain div — `html`
|
||||
here is a full `<body>...</body>` fragment (see emailTemplates.ts's
|
||||
emailShell()), meant to become an actual email document. Dropped
|
||||
directly into this page's own already-existing <body> via
|
||||
dangerouslySetInnerHTML, that's a nested <body> tag — invalid
|
||||
HTML the browser "fixes" unpredictably, which is why this preview
|
||||
used to render broken (wrong background/padding/font, inline
|
||||
styles not applying). An iframe gives the email HTML its own
|
||||
real document, exactly like an actual email client would. */}
|
||||
<iframe
|
||||
srcDoc={`<!DOCTYPE html><html>${html}</html>`}
|
||||
title="E-Mail-Vorschau"
|
||||
style={{ width: "100%", height: "100vh", border: "none", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-cancelled",
|
||||
"order-return-requested",
|
||||
"order-returned",
|
||||
"order-tracking-added",
|
||||
"order-tracking-corrected",
|
||||
"order-delivered",
|
||||
"payment-method-switched",
|
||||
"back-in-stock",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
@@ -23,6 +28,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
"order-cancelled": "Deine Bestellung wurde storniert",
|
||||
"order-return-requested": "Deine Rücksendung wurde angefragt",
|
||||
"order-returned": "Deine Retoure wurde bearbeitet",
|
||||
"order-tracking-added": "Hier ist deine Sendungsnummer",
|
||||
"order-tracking-corrected": "Korrigierte Sendungsnummer",
|
||||
"order-delivered": "Dein Paket ist angekommen",
|
||||
"payment-method-switched": "Erledigt!",
|
||||
"back-in-stock": "Wieder da!",
|
||||
};
|
||||
|
||||
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
|
||||
@@ -31,6 +41,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
// draft:true so an unsaved edit in the admin shows up here immediately;
|
||||
// the actual sent email (orderEmail.ts) always reads the published version
|
||||
// instead.
|
||||
//
|
||||
// "back-in-stock" has its own renderer (renderBackInStockHtml) rather than
|
||||
// sharing the generic order-status one below — it has no order at all, so
|
||||
// no order-number line, and its CTA points at the product page ("Zum
|
||||
// Produkt"), not /konto/bestellungen.
|
||||
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
|
||||
const { type } = await params;
|
||||
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 1.8 KiB |
+57
-53
@@ -2,10 +2,10 @@
|
||||
|
||||
/*
|
||||
* Fluid design tokens — every value below scales continuously via clamp()
|
||||
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
|
||||
* the clamp() floor freezes the value automatically (no separate mobile
|
||||
* media query needed for sizing — only for structural layout changes,
|
||||
* see the `md:` breakpoint usage in components).
|
||||
* between the structural breakpoint (640px) and Desktop (1440px). Below
|
||||
* 640px the clamp() floor freezes the value automatically (no separate
|
||||
* mobile media query needed for sizing — only for structural layout
|
||||
* changes, see the `sm:` breakpoint usage in components).
|
||||
*
|
||||
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
|
||||
* exact numbers the styleguide already used (n times 0.25rem equals the
|
||||
@@ -53,10 +53,10 @@
|
||||
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
|
||||
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
|
||||
|
||||
/* Type scale (fluid 768->1440) */
|
||||
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
|
||||
/* Type scale (fluid 640->1440) */
|
||||
--text-display: clamp(2.75rem, 1.75rem + 2.5vw, 4rem);
|
||||
--text-display--line-height: 1.1;
|
||||
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
|
||||
--text-h-feature: clamp(1.875rem, 1.375rem + 1.25vw, 2.5rem);
|
||||
--text-h-feature--line-height: 1.2;
|
||||
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
|
||||
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
|
||||
@@ -67,21 +67,21 @@
|
||||
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
|
||||
by page-weekly-impulses's hero heading — same 48px role, reused
|
||||
rather than minting a near-duplicate token. */
|
||||
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
|
||||
--text-h-page: clamp(2.5rem, 2.1rem + 1vw, 3rem);
|
||||
--text-h-page--line-height: 1.15;
|
||||
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
|
||||
--text-h1: clamp(1.75rem, 1.35rem + 1vw, 2.25rem);
|
||||
--text-h1--line-height: 1.15;
|
||||
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--text-h2: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
--text-h2--line-height: 1.25;
|
||||
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
|
||||
--text-h-section: clamp(1.4375rem, 1.1875rem + 0.625vw, 1.75rem);
|
||||
--text-h-section--line-height: 1.3;
|
||||
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--text-h-emphasis: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--text-h-emphasis--line-height: 1.4;
|
||||
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
|
||||
--text-h3: clamp(1.1875rem, 1.0375rem + 0.375vw, 1.375rem);
|
||||
--text-h3--line-height: 1.35;
|
||||
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
|
||||
--text-h-small: clamp(1.125rem, 1.025rem + 0.25vw, 1.25rem);
|
||||
--text-h-small--line-height: 1.4;
|
||||
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
|
||||
--text-h4: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
|
||||
--text-h4--line-height: 1.4;
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.6;
|
||||
@@ -90,21 +90,21 @@
|
||||
--text-label: 0.75rem;
|
||||
--text-label--line-height: 1.4;
|
||||
|
||||
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
|
||||
/* Spacing scale (fluid 640->1440) — overrides Tailwind's default n*0.25rem
|
||||
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
|
||||
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
|
||||
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
|
||||
--spacing-3: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--spacing-4: clamp(0.875rem, 0.775rem + 0.25vw, 1rem);
|
||||
--spacing-5: clamp(1rem, 0.8rem + 0.5vw, 1.25rem);
|
||||
--spacing-6: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
|
||||
--spacing-8: clamp(1.5rem, 1.1rem + 1vw, 2rem);
|
||||
--spacing-10: clamp(1.75rem, 1.15rem + 1.5vw, 2.5rem);
|
||||
--spacing-12: clamp(2rem, 1.2rem + 2vw, 3rem);
|
||||
--spacing-16: clamp(2.5rem, 1.3rem + 3vw, 4rem);
|
||||
--spacing-20: clamp(3rem, 1.4rem + 4vw, 5rem);
|
||||
--spacing-24: clamp(3.5rem, 1.5rem + 5vw, 6rem);
|
||||
--spacing-30: clamp(4rem, 1.2rem + 7vw, 7.5rem);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
@@ -116,18 +116,22 @@
|
||||
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
|
||||
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
|
||||
--layout-max-width: 75rem; /* 1200px */
|
||||
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
|
||||
--layout-padding-x: clamp(2rem, -0.4rem + 6vw, 5rem);
|
||||
--layout-grid-gap: clamp(1rem, 0.6rem + 1vw, 1.5rem);
|
||||
--layout-grid-gap-lg: clamp(1.25rem, 0.65rem + 1.5vw, 2rem);
|
||||
|
||||
/* One-off fluid values that aren't part of a broader scale */
|
||||
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
|
||||
--hero-avatar-size: clamp(2.25rem, 1.8rem + 1.125vw, 2.8125rem);
|
||||
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
|
||||
room for the form column at the 768px tablet floor (only 656px of
|
||||
content width available for 612px column plus 56px gap), crushing the
|
||||
input/button/privacy text to near-zero width and overflowing the page.
|
||||
Scales down to 23rem at 768px instead. */
|
||||
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
|
||||
room for the form column at the 640px structural floor (only ~576px of
|
||||
content width available for a 612px column plus gap at that width),
|
||||
crushing the input/button/privacy text to near-zero width and
|
||||
overflowing the page. Scales down to 23rem at 640px instead. Note: the
|
||||
inner input+button row still switches to a row layout at `lg:` (1024px),
|
||||
not at the site's `sm:` (640px) structural line — even at this reduced
|
||||
floor there isn't enough room for input+button side by side below
|
||||
~900px, see Newsletter.tsx's own comment. */
|
||||
--newsletter-copy-width: clamp(23rem, 10.8rem + 30.5vw, 38.25rem);
|
||||
|
||||
/* Divider section icons (arrow separators, sparkle) — previously hard
|
||||
rem values while the "Klarheit/Fokus/Entlastung" text next to them
|
||||
@@ -135,30 +139,30 @@
|
||||
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
|
||||
of this file, floor ~82.5% of the Desktop value per the styleguide's
|
||||
80-85% Mobile-ratio guidance. */
|
||||
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
|
||||
--divider-arrow-w: clamp(2.125rem, 1.725rem + 1vw, 2.625rem);
|
||||
--divider-arrow-h: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
|
||||
--divider-sparkle-w: clamp(1.5625rem, 1.2775rem + 0.7125vw, 1.9188rem);
|
||||
--divider-sparkle-h: clamp(2.0625rem, 1.7075rem + 0.8875vw, 2.5063rem);
|
||||
--divider-sparkle-inner-w: clamp(1.4375rem, 1.2003rem + 0.593vw, 1.734rem);
|
||||
--divider-sparkle-inner-h: clamp(1.9375rem, 1.5875rem + 0.875vw, 2.375rem);
|
||||
/* Own token, mirrors --text-h2's clamp() exactly rather than the Word
|
||||
component reading var(--text-h2) directly — that's what lets the
|
||||
mobile override below shrink just this component's words without
|
||||
touching every other text-h2 heading site-wide. */
|
||||
--divider-word-size: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
|
||||
--divider-word-size: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
|
||||
}
|
||||
|
||||
/* This project's fluid() scale (see fluid.ts) is calibrated for the
|
||||
768-1440px Tablet-Desktop range and floors out at the 768px value for
|
||||
any narrower viewport (clamp()'s MIN bound) — by design, see the other
|
||||
fluid tokens above. Divider is the one spot that floor doesn't work:
|
||||
the "Klarheit → Fokus → Entlastung" phrase plus its connector icons
|
||||
needs ~550px of width to lay out on one row even at the 768px floor
|
||||
size, far more than a phone's ~310px content width. Below Tailwind's
|
||||
sm: breakpoint, shrink these tokens further so the phrase gets much
|
||||
closer to fitting on one row instead of stacking into three separate
|
||||
centered lines (see Divider.tsx's gap-x-3/gap-3 mobile overrides,
|
||||
same breakpoint). Scoped to these component-only tokens, not
|
||||
640-1440px structural-to-Desktop range and floors out at the 640px value
|
||||
for any narrower viewport (clamp()'s MIN bound) — by design, see the
|
||||
other fluid tokens above. Divider is the one spot that floor doesn't
|
||||
work: the "Klarheit → Fokus → Entlastung" phrase plus its connector
|
||||
icons needs ~550px of width to lay out on one row even at the 640px
|
||||
floor size, far more than a phone's ~310px content width. Below
|
||||
Tailwind's sm: breakpoint, shrink these tokens further so the phrase
|
||||
gets much closer to fitting on one row instead of stacking into three
|
||||
separate centered lines (see Divider.tsx's gap-x-3/gap-3 mobile
|
||||
overrides, same breakpoint). Scoped to these component-only tokens, not
|
||||
--text-h2 itself. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect x="0" y="0" width="64" height="64" rx="14" fill="#f6a701" />
|
||||
<text
|
||||
x="32"
|
||||
y="46"
|
||||
text-anchor="middle"
|
||||
font-family="Georgia, 'Playfair Display', 'Times New Roman', serif"
|
||||
font-weight="700"
|
||||
font-style="italic"
|
||||
font-size="42"
|
||||
fill="#1a1a1a"
|
||||
>e</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -35,7 +35,8 @@ import type { TOCSection } from "../../components/SectionTOC";
|
||||
// basis first, and don't assume the old attempt's reasoning was correct.
|
||||
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
|
||||
if (!seller) return [];
|
||||
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
|
||||
const sections = ["Angaben zum Anbieter"];
|
||||
if (seller.vatId) sections.push("Umsatzsteuer");
|
||||
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
|
||||
if (seller.managingDirector) sections.push("Geschäftsführung");
|
||||
sections.push("Verantwortlich für den Inhalt");
|
||||
@@ -75,14 +76,20 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</P>
|
||||
<P>
|
||||
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
{seller.vatId && (
|
||||
<>
|
||||
<Heading>Umsatzsteuer</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
|
||||
<P>{seller.vatId}</P>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{seller.registerCourt && seller.registerNumber && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
|
||||
|
||||
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
|
||||
// eligibility check, mirroring api/account/orders/[orderNumber]/
|
||||
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
|
||||
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
|
||||
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
|
||||
// once this endpoint hands back a clientSecret — the order already
|
||||
// exists, this only changes how it gets paid.
|
||||
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
|
||||
const [state, setState] = useState<
|
||||
| { step: "idle" }
|
||||
| { step: "loading" }
|
||||
| { step: "error"; reason: string }
|
||||
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
|
||||
>({ step: "idle" });
|
||||
|
||||
async function start() {
|
||||
setState({ step: "loading" });
|
||||
try {
|
||||
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
step: "paying",
|
||||
clientSecret: data.clientSecret,
|
||||
orderId: data.orderId,
|
||||
testMode: Boolean(data.testMode),
|
||||
providerReference: data.providerReference,
|
||||
});
|
||||
} catch {
|
||||
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
|
||||
}
|
||||
}
|
||||
|
||||
if (state.step === "paying") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Mit Kreditkarte/PayPal bezahlen</p>
|
||||
<PaymentStep
|
||||
clientSecret={state.clientSecret}
|
||||
orderNumber={orderNumber}
|
||||
orderId={state.orderId}
|
||||
testMode={state.testMode}
|
||||
providerReference={state.providerReference}
|
||||
returnContext="account"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={state.step === "loading"}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
|
||||
</button>
|
||||
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { Footer } from "../../../components/Footer";
|
||||
import { VatBreakdown } from "../../../components/VatBreakdown";
|
||||
import { formatPrice, formatDate } from "../../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
|
||||
import { getProductImagesByIds } from "../../../lib/payload";
|
||||
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { buildTrackingUrl, CARRIER_LABELS, type Carrier } from "@einfach-produktiv/invoicing";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
|
||||
|
||||
// Dynamic (was a static "Bestelldetails" title despite this being a
|
||||
// per-order route) — just formats the already-known order number into
|
||||
@@ -33,7 +35,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
|
||||
if (!order) notFound();
|
||||
|
||||
const address =
|
||||
@@ -46,7 +48,16 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
|
||||
// itself re-checks authoritatively — only offer the button when it
|
||||
// would actually succeed.
|
||||
const canSwitchPayment =
|
||||
order.paymentProvider === "manual" &&
|
||||
order.status === "received" &&
|
||||
(order.paymentStatus === "pending" || order.paymentStatus === "not_applicable") &&
|
||||
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,6 +80,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsart</p>
|
||||
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
|
||||
@@ -77,7 +92,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
|
||||
{order.trackingNumber && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
|
||||
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier as Carrier] ?? order.carrier})` : ""}</p>
|
||||
{(() => {
|
||||
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
|
||||
return trackingUrl ? (
|
||||
@@ -91,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhlReturnLabel && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
|
||||
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
|
||||
Retourenschein herunterladen
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{/* Labeled "Rechnungsadresse" only once there's an actual
|
||||
second (shipping) address to distinguish it from — the
|
||||
@@ -120,6 +144,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
@@ -127,6 +152,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
{(order.shippingContactEmail || order.shippingContactPhone) && (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,6 +175,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</p>
|
||||
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
@@ -220,12 +251,17 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
{(action || canSwitchPayment) && (
|
||||
<div className="flex flex-wrap gap-3 items-start w-full">
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { CustomSelect } from "../../../components/CustomSelect";
|
||||
|
||||
// Three custom-styled dropdowns in a row instead of a wall of filter
|
||||
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
|
||||
// payment-status options + N years, a chip per option read as cluttered
|
||||
// and ate a lot of vertical space) or plain native <select>s (tried next,
|
||||
// also reverted the same day — a native <select>'s open options popup is
|
||||
// rendered by the browser/OS and can't be styled at all, so it looked
|
||||
// completely off-brand next to everything else on the page; see
|
||||
// CustomSelect.tsx for the fully custom-styled replacement). Client
|
||||
// Component only for the onChange→navigate wiring; the actual filtering
|
||||
// still happens server-side in page.tsx via the same URL search params,
|
||||
// so this stays a plain GET-style filter (shareable/bookmarkable/
|
||||
// back-button-safe), not client-side state.
|
||||
export function OrderFilters({
|
||||
statusOptions,
|
||||
paymentStatusOptions,
|
||||
years,
|
||||
}: {
|
||||
statusOptions: { value: string; label: string }[];
|
||||
paymentStatusOptions: { value: string; label: string }[];
|
||||
years: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const paymentStatus = searchParams.get("paymentStatus") ?? "";
|
||||
const year = searchParams.get("year") ?? "";
|
||||
const hasAnyFilter = Boolean(status || paymentStatus || year);
|
||||
const activeCount = [status, paymentStatus, year].filter(Boolean).length;
|
||||
|
||||
// Collapsed by default on mobile — with the account tab bar now also
|
||||
// stacked above this (see KontoShell/AccountNav), 3 full-width dropdowns
|
||||
// always visible left little room for the actual order list. Desktop
|
||||
// (sm+) ignores this entirely and always shows the row inline, same as
|
||||
// before. Starts expanded whenever a filter is already active (arriving
|
||||
// via a shared/bookmarked filtered URL shouldn't hide what's applied) —
|
||||
// a lazy initializer since it only needs to run once, on mount.
|
||||
const [expanded, setExpanded] = useState(() => hasAnyFilter);
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
|
||||
}
|
||||
|
||||
const yearOptions = years.map((y) => ({ value: y, label: y }));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="sm:hidden flex items-center justify-between w-full px-4 py-3 border border-border rounded-sm text-body-sm font-bold text-text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Filter
|
||||
{activeCount > 0 && (
|
||||
<span className="flex items-center justify-center min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-brand text-[0.6875rem] font-bold leading-none text-text-primary">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden>{expanded ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${expanded ? "flex" : "hidden"} sm:flex flex-col sm:flex-row sm:items-center gap-3 w-full mt-3 sm:mt-0`}
|
||||
>
|
||||
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
|
||||
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
|
||||
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
|
||||
{hasAnyFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/konto/bestellungen")}
|
||||
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrders,
|
||||
getCustomerOrderYears,
|
||||
ORDER_STATUS_LABEL,
|
||||
} from "../../lib/customerAuth";
|
||||
import { getOrderFilterEnabled } from "../../lib/payload";
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
|
||||
import { KontoShell } from "../components/KontoShell";
|
||||
import { AccountIdentity } from "../components/AccountIdentity";
|
||||
import { OrderFilters } from "./components/OrderFilters";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
@@ -18,50 +27,105 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoBestellungenPage() {
|
||||
// Mirrors PaymentStatusBadge's own grouping ("Offen" covers both
|
||||
// not_applicable and pending) — see getCustomerOrders's OPEN_PAYMENT_STATUSES.
|
||||
const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
|
||||
open: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ({ value, label }));
|
||||
const PAYMENT_STATUS_OPTIONS = Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default async function KontoBestellungenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; paymentStatus?: string; year?: string }>;
|
||||
}) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orderFilterEnabled = await getOrderFilterEnabled();
|
||||
const rawSearchParams = await searchParams;
|
||||
const status = orderFilterEnabled ? rawSearchParams.status : undefined;
|
||||
const paymentStatus = orderFilterEnabled ? rawSearchParams.paymentStatus : undefined;
|
||||
const year = orderFilterEnabled ? rawSearchParams.year : undefined;
|
||||
|
||||
const [orders, availableYears] = await Promise.all([
|
||||
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
|
||||
getCustomerOrderYears(session.token, session.customer.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[56rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Bestellungen
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
|
||||
</p>
|
||||
<KontoShell>
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Bestellungen
|
||||
</p>
|
||||
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
{orderFilterEnabled && availableYears.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<OrderFilters statusOptions={STATUS_OPTIONS} paymentStatusOptions={PAYMENT_STATUS_OPTIONS} years={availableYears} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{availableYears.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Keine Bestellungen gefunden, die zu den gewählten Filtern passen.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{orders.map((order) => (
|
||||
<Link
|
||||
key={order.orderNumber}
|
||||
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
|
||||
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 sm:gap-x-6 gap-y-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Below sm (640px): 2 columns, plain DOM order —
|
||||
Bestellnummer/Datum, Status/Zahlungsstatus,
|
||||
Artikel/Gesamtbetrag tile into exactly those 3 pairs
|
||||
on their own, no explicit placement needed. From sm
|
||||
up: 4 columns, every cell explicitly pinned to a
|
||||
row+column (sm:row-start-N sm:col-start-N) rather
|
||||
than relying on `order` + Grid's auto-placement —
|
||||
mixing `order` with only SOME items explicitly
|
||||
positioned is a real footgun: the auto-placement
|
||||
cursor for the un-pinned items starts scanning from
|
||||
column 1 again regardless of what's already
|
||||
explicitly placed elsewhere. Pinning every cell's row
|
||||
AND column explicitly removes that ambiguity
|
||||
entirely. Row 1: Bestellnummer/Datum in columns 1-2,
|
||||
left-aligned (centering them in columns 2-3 was
|
||||
tried and reverted — not wanted). Row 2: Artikel/
|
||||
Status/Zahlungsstatus/Gesamtbetrag fill all 4
|
||||
columns. */}
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Datum</p>
|
||||
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-2">
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 ml-auto">
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-3">
|
||||
<p className="text-label text-text-muted">Zahlungsstatus</p>
|
||||
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-1">
|
||||
<p className="text-label text-text-muted">Artikel</p>
|
||||
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-4">
|
||||
<p className="text-label text-text-muted">Gesamtbetrag</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
@@ -70,18 +134,7 @@ export default async function KontoBestellungenPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Profil & Adresse
|
||||
</Link>
|
||||
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Weiter einkaufen
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
</Reveal>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function AccountIdentity({ email, customerNumber }: { email: string; customerNumber: string }) {
|
||||
return (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Eingeloggt als {email} (Kundennummer {customerNumber})
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
// Persistent account navigation — a fixed sidebar (sm+) / horizontal tab
|
||||
// bar (below sm) shown on every logged-in /konto/* page via KontoShell.
|
||||
// Replaces the "Profil & Adresse / Weiter einkaufen / Abmelden" links that
|
||||
// used to live at the bottom of the orders page's content — those became
|
||||
// unreachable without scrolling past an arbitrarily long order list. Being
|
||||
// part of the shell now (not page content), reachability no longer depends
|
||||
// on how much is above it.
|
||||
//
|
||||
// No Merkliste entry — the Navbar's own wishlist heart icon already covers
|
||||
// that (visible at every width, on every page, not just inside /konto/*),
|
||||
// so a second entry here would just be a redundant path to the same page.
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/konto/bestellungen", label: "Bestellungen" },
|
||||
{ href: "/konto/profil", label: "Profil" },
|
||||
] as const;
|
||||
|
||||
export function AccountNav() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Same sequence as the old LogoutButton (now folded in here, its one
|
||||
// call site): clear the local cart (already mirrored server-side by
|
||||
// CartSync, so safe to drop — the next login's mergeServerCartIntoLocal()
|
||||
// restores it), tell already-mounted Client Components (Navbar's
|
||||
// AccountLink) the auth state changed, leave the account area, then
|
||||
// force a fresh Server Component render.
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
clearCart();
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop/tablet — sidebar, sm+ (640px). Sits to the left of the
|
||||
page content inside KontoShell's flex row. */}
|
||||
<nav className="hidden sm:flex sm:flex-col sm:w-48 shrink-0 gap-1">
|
||||
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Mein Konto</p>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-sm text-body-sm font-semibold transition-colors ${
|
||||
pathname.startsWith(item.href) ? "bg-bg-muted text-text-primary" : "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="mt-4 px-3 py-2 text-left rounded-sm text-body-sm font-semibold text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile — horizontal tab bar, below sm. overflow-x-auto rather
|
||||
than wrapping: 3 items at once already fits most phones, and a
|
||||
scrollable single row reads clearly as "more tabs this way"
|
||||
rather than a wrapped second line competing for attention with
|
||||
the page content right below it. */}
|
||||
<nav className="sm:hidden flex items-center gap-2 overflow-x-auto pb-1 -mx-[var(--layout-padding-x)] px-[var(--layout-padding-x)]">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap transition-colors ${
|
||||
pathname.startsWith(item.href) ? "bg-brand text-text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap text-red-600"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { AccountNav } from "./AccountNav";
|
||||
|
||||
// Shared shell for every logged-in /konto/* page (bestellungen, merkliste,
|
||||
// profil) — the <main>/<Footer> wrapping plus the persistent AccountNav
|
||||
// were previously duplicated per page, with the equivalent of the nav
|
||||
// buried as plain links at the bottom of the orders page's own content
|
||||
// (unreachable once the order list got long enough to push it below the
|
||||
// fold). Each page keeps its own auth-check/redirect and data-fetching
|
||||
// exactly as before — this only replaces the outer chrome, not the
|
||||
// page-specific logic each page still needs (e.g. merkliste's return-URL
|
||||
// login redirect, profil's second profile-fetch redirect).
|
||||
//
|
||||
// "Eingeloggt als …" is NOT rendered here (even though `customer` used to
|
||||
// be the only prop this shell needed) — it belongs directly under each
|
||||
// page's own title so it reads as "page title, then who's logged in", not
|
||||
// above the title before the page has even introduced itself. See
|
||||
// AccountIdentity.tsx, rendered by each page right after its own heading.
|
||||
export async function KontoShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 w-full max-w-[75rem] mx-auto px-[var(--layout-padding-x)] pt-8 sm:pt-10 pb-16">
|
||||
<AccountNav />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-6">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const STYLES: Record<string, string> = {
|
||||
export function OrderStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{ORDER_STATUS_LABEL[status] ?? status}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Same visual pattern as OrderStatusBadge — but answers a different
|
||||
// question ("hat Stripe/die Buchhaltung eine Zahlung bestätigt?", not
|
||||
// "wo im Fulfillment steht die Bestellung"). 'not_applicable' (an
|
||||
// Überweisung order before payment is manually reconciled) reads as
|
||||
// "offen", same as a still-'pending' Stripe order — the customer doesn't
|
||||
// need to know the internal distinction between the two.
|
||||
const LABEL: Record<string, string> = {
|
||||
not_applicable: "Offen",
|
||||
pending: "Offen",
|
||||
paid: "Bezahlt",
|
||||
failed: "Fehlgeschlagen",
|
||||
refunded: "Erstattet",
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
const STYLES: Record<string, string> = {
|
||||
not_applicable: "bg-red-50 text-red-600",
|
||||
pending: "bg-red-50 text-red-600",
|
||||
paid: "bg-success-subtle text-success",
|
||||
failed: "bg-red-50 text-red-600",
|
||||
refunded: "bg-bg-muted text-text-light",
|
||||
partially_refunded: "bg-orange-50 text-orange-600",
|
||||
};
|
||||
|
||||
export function PaymentStatusBadge({ paymentStatus }: { paymentStatus: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[paymentStatus] ?? "bg-bg-muted text-text-muted"}`}
|
||||
>
|
||||
{LABEL[paymentStatus] ?? paymentStatus}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
@@ -9,6 +9,12 @@ import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// WishlistButton (and anything else login-gated) sends the shopper back
|
||||
// here with ?redirect=<where they were>, e.g. a product page they
|
||||
// wanted to heart — previously ignored, always landing on
|
||||
// /konto/bestellungen regardless of where the login was triggered from.
|
||||
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +38,7 @@ export function LoginForm() {
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push("/konto/bestellungen");
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Login ist gerade nicht möglich.");
|
||||
@@ -81,11 +87,14 @@ export function LoginForm() {
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Noch kein Konto? Einfach beim{" "}
|
||||
<Link href="/checkout" className="underline hover:text-brand transition-colors">
|
||||
nächsten Einkauf
|
||||
</Link>{" "}
|
||||
anlegen.
|
||||
Noch kein Konto?{" "}
|
||||
<Link
|
||||
href={`/konto/registrieren${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
|
||||
className="underline hover:text-brand transition-colors"
|
||||
>
|
||||
Jetzt anlegen
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./components/LoginForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
@@ -16,7 +17,12 @@ export default function KontoLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<LoginForm />
|
||||
{/* Suspense required — LoginForm uses useSearchParams() (?redirect=,
|
||||
e.g. from WishlistButton's login-gate) which opts any consumer
|
||||
into client-side rendering unless wrapped. */}
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { RevealGroup, RevealItem } from "../../../components/Reveal";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { ProductCard } from "../../../components/ProductCard";
|
||||
import { useWishlist } from "../../../lib/useWishlist";
|
||||
import type { Product } from "../../../lib/payload";
|
||||
|
||||
// Client Component so removing an item (WishlistButton toggling it off)
|
||||
// disappears from this grid immediately — the server-rendered initial
|
||||
// list alone doesn't react to that client-side toggle at all (the parent
|
||||
// page.tsx is a Server Component, its render is fixed at request time).
|
||||
// useWishlist()'s live `items` is the actual source of truth for which
|
||||
// products are still wishlisted; `initialProducts` only supplies the
|
||||
// display data (name/image/price) for whatever numericIds are currently
|
||||
// wishlisted, since the wishlist itself only stores product ids.
|
||||
export function MerklisteGrid({
|
||||
initialProducts,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
defaultTaxRate: number;
|
||||
kleinunternehmer: boolean;
|
||||
}) {
|
||||
const { items } = useWishlist();
|
||||
// Preserve the wishlist's own order (most-recently-added-first, via
|
||||
// `items`) rather than initialProducts' own order. Zipped with the
|
||||
// originating wishlist item (not just the product) so purchasedAt stays
|
||||
// attached — the earlier `.map().filter()` chain that only kept the
|
||||
// product lost that association.
|
||||
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
|
||||
const visibleEntries = items
|
||||
.map((item) => ({ item, product: productsByNumericId.get(item.productId) }))
|
||||
.filter((entry): entry is { item: (typeof items)[number]; product: Product } => Boolean(entry.product));
|
||||
|
||||
if (visibleEntries.length === 0) {
|
||||
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
{visibleEntries.map(({ item, product }) => (
|
||||
<RevealItem key={`${product.id}-${item.variant}`}>
|
||||
<ProductCard
|
||||
product={product}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
wishlistEnabled
|
||||
className="h-full"
|
||||
// Already-purchased takes precedence over the default
|
||||
// Ausverkauft/discount badge — a customer who already bought
|
||||
// this doesn't need a restock notice, they need to know they
|
||||
// already own it (and can still remove it manually via
|
||||
// WishlistButton — this is a status note, not an
|
||||
// auto-removal, see feedback discussion this implements).
|
||||
// undefined (not purchased) falls through to ProductCard's
|
||||
// own default Ausverkauft/discount badge.
|
||||
topLeftBadge={
|
||||
item.purchasedAt ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
|
||||
Gekauft am {formatDate(item.purchasedAt)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
|
||||
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { KontoShell } from "../components/KontoShell";
|
||||
import { AccountIdentity } from "../components/AccountIdentity";
|
||||
import { MerklisteGrid } from "./components/MerklisteGrid";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /konto/bestellungen.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Merkliste",
|
||||
description: "Deine gemerkten Produkte bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoMerklistePage() {
|
||||
const session = await getSessionCustomer();
|
||||
// ?redirect= back to this page — landing on the generic account/orders
|
||||
// overview after logging in from the wishlist icon (the common entry
|
||||
// point, unlike most other /konto/* pages that are only ever reached
|
||||
// once already logged in) would silently lose the wishlist context the
|
||||
// customer actually came here for.
|
||||
if (!session) redirect("/konto/login?redirect=%2Fkonto%2Fmerkliste");
|
||||
|
||||
// The feature can be switched off after a customer already had rows in
|
||||
// their wishlist — 404 rather than showing a stale page for a feature
|
||||
// that's no longer offered, same reasoning as any other feature-flagged
|
||||
// route in this codebase.
|
||||
const wishlistEnabled = await getWishlistEnabled();
|
||||
if (!wishlistEnabled) notFound();
|
||||
|
||||
const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getWishlist(session.token, session.customer.id),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
|
||||
|
||||
return (
|
||||
<KontoShell>
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Meine Merkliste
|
||||
</p>
|
||||
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
|
||||
</div>
|
||||
|
||||
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
</Reveal>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function ProfileForm({
|
||||
shippingCountries: ShippingCountry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
|
||||
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(profile.hasDifferentShippingAddress);
|
||||
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
|
||||
profile.shippingDeliveryMethod ?? "address",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -49,15 +52,32 @@ export function ProfileForm({
|
||||
const body = {
|
||||
firstName: String(form.get("firstName") ?? ""),
|
||||
lastName: String(form.get("lastName") ?? ""),
|
||||
deliveryMethod,
|
||||
// Always a plain street address — same "Rechnungsadresse is never a
|
||||
// Packstation" rule as /checkout's own billing card (an invoice
|
||||
// needs a real postal address). Packstation is only ever offered
|
||||
// below, in the optional "Lieferadresse" section.
|
||||
deliveryMethod: "address" as const,
|
||||
street: String(form.get("street") ?? "") || undefined,
|
||||
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
|
||||
postNumber: String(form.get("postNumber") ?? "") || undefined,
|
||||
zip: String(form.get("zip") ?? ""),
|
||||
city: String(form.get("city") ?? ""),
|
||||
country: String(form.get("country") ?? ""),
|
||||
companyName: String(form.get("companyName") ?? "") || undefined,
|
||||
vatId: String(form.get("vatId") ?? "") || undefined,
|
||||
hasDifferentShippingAddress,
|
||||
shippingFirstName: hasDifferentShippingAddress ? String(form.get("shippingFirstName") ?? "") : undefined,
|
||||
shippingLastName: hasDifferentShippingAddress ? String(form.get("shippingLastName") ?? "") : undefined,
|
||||
shippingCompanyName: hasDifferentShippingAddress ? String(form.get("shippingCompanyName") ?? "") || undefined : undefined,
|
||||
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
|
||||
shippingStreet: hasDifferentShippingAddress ? String(form.get("shippingStreet") ?? "") || undefined : undefined,
|
||||
shippingPackstationNumber: hasDifferentShippingAddress
|
||||
? String(form.get("shippingPackstationNumber") ?? "") || undefined
|
||||
: undefined,
|
||||
shippingPostNumber: hasDifferentShippingAddress ? String(form.get("shippingPostNumber") ?? "") || undefined : undefined,
|
||||
shippingZip: hasDifferentShippingAddress ? String(form.get("shippingZip") ?? "") : undefined,
|
||||
shippingCity: hasDifferentShippingAddress ? String(form.get("shippingCity") ?? "") : undefined,
|
||||
shippingCountry: hasDifferentShippingAddress ? String(form.get("shippingCountry") ?? "") : undefined,
|
||||
shippingContactEmail: hasDifferentShippingAddress ? String(form.get("shippingContactEmail") ?? "") || undefined : undefined,
|
||||
shippingContactPhone: hasDifferentShippingAddress ? String(form.get("shippingContactPhone") ?? "") || undefined : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -83,14 +103,12 @@ export function ProfileForm({
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Mein Profil
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
{profile.email} · Kundennummer {profile.customerNumber}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
|
||||
card title — without it, this section and the "Lieferadresse"
|
||||
one further down read as one undifferentiated form instead of
|
||||
two distinct addresses. */}
|
||||
<p className="font-semibold text-body-sm text-text-primary">Rechnungsadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
|
||||
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
|
||||
@@ -112,43 +130,18 @@ export function ProfileForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("address")}
|
||||
aria-pressed={deliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryMethod("packstation")}
|
||||
aria-pressed={deliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
|
||||
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
{/* Always a plain street address, no Lieferart/Packstation toggle —
|
||||
matches /checkout's own Rechnungsadresse card exactly (an
|
||||
invoice needs a real postal address). Packstation is only ever
|
||||
offered below, in the optional "Lieferadresse" section. */}
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
defaultValue={profile.street ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
|
||||
@@ -164,6 +157,111 @@ export function ProfileForm({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasDifferentShippingAddress}
|
||||
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-body-sm text-text-primary">
|
||||
Abweichende Lieferadresse hinterlegen — wird beim Checkout vorgeschlagen, sobald dort "Abweichende Lieferadresse" aktiviert wird
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Vorname" name="shippingFirstName" type="text" defaultValue={profile.shippingFirstName ?? ""} required />
|
||||
<Field label="Nachname" name="shippingLastName" type="text" defaultValue={profile.shippingLastName ?? ""} required />
|
||||
</div>
|
||||
<Field
|
||||
label="Firma (optional)"
|
||||
name="shippingCompanyName"
|
||||
type="text"
|
||||
defaultValue={profile.shippingCompanyName ?? ""}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("address")}
|
||||
aria-pressed={shippingDeliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${shippingDeliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("packstation")}
|
||||
aria-pressed={shippingDeliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${shippingDeliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<Field
|
||||
label="Straße und Hausnummer"
|
||||
name="shippingStreet"
|
||||
type="text"
|
||||
defaultValue={profile.shippingStreet ?? ""}
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Packstationnummer"
|
||||
name="shippingPackstationNumber"
|
||||
type="text"
|
||||
defaultValue={profile.shippingPackstationNumber ?? ""}
|
||||
required
|
||||
/>
|
||||
<Field label="Postnummer" name="shippingPostNumber" type="text" defaultValue={profile.shippingPostNumber ?? ""} required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="shippingZip" type="text" defaultValue={profile.shippingZip ?? ""} required />
|
||||
<Field label="Ort" name="shippingCity" type="text" defaultValue={profile.shippingCity ?? ""} required />
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select name="shippingCountry" defaultValue={profile.shippingCountry ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field
|
||||
label="Kontakt-E-Mail (optional)"
|
||||
name="shippingContactEmail"
|
||||
type="email"
|
||||
defaultValue={profile.shippingContactEmail ?? ""}
|
||||
/>
|
||||
<Field
|
||||
label="Telefonnummer (optional)"
|
||||
name="shippingContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.shippingContactPhone ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
{success && <p className="text-label text-success">Gespeichert.</p>}
|
||||
|
||||
|
||||
+15
-15
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
|
||||
import { getShippingCountries } from "../../lib/payload";
|
||||
import { KontoShell } from "../components/KontoShell";
|
||||
import { AccountIdentity } from "../components/AccountIdentity";
|
||||
import { ProfileForm } from "./components/ProfileForm";
|
||||
import { PasswordForm } from "./components/PasswordForm";
|
||||
import { VerificationBanner } from "./components/VerificationBanner";
|
||||
@@ -29,19 +29,19 @@ export default async function KontoProfilPage({
|
||||
const { verified } = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
|
||||
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
← Meine Bestellungen
|
||||
</Link>
|
||||
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
|
||||
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
|
||||
<PasswordForm email={profile.email} />
|
||||
<AccountDataSection />
|
||||
<KontoShell>
|
||||
<div className="flex flex-col gap-10 items-start w-full max-w-[40rem]">
|
||||
<div className="flex flex-col gap-1 items-start w-full">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Mein Profil
|
||||
</p>
|
||||
<AccountIdentity email={profile.email} customerNumber={session.customer.customerNumber} />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
|
||||
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
|
||||
<PasswordForm email={profile.email} />
|
||||
<AccountDataSection />
|
||||
</div>
|
||||
</KontoShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
// Standalone registration, independent of checkout — until the Wishlist
|
||||
// shipped, the only way to get a customer account was the inline
|
||||
// registration step inside checkout (see api/checkout/route.ts), which
|
||||
// made sense when an account only ever existed to hold an order. That
|
||||
// stopped being true the moment a shopper could want an account just to
|
||||
// save products to a Wishlist without buying anything yet.
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ firstName, lastName, email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Registrierung fehlgeschlagen.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Registrierung ist gerade nicht möglich.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Konto anlegen
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
<div className="flex gap-4 w-full">
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Vorname</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Nachname</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">E-Mail-Adresse</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Passwort</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "Einen Moment…" : "Konto anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Schon ein Konto?{" "}
|
||||
<Link
|
||||
href={`/konto/login${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
|
||||
className="underline hover:text-brand transition-colors"
|
||||
>
|
||||
Einloggen
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "./components/RegisterForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /konto/login.
|
||||
export const metadata: Metadata = {
|
||||
title: "Konto anlegen",
|
||||
description: "Lege ein Konto bei einfach produktiv an.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function KontoRegistrierenPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{/* Suspense required — RegisterForm uses useSearchParams() (?redirect=),
|
||||
same reasoning as /konto/login's own page. */}
|
||||
<Suspense fallback={null}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+24
-3
@@ -4,7 +4,9 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings } from "./lib/payload";
|
||||
import { KlaroConsentManager } from "./components/KlaroConsentManager";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -54,6 +56,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
card: "summary_large_image",
|
||||
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
|
||||
},
|
||||
verification: seo.googleSearchConsoleVerification
|
||||
? { google: seo.googleSearchConsoleVerification }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,8 +71,22 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const products = await getProducts();
|
||||
const [products, seller, wishlistEnabled, searchEnabled, trackingCodes] = await Promise.all([
|
||||
getProducts(),
|
||||
getCompanySettings(),
|
||||
getWishlistEnabled(),
|
||||
getSearchEnabled(),
|
||||
getTrackingCodes(),
|
||||
]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
|
||||
// instead of repeating the full seller object per page (see
|
||||
// structuredData.ts's own comment). getCompanySettings() is already the
|
||||
// established pattern for a public page needing seller data server-side
|
||||
// (see /impressum) — only non-sensitive fields (name/address/email/
|
||||
// vatID) ever make it into the rendered schema, never iban/bic.
|
||||
const organizationSchema = buildOrganizationSchema(seller);
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -75,9 +94,11 @@ export default async function RootLayout({
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased scroll-smooth`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<KlaroConsentManager codes={trackingCodes} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
</body>
|
||||
|
||||
+3
-3
@@ -13,11 +13,11 @@ function LockIcon() {
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.</p>;
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">{successMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -60,7 +60,7 @@ export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabe
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
onChange={(e) => handleConsentChange(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
@@ -18,12 +18,12 @@ export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/challenge",
|
||||
canonical: "/lebensuhr",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/challenge",
|
||||
url: "/lebensuhr",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
twitter: {
|
||||
@@ -152,7 +152,7 @@ export default async function ChallengePage() {
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">7-Tage-Challenge</span>
|
||||
<span className="text-text-primary">Lebensuhr</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space,
|
||||
@@ -6,10 +6,12 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
id: 1,
|
||||
slug: "starter-set",
|
||||
name: "Starter-Set",
|
||||
sku: null,
|
||||
price: 29.9,
|
||||
active: true,
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
trackInventory: false,
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { Product } from "../payload";
|
||||
|
||||
const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
numericId: 1,
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
price: 12.9,
|
||||
compareAtPrice: null,
|
||||
image: "",
|
||||
gallery: [],
|
||||
href: null,
|
||||
active: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -17,11 +19,15 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
spotlightShowWishlist: false,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
categories: [],
|
||||
relatedProduct: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ export async function sendVerificationEmail(to: string, firstName: string, token
|
||||
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
|
||||
const seller = await getSellerForInvoice();
|
||||
await transport.sendMail({
|
||||
// See orderEmail.ts's own comment on why only the display name is
|
||||
// dynamic — the address stays admin@mk360.de until sellerEmail's domain
|
||||
// is confirmed SPF-authorized on the Hostinger account.
|
||||
from: `"${seller?.sellerName ?? "einfach produktiv"}" <admin@mk360.de>`,
|
||||
// See orderEmail.ts's own comment — From now matches sellerEmail
|
||||
// itself since the SMTP account moved to a mailbox on that domain,
|
||||
// and emailFromName/emailFromAddress let an admin override both.
|
||||
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
|
||||
replyTo: seller?.sellerEmail || undefined,
|
||||
to,
|
||||
subject: "Bitte bestätige deine E-Mail-Adresse",
|
||||
|
||||
+33
-7
@@ -15,11 +15,36 @@
|
||||
// Switched 2026-07-25 per explicit request once the confirmation-email
|
||||
// template existed to point templateId at.
|
||||
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
|
||||
const BREVO_CONTACTS_URL = "https://api.brevo.com/v3/contacts";
|
||||
|
||||
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
|
||||
export type BrevoSyncResult = { ok: true; alreadySubscribed?: boolean } | { ok: false; reason: string };
|
||||
|
||||
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
|
||||
|
||||
// Checked before calling doubleOptinConfirmation — that endpoint gives
|
||||
// no way to tell "brand new signup" apart from "already confirmed,
|
||||
// resending the same mail again" (verified directly: calling it a
|
||||
// second time for an already-subscribed contact still returns a plain
|
||||
// 201, same as the first time). `listIds` on a Brevo contact is only
|
||||
// populated once double opt-in actually confirms (never for a merely
|
||||
// *requested*, still-pending one), so its presence here is a reliable
|
||||
// "already subscribed to this list" signal. Fails open on any error —
|
||||
// this check is a UX nicety (skip an unnecessary resend, show a
|
||||
// friendlier message), never a reason to block a real signup attempt.
|
||||
async function isAlreadySubscribed(email: string, apiKey: string, listId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BREVO_CONTACTS_URL}/${encodeURIComponent(email)}`, {
|
||||
headers: { "api-key": apiKey },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return false; // 404 (never signed up before) or any transient error
|
||||
const contact: { listIds?: number[] } = await res.json();
|
||||
return (contact.listIds ?? []).includes(Number(listId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// `source` becomes a Brevo contact attribute so campaigns/segments can
|
||||
// tell a checkout opt-in apart from the standalone signup forms without
|
||||
// needing separate lists.
|
||||
@@ -33,6 +58,11 @@ export async function upsertNewsletterContact(
|
||||
if (!apiKey || !listId || !templateId) {
|
||||
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
|
||||
}
|
||||
|
||||
if (await isAlreadySubscribed(email, apiKey, listId)) {
|
||||
return { ok: true, alreadySubscribed: true };
|
||||
}
|
||||
|
||||
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
|
||||
|
||||
try {
|
||||
@@ -52,12 +82,8 @@ export async function upsertNewsletterContact(
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
// 201 Created is this endpoint's success status (unlike the plain
|
||||
// contacts upsert this replaced, which used 204). A contact who's
|
||||
// already confirmed-and-subscribed re-submitting the form is not
|
||||
// treated as an error either — Brevo resends the confirmation email
|
||||
// in that case rather than erroring, which is an acceptable no-op
|
||||
// resend from this app's point of view (matches the previous
|
||||
// endpoint's "always succeeds for an existing contact too" behavior).
|
||||
// contacts upsert this replaced, which used 204). The already-
|
||||
// subscribed case is handled above, before this call ever fires.
|
||||
if (res.ok || res.status === 201) return { ok: true };
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
|
||||
|
||||
+69
-8
@@ -18,7 +18,8 @@ function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CART_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -90,7 +91,8 @@ export function getCart(): CartItem[] {
|
||||
if (raw === cachedRaw) return cachedItems;
|
||||
cachedRaw = raw;
|
||||
try {
|
||||
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
const parsed = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
cachedItems = Array.isArray(parsed) ? parsed : EMPTY_CART;
|
||||
} catch {
|
||||
cachedItems = EMPTY_CART;
|
||||
}
|
||||
@@ -125,17 +127,76 @@ export function useCart(): CartItem[] {
|
||||
// Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's
|
||||
// inline login toggle) — folds whatever was saved server-side into the
|
||||
// local cart by quantity (addToCart adds to an existing line rather than
|
||||
// overwriting it), so items added before logging in aren't lost. CartSync
|
||||
// then picks up the resulting change and pushes the merged cart back to
|
||||
// the server on its own, closing the loop without a separate save call here.
|
||||
// overwriting it), so items added as a guest before logging in aren't lost.
|
||||
// This only stays correct because LogoutButton.tsx clears the local cart on
|
||||
// logout — the local cart is always either empty (no guest additions since
|
||||
// the last logout) or holds only genuinely new guest-session items, never a
|
||||
// stale copy of what's already in the server cart, so this add never
|
||||
// double-counts.
|
||||
//
|
||||
// Always pushes the resulting cart to the server itself at the end,
|
||||
// explicitly — does NOT rely on CartSync's own change-triggered push.
|
||||
// That was the bug (confirmed live 2026-08-01): logging in with an empty
|
||||
// server cart but a non-empty guest cart means the merge loop below never
|
||||
// actually runs (nothing in `data.cart` to add), so `writeCart` is never
|
||||
// called, no change event fires, and CartSync's effect — which only
|
||||
// re-fires when the cart *reference* actually changes — never pushes the
|
||||
// guest cart to the server at all. The cart looked fine on the device
|
||||
// that was already logged in, but never reached any other device. An
|
||||
// explicit push here doesn't depend on anything having changed.
|
||||
export async function mergeServerCartIntoLocal(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/account/cart");
|
||||
if (res.ok) {
|
||||
const data: { cart?: CartItem[] } = await res.json();
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — a failed merge just means the server-side cart stays
|
||||
// as it was; nothing local is lost either way. Still fall through to
|
||||
// push below — the local (guest) cart is genuine either way.
|
||||
}
|
||||
try {
|
||||
await fetch("/api/account/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cart: readCart() }),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — CartSync's own push will retry on the next cart
|
||||
// change, or the next login-time merge tries again.
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-device sync for an *already* logged-in session — CartSync.tsx
|
||||
// only ever pushes local→server, and mergeServerCartIntoLocal() above only
|
||||
// ever runs at the moment of a fresh login/register, so a customer already
|
||||
// signed in on device B never picked up a change made on device A. This
|
||||
// covers that gap: called on mount and on window focus (CartSync.tsx), not
|
||||
// just at login.
|
||||
//
|
||||
// Replaces the local cart outright with the server's — NOT an additive
|
||||
// merge like mergeServerCartIntoLocal() above (that one is only safe
|
||||
// right after login, when the local cart can't yet overlap the server
|
||||
// one, see its own comment). An earlier version of this function only
|
||||
// added/updated matching lines and never removed a local line the server
|
||||
// no longer had, which meant a *removal* on device A never reached device
|
||||
// B (confirmed live 2026-08-01 — adding synced, removing didn't). Server
|
||||
// wins outright, including "server cart is now empty."
|
||||
//
|
||||
// Trade-off: a local addition made in the last few hundred ms — after
|
||||
// this fetch went out but before CartSync's own 800ms debounce pushed it
|
||||
// — could theoretically get overwritten by a pull that lands in between.
|
||||
// Narrow enough (and self-healing on the next change/focus) to accept,
|
||||
// consistent with every other "best-effort" sync path in this file.
|
||||
export async function pullServerCart(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/account/cart");
|
||||
if (!res.ok) return;
|
||||
const data: { cart?: CartItem[] } = await res.json();
|
||||
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
|
||||
writeCart(data.cart ?? []);
|
||||
} catch {
|
||||
// Best-effort — a failed merge just means the server-side cart stays
|
||||
// as it was; nothing local is lost either way.
|
||||
// Best-effort — a failed pull just means this device doesn't see
|
||||
// another device's changes yet; nothing local is lost either way.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export function computeSubtotal(items: CartLine[]): number {
|
||||
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
|
||||
}
|
||||
|
||||
// A cart only needs a shipping line at all if at least one item doesn't
|
||||
// opt out via Products.noShippingCost (e.g. a purely digital download) —
|
||||
// mirrors api/checkout/route.ts's own hasShippableItem check, which is
|
||||
// the actual charged amount; this is only the storefront's estimate/
|
||||
// display before that. A single non-exempt item still triggers normal
|
||||
// shipping for the whole cart, this never partially discounts it.
|
||||
export function cartHasShippableItem(items: CartLine[]): boolean {
|
||||
return items.some(({ product }) => !product.noShippingCost);
|
||||
}
|
||||
|
||||
export type CartTotals = {
|
||||
subtotal: number;
|
||||
/** compareAtPrice-based per-product savings — already excluded from
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user