Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff6118a778 | |||
| 3328f30a06 | |||
| df4bd700e6 | |||
| bab2c916be | |||
| 4e22942031 | |||
| 740b791e5e | |||
| bb3f94d39e | |||
| d48a00973d | |||
| 7b4b54a9ac | |||
| 797d9d42fe | |||
| b1b1aa2037 | |||
| 4f95a347dc | |||
| 268f2e841d | |||
| 8d4f167374 | |||
| 6a6d50abdb | |||
| bca29ab7a3 | |||
| 3da8b75395 | |||
| f20a02dfa2 | |||
| 55de9b3e29 | |||
| a3fb864f7d | |||
| 9f92f7324a | |||
| 9bfd0affd0 | |||
| f0aec851d3 | |||
| 36bfa3bd84 | |||
| ba830947d2 | |||
| 89dd11bf77 | |||
| 14b1d5685c | |||
| 19f6559c29 | |||
| 0c884a73ec | |||
| f035ccaacc | |||
| c60e936b7e | |||
| 789a818c6b | |||
| 6a4539bf9b | |||
| 0c849c9525 | |||
| 47d03dd61b | |||
| 50db2fcb4b | |||
| 0c9050cc8a | |||
| 80b82e0117 | |||
| 4d2e78dd2a | |||
| ba4d7b443f | |||
| 6802636d1d | |||
| e48107470a | |||
| 2d88fb86a1 | |||
| 1212b9d115 | |||
| 97833ab2bb | |||
| 03a29cf93c | |||
| bd884357b0 | |||
| b5ad13cf43 | |||
| a3912a47c4 | |||
| 0c3f7ddf2e | |||
| b70aefd5cc | |||
| 21c4e9f007 | |||
| 039b8ba28d | |||
| 05a3b009d3 | |||
| 66ac184a6f | |||
| 21e150f177 | |||
| 2df4dc7ea7 | |||
| d72102bdf0 | |||
| ddf842f910 | |||
| 02c3fef9b2 | |||
| 5e198a30c6 | |||
| 44029cdaad | |||
| b2bffd13a3 | |||
| a50524832e | |||
| dc6b61324f | |||
| 43944d8cc8 | |||
| 7a9fed6f95 | |||
| b3c44e3082 | |||
| 39782eeab9 | |||
| c5500bcc97 | |||
| a935357e70 | |||
| 07c70c86f5 | |||
| d7e7928dfc | |||
| e61a62e579 | |||
| a801d41d79 | |||
| 51fee198f4 | |||
| 91f6fef6ea | |||
| d249614027 | |||
| e50d43ea44 | |||
| f144ad25f2 | |||
| 179b59d73d | |||
| 04cc69f98b | |||
| 6102fef6d1 | |||
| 5232b14cdf | |||
| fa02d95dff | |||
| ec75a480bd | |||
| f0df359db4 | |||
| adca6e0f64 | |||
| df05ea5358 | |||
| 7f37f111e8 | |||
| 516945fc8c | |||
| 06abf1a6ae | |||
| 028a1fc4ec | |||
| 37b710c933 | |||
| 4f2f137b27 | |||
| 26ae4a15f4 | |||
| 2d6cff9f40 | |||
| 225a8567a9 | |||
| a6edd7ff61 | |||
| 9c9f0b02c0 | |||
| af00fd091f | |||
| a560ec9434 | |||
| cc9da6ac6d | |||
| a72aea1070 | |||
| 8273989d01 | |||
| dee72641a6 | |||
| 3bde1d61ff | |||
| ec016d55d4 | |||
| a17a9fcae0 | |||
| 819f87cdeb | |||
| 90be4696af | |||
| f973eccca6 | |||
| 4293df674f | |||
| 7b35b3e55b | |||
| 2a14e18aa1 | |||
| 0c72b70de7 | |||
| d472eb546f | |||
| 754966c8ba | |||
| b6b81be901 | |||
| f356216302 | |||
| f2c05deaed | |||
| 59b73f6f1f | |||
| 8bfbf74693 | |||
| ed58a65b8f | |||
| 2b270cf299 | |||
| 3cf45cfb16 | |||
| e4a5ec1775 | |||
| 0664e11d3b | |||
| f5df384b13 | |||
| 7a3ae0ea53 |
@@ -0,0 +1,5 @@
|
||||
# npm 12+ disables fetching git-protocol dependencies by default
|
||||
# (allow-git=none). @einfach-produktiv/invoicing is declared directly in
|
||||
# this file's own package.json (not a transitive dependency), so "root" is
|
||||
# the narrowest setting that still allows it.
|
||||
allow-git=root
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# git — needed for `npm ci` to fetch @einfach-produktiv/invoicing, a git-URL
|
||||
# dependency (see package.json); Alpine's base image doesn't ship it.
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AGB",
|
||||
description: "Allgemeine Geschäftsbedingungen von einfach produktiv.",
|
||||
alternates: { canonical: "/agb" },
|
||||
};
|
||||
|
||||
export default async function AgbPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("agb", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<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">AGB</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Allgemeine Geschäftsbedingungen
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
|
||||
{/* Static, not part of the CMS content — same reasoning as
|
||||
the Impressum/Datenschutz pages' own callout cards. */}
|
||||
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
|
||||
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Unser Anspruch
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Wir entwickeln Produkte, die dich im Alltag wirklich weiterbringen – mit Klarheit,
|
||||
Qualität und einem bewussten Umgang mit Ressourcen.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
{page ? (
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../../lib/cart";
|
||||
import { getServerCart, getSessionCustomer, saveServerCart } from "../../../lib/customerAuth";
|
||||
import { fetchProductsBySlug } from "../../../lib/productsServer";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ cart: [] }, { status: 401 });
|
||||
const cart = await getServerCart(session.token);
|
||||
return NextResponse.json({ cart });
|
||||
}
|
||||
|
||||
// Called by CartSync.tsx (debounced) on every local cart change while a
|
||||
// session is active — keeps the server-side mirror current so the cart
|
||||
// follows the customer across devices. Silently no-ops when logged out;
|
||||
// the caller doesn't care either way.
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const cart: CartItem[] = Array.isArray(body?.cart) ? body.cart : [];
|
||||
|
||||
const productsBySlug = await fetchProductsBySlug();
|
||||
const lines: { productId: number; productSlug: string; quantity: number; variant?: string }[] = [];
|
||||
for (const item of cart) {
|
||||
const product = productsBySlug.get(item.id);
|
||||
if (product) lines.push({ productId: product.id, productSlug: product.slug, quantity: item.qty, variant: item.variant });
|
||||
}
|
||||
|
||||
const ok = await saveServerCart(session.token, session.customer.id, lines);
|
||||
return NextResponse.json({ ok });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkEmailExists } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
// Called on blur from the checkout email field (CheckoutContent.tsx) —
|
||||
// lets the form switch to login mode as soon as an existing account is
|
||||
// detected, instead of only after a failed registration attempt. Same
|
||||
// exposure as the registration-collision case already had (both reveal
|
||||
// "this email has an account"), so rate-limited the same way rather than
|
||||
// treated as a new problem.
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`check-email:${getClientIp(request)}`, { limit: 20, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ exists: false }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
if (!email) return NextResponse.json({ exists: false });
|
||||
|
||||
const exists = await checkEmailExists(email);
|
||||
return NextResponse.json({ exists });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteCustomerAccount, getSessionCustomer, loginCustomer, clearSessionCookie } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR self-service deletion. Password re-verified here (not just trusting
|
||||
// the active session) before anything is deleted — same reasoning as
|
||||
// changeCustomerPassword. See customerAuth.ts's deleteCustomerAccount for
|
||||
// what actually survives (past orders, anonymized-by-omission — their own
|
||||
// snapshot fields aren't touched, only the account/login disappears).
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!password) return NextResponse.json({ ok: false, reason: "Bitte dein Passwort zur Bestätigung eingeben." }, { status: 400 });
|
||||
|
||||
const verify = await loginCustomer({ email: session.customer.email, password });
|
||||
if (!verify.ok) return NextResponse.json({ ok: false, reason: "Passwort ist falsch." }, { status: 400 });
|
||||
|
||||
const deleted = await deleteCustomerAccount(verify.token, session.customer.id);
|
||||
if (!deleted) return NextResponse.json({ ok: false, reason: "Konto konnte nicht gelöscht werden." }, { status: 500 });
|
||||
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerProfile, getCustomerOrders, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR data portability (Art. 20) — a full, structured, machine-readable
|
||||
// export of everything tied to the account: profile + every order's full
|
||||
// detail (not just the summary list, so this is a genuinely complete
|
||||
// export, not a teaser).
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile,
|
||||
orders: orders.filter(Boolean),
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": 'attachment; filename="meine-daten.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requestPasswordReset } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
// Always responds the same way regardless of whether the email exists —
|
||||
// see requestPasswordReset()'s own comment. Rate-limited a bit tighter
|
||||
// than login/register (5/15min) since there's no secondary defense here
|
||||
// the way Payload's own per-account lockout backs up the login route.
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`forgot-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
if (email) await requestPasswordReset(email);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Complements Payload's own per-account lockout (5 attempts / 10min,
|
||||
// see Customers.ts in the Payload repo) with a per-IP layer — that one
|
||||
// alone doesn't stop someone spraying single attempts across many
|
||||
// different email addresses from the same IP.
|
||||
if (!checkRateLimit(`login:${getClientIp(request)}`, { limit: 10, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte E-Mail und Passwort angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await loginCustomer({ email, password });
|
||||
if (!result.ok) return NextResponse.json(result, { status: 401 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSessionCookie } from "../../../lib/customerAuth";
|
||||
|
||||
export async function POST() {
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ customer: null }, { status: 401 });
|
||||
return NextResponse.json({ customer: session.customer });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateCorrectionInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
import { getProductImagesByIds } from "../../../../../lib/payload";
|
||||
|
||||
// On-demand download for "Stornorechnung/Gutschrift herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber]. The real document was generated once
|
||||
// by Payload's Orders.ts afterChange hook and emailed at the moment of the
|
||||
// status change — this regenerates the identical PDF from the order's own
|
||||
// stored correctionInvoiceNumber/correctionInvoiceIssuedAt (immutable once
|
||||
// set) rather than storing the file anywhere, same approach as the
|
||||
// original invoice's own download route.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (!order.correctionInvoiceNumber || !order.correctionInvoiceIssuedAt || !order.invoiceNumber || !order.invoiceIssuedAt) {
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt keine Korrekturrechnung vor." }, { status: 404 });
|
||||
}
|
||||
const kind = order.status === "returned" ? "gutschrift" : "storno";
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const pdf = await generateCorrectionInvoicePdf(
|
||||
kind,
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
correctionInvoiceNumber: order.correctionInvoiceNumber,
|
||||
correctionInvoiceIssuedAt: order.correctionInvoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Korrekturrechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
const filename = kind === "storno" ? `Stornorechnung-${order.correctionInvoiceNumber}.pdf` : `Gutschrift-${order.correctionInvoiceNumber}.pdf`;
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
|
||||
import { generateInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
|
||||
import { getProductImagesByIds } from "../../../../../lib/payload";
|
||||
|
||||
// On-demand download for "Rechnung herunterladen" on
|
||||
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
|
||||
// the checkout-time attachment (app/lib/orderEmail.ts), so a re-download
|
||||
// always matches what was emailed; invoiceNumber itself never changes
|
||||
// (assigned once, server-side, at order creation — see Orders.ts).
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (!order.invoiceNumber || !order.invoiceIssuedAt) {
|
||||
return NextResponse.json({ ok: false, reason: "Für diese Bestellung liegt noch keine Rechnung vor." }, { status: 404 });
|
||||
}
|
||||
|
||||
const seller = await getSellerForInvoice();
|
||||
// On-demand re-download has no imageUrl snapshot to fall back to like
|
||||
// the checkout-time attachment does (order.items only stores a numeric
|
||||
// product id, see CustomerOrderItem) — resolved fresh here instead.
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const pdf = await generateInvoicePdf(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: order.customerFirstName,
|
||||
customerLastName: order.customerLastName,
|
||||
companyName: order.companyName,
|
||||
vatId: order.vatId,
|
||||
vatExempt: order.vatExempt,
|
||||
kleinunternehmer: order.kleinunternehmer,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
street: order.street,
|
||||
packstationNumber: order.packstationNumber,
|
||||
postNumber: order.postNumber,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
hasDifferentShippingAddress: order.hasDifferentShippingAddress,
|
||||
shippingFirstName: order.shippingFirstName,
|
||||
shippingLastName: order.shippingLastName,
|
||||
shippingDeliveryMethod: order.shippingDeliveryMethod,
|
||||
shippingStreet: order.shippingStreet,
|
||||
shippingPackstationNumber: order.shippingPackstationNumber,
|
||||
shippingPostNumber: order.shippingPostNumber,
|
||||
shippingZip: order.shippingZip,
|
||||
shippingCity: order.shippingCity,
|
||||
shippingCountry: order.shippingCountry,
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
discountAmount: order.discountAmount,
|
||||
discountCode: order.discountCode,
|
||||
total: order.total,
|
||||
},
|
||||
seller,
|
||||
);
|
||||
if (!pdf) return NextResponse.json({ ok: false, reason: "Rechnung konnte nicht erzeugt werden." }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="Rechnung-${order.invoiceNumber}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrderDetail,
|
||||
requestOrderStatusChange,
|
||||
customerOrderAction,
|
||||
type CustomerOrderItem,
|
||||
} from "../../../../lib/customerAuth";
|
||||
|
||||
// The real security boundary is Orders.ts's beforeChange hook in Payload
|
||||
// (only `status`/`returnReason`/items' `returnQuantity` can change, only
|
||||
// via an allowed transition) — the checks here are just for a friendlier
|
||||
// error message than a bare 403 when the request is malformed or stale
|
||||
// (e.g. two tabs open, order shipped in the meantime, a quantity that no
|
||||
// longer fits).
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const action = body?.action;
|
||||
if (action !== "cancel" && action !== "request-return") {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
|
||||
}
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (customerOrderAction(order.status) !== action) {
|
||||
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === "cancel") {
|
||||
const result = await requestOrderStatusChange(session.token, order.id, "cancel");
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
|
||||
// request-return: partial returns supported — the client sends
|
||||
// { product, returnQuantity } per line it wants to return (0 or
|
||||
// omitted for lines being kept). Reconstruct the order's FULL items
|
||||
// array here (see requestOrderStatusChange's own comment on why a
|
||||
// sparse patch doesn't work), validating each requested quantity
|
||||
// against what was actually ordered.
|
||||
const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : "";
|
||||
if (!returnReason) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 });
|
||||
}
|
||||
const requestedQuantities = new Map<number, number>();
|
||||
if (Array.isArray(body?.returnItems)) {
|
||||
for (const line of body.returnItems) {
|
||||
const product = Number(line?.product);
|
||||
const returnQuantity = Number(line?.returnQuantity);
|
||||
if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) {
|
||||
requestedQuantities.set(product, returnQuantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requestedQuantities.size === 0) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const items: CustomerOrderItem[] = order.items.map((item) => {
|
||||
const requested = requestedQuantities.get(item.product) ?? 0;
|
||||
if (requested > item.quantity) {
|
||||
throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 });
|
||||
}
|
||||
return { ...item, returnQuantity: requested };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items });
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCustomerOrders, getSessionCustomer } from "../../../lib/customerAuth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ orders: [] }, { status: 401 });
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
return NextResponse.json({ orders });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
||||
const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte aktuelles und ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await changeCustomerPassword(session.customer.email, currentPassword, newPassword);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ profile: null }, { status: 401 });
|
||||
return NextResponse.json({ profile: session.customer });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
!firstName ||
|
||||
typeof lastName !== "string" ||
|
||||
!lastName ||
|
||||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
|
||||
typeof zip !== "string" ||
|
||||
!zip ||
|
||||
typeof city !== "string" ||
|
||||
!city ||
|
||||
typeof country !== "string" ||
|
||||
!country
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
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;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(session.token, session.customer.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
deliveryMethod,
|
||||
street,
|
||||
packstationNumber,
|
||||
postNumber,
|
||||
zip,
|
||||
city,
|
||||
country,
|
||||
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
|
||||
vatId: normalizedVatId,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`register:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, email, password } = body ?? {};
|
||||
if (
|
||||
typeof firstName !== "string" ||
|
||||
typeof lastName !== "string" ||
|
||||
typeof email !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
!firstName ||
|
||||
!lastName ||
|
||||
!email ||
|
||||
!password
|
||||
) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Felder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await registerCustomer({ firstName, lastName, email, password });
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, resendVerificationEmail } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`resend-verification:${getClientIp(request)}`, { limit: 3, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
if (session.customer.emailVerified) return NextResponse.json({ ok: true });
|
||||
|
||||
const ok = await resendVerificationEmail(session);
|
||||
return NextResponse.json({ ok }, { status: ok ? 200 : 500 });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resetPassword, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`reset-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const token = typeof body?.token === "string" ? body.token : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!token || !password || password.length < 8) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await resetPassword(token, password);
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
|
||||
await setSessionCookie(result.token);
|
||||
return NextResponse.json({ ok: true, customer: result.customer });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyEmailByToken } from "../../../lib/customerAuth";
|
||||
|
||||
// Entered from the link in the verification email — no session exists
|
||||
// yet at this point. See Customers.ts's own comment on why this is a
|
||||
// non-blocking flag (login already works before this is ever clicked).
|
||||
//
|
||||
// Base URL is deliberately NOT built from request.url — behind Caddy's
|
||||
// reverse proxy that reflects the container's internal address
|
||||
// (0.0.0.0:3000, confirmed live), not the public domain, and would send a
|
||||
// real browser to an unreachable address. Same hardcoded-origin approach
|
||||
// as Customers.ts's own FRONTEND_URL default on the Payload side.
|
||||
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token) return new Response("Ungültiger Link.", { status: 400 });
|
||||
|
||||
const ok = await verifyEmailByToken(token);
|
||||
const url = new URL("/konto/profil", SITE_URL);
|
||||
url.searchParams.set("verified", ok ? "1" : "0");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { describeBundleContents } from "../../lib/bundleContents";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
|
||||
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
// into results like 84.30000000000001 — cosmetically invisible wherever
|
||||
// formatPrice()'s toFixed(2) already rounds for display, but stored as-is
|
||||
// on the order otherwise, which is where it actually showed up (Payload's
|
||||
// admin list/edit view for a plain number field has no such formatting).
|
||||
// Rounded once here, right before persisting, rather than chasing it down
|
||||
// at every downstream display site.
|
||||
function roundMoney(amount: number): number {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
|
||||
type CheckoutBody = {
|
||||
cart: CartItem[];
|
||||
shippingMethodId: number;
|
||||
paymentMethodId: number;
|
||||
discountCode: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
companyName?: string;
|
||||
vatId?: string;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string;
|
||||
packstationNumber?: string;
|
||||
postNumber?: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
hasDifferentShippingAddress?: boolean;
|
||||
shippingFirstName?: string;
|
||||
shippingLastName?: string;
|
||||
shippingDeliveryMethod?: "address" | "packstation";
|
||||
shippingStreet?: string;
|
||||
shippingPackstationNumber?: string;
|
||||
shippingPostNumber?: string;
|
||||
shippingZip?: string;
|
||||
shippingCity?: string;
|
||||
shippingCountry?: string;
|
||||
newsletterOptIn: boolean;
|
||||
};
|
||||
|
||||
function isValidBody(body: unknown): body is CheckoutBody {
|
||||
const b = body as Partial<CheckoutBody> | null;
|
||||
return Boolean(
|
||||
b &&
|
||||
Array.isArray(b.cart) &&
|
||||
b.cart.length > 0 &&
|
||||
typeof b.shippingMethodId === "number" &&
|
||||
typeof b.paymentMethodId === "number" &&
|
||||
typeof b.firstName === "string" &&
|
||||
b.firstName &&
|
||||
typeof b.lastName === "string" &&
|
||||
b.lastName &&
|
||||
typeof b.email === "string" &&
|
||||
b.email &&
|
||||
(b.deliveryMethod === "address" || b.deliveryMethod === "packstation") &&
|
||||
typeof b.zip === "string" &&
|
||||
b.zip &&
|
||||
typeof b.city === "string" &&
|
||||
b.city &&
|
||||
typeof b.country === "string" &&
|
||||
b.country,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!isValidBody(body)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (body.deliveryMethod === "address" && !body.street) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
|
||||
}
|
||||
// Optional — only format-checked when actually provided, same "never
|
||||
// trust the client" reasoning as every other checkout field re-validated
|
||||
// here. Normalized the same way Orders.ts's own field does (uppercase +
|
||||
// trim), so the snapshot on the order matches what would've been
|
||||
// accepted directly through the Payload admin.
|
||||
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
|
||||
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
|
||||
}
|
||||
if (body.hasDifferentShippingAddress) {
|
||||
if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod === "address" && !body.shippingStreet) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod === "packstation" && (!body.shippingPackstationNumber || !body.shippingPostNumber)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
|
||||
}
|
||||
if (body.shippingDeliveryMethod !== "address" && body.shippingDeliveryMethod !== "packstation") {
|
||||
return NextResponse.json({ ok: false, reason: "Lieferart der Lieferadresse ist ungültig." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Auth: an existing session wins; otherwise this checkout submit doubles
|
||||
// as inline registration ("Konto Pflicht, Registrierung direkt im
|
||||
// Checkout") — logging in with an *existing* account happens separately
|
||||
// beforehand via /api/account/login from the checkout page's own toggle.
|
||||
let customer: CustomerSummary;
|
||||
const session = await getSessionCustomer();
|
||||
if (session) {
|
||||
customer = session.customer;
|
||||
} else {
|
||||
if (!body.password) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
|
||||
}
|
||||
const result = await registerCustomer({
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
});
|
||||
if (!result.ok) return NextResponse.json(result, { status: 400 });
|
||||
await setSessionCookie(result.token);
|
||||
customer = result.customer;
|
||||
}
|
||||
|
||||
// Re-price everything server-side — never trust client-submitted prices.
|
||||
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
|
||||
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
|
||||
// §19 UStG — a Kleinunternehmer tenant never charges VAT on anything,
|
||||
// full stop, so every item's tax rate is forced to 0% here regardless of
|
||||
// its own catalog/company-settings default rate. Unlike the
|
||||
// intra-community exemption below, prices are NOT de-grossed — see
|
||||
// Orders.ts's own kleinunternehmer field comment and this shop's
|
||||
// Kleinunternehmer decision: catalog gross prices stay exactly what they
|
||||
// are, they simply never had a VAT component charged on top in the
|
||||
// first place.
|
||||
const kleinunternehmer = Boolean(companySettings?.kleinunternehmer);
|
||||
const items: {
|
||||
productId: number;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
imageUrl: string | null;
|
||||
taxRatePercent: number;
|
||||
bundleContents: string | null;
|
||||
variantName: string | null;
|
||||
}[] = [];
|
||||
for (const line of body.cart) {
|
||||
const product = productsBySlug.get(line.id);
|
||||
if (!product) return NextResponse.json({ ok: false, reason: "Ein Artikel im Warenkorb ist nicht mehr verfügbar." }, { status: 400 });
|
||||
// Same "never trust the client" reasoning as unitPrice below — a
|
||||
// requested variant that no longer exists on this product (removed,
|
||||
// or never existed — a tampered request) fails the whole checkout
|
||||
// rather than silently falling back to the base product/price.
|
||||
let variant: { name: string; priceOverride: number | null } | null = null;
|
||||
if (line.variant) {
|
||||
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
|
||||
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
|
||||
}
|
||||
// Same depth-in-defense reasoning as the price re-check above — the
|
||||
// storefront already disables "add to cart" for sold-out items, but a
|
||||
// tampered/stale request could still submit one, so stock is
|
||||
// re-validated here as the actual source of truth. Falls through
|
||||
// (buyable) whenever trackInventory is off or backorders are allowed.
|
||||
const stockSource = line.variant ? product.variants?.find((v) => v.name === line.variant) : product;
|
||||
if (stockSource?.trackInventory && !stockSource.allowBackorder && (stockSource.stock ?? 0) < line.qty) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: `"${product.name}"${line.variant ? ` (${line.variant})` : ""} ist nicht mehr in ausreichender Menge verfügbar.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
|
||||
items.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: line.qty,
|
||||
unitPrice: variant?.priceOverride ?? product.price,
|
||||
imageUrl,
|
||||
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
|
||||
bundleContents: describeBundleContents(product),
|
||||
variantName: variant?.name ?? null,
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
|
||||
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 paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
if (!paymentMethod) return NextResponse.json({ ok: false, reason: "Zahlungsart ist ungültig." }, { status: 400 });
|
||||
|
||||
let discountAmount = 0;
|
||||
if (body.discountCode) {
|
||||
const validation = await validateDiscountCode(body.discountCode, subtotal);
|
||||
if (!validation.valid) return NextResponse.json({ ok: false, reason: validation.reason }, { status: 400 });
|
||||
const redeemed = await redeemDiscountCode(validation.doc);
|
||||
if (!redeemed) return NextResponse.json({ ok: false, reason: "Rabattcode konnte nicht eingelöst werden." }, { status: 400 });
|
||||
discountAmount = roundMoney(
|
||||
validation.doc.type === "percent" ? (subtotal * validation.doc.value) / 100 : Math.min(validation.doc.value, subtotal),
|
||||
);
|
||||
}
|
||||
|
||||
// VAT-ID validity and the exemption decision are two separate questions.
|
||||
// Validity (is this actually a currently-registered VAT ID at all) is
|
||||
// checked via VIES for ANY country whenever one is given — worth
|
||||
// recording regardless of destination, same "data quality" reasoning as
|
||||
// company-settings.vatId's own VIES check on the backend; a merely
|
||||
// format-valid id (e.g. "ED123456789" — "ED" isn't even a real country
|
||||
// code) is never enough on its own. The exemption itself
|
||||
// (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG) additionally
|
||||
// requires the goods' actual destination (the shipping override's
|
||||
// country when set, the billing country otherwise) to be Österreich,
|
||||
// the one EU-cross-border option this checkout offers — a validated
|
||||
// *German* VAT ID never zero-rates a domestic sale, no matter how real
|
||||
// it is. VIES being unreachable fails closed on the exemption: normal
|
||||
// VAT applies, never a guessed exemption (vatIdValidatedAt just stays
|
||||
// unset in that case too).
|
||||
let vatExempt = false;
|
||||
let vatIdValidatedAt: string | null = null;
|
||||
// A Kleinunternehmer never charges VAT on any sale, domestic or
|
||||
// cross-border — the intra-community exemption exists to zero-rate what
|
||||
// would otherwise be a positive-rate charge, which never applies here in
|
||||
// the first place, so the VIES lookup is skipped entirely (also saves an
|
||||
// unneeded network round-trip).
|
||||
const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry);
|
||||
if (!kleinunternehmer && normalizedVatId) {
|
||||
const viesResult = await checkVatIdViaVies(normalizedVatId);
|
||||
if (viesResult.ok && viesResult.valid) {
|
||||
vatIdValidatedAt = new Date().toISOString();
|
||||
if (isExemptionEligibleCountry(buyerDestinationCountry)) {
|
||||
vatExempt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vatExempt) {
|
||||
// Re-price every line net of VAT (0% now applies) instead of the
|
||||
// catalog's normal VAT-inclusive price — the whole point of the
|
||||
// exemption is that the buyer pays less, not that this shop quietly
|
||||
// keeps the VAT portion as extra margin. items/subtotal/shippingCost
|
||||
// below are overwritten with the de-grossed figures actually charged
|
||||
// and actually persisted on the order/invoice.
|
||||
for (const item of items) {
|
||||
item.unitPrice = roundMoney(item.unitPrice / (1 + item.taxRatePercent / 100));
|
||||
item.taxRatePercent = 0;
|
||||
}
|
||||
}
|
||||
const exemptTotals = vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map((i) => ({ quantity: i.quantity, grossUnitPrice: i.unitPrice, taxRatePercent: 0 })),
|
||||
shippingCost,
|
||||
defaultTaxRate,
|
||||
discountAmount,
|
||||
)
|
||||
: null;
|
||||
// Note: exemptTotals recomputes `subtotal` from the already-degrossed
|
||||
// `items` above (taxRatePercent 0 there means computeExemptTotals's own
|
||||
// degross() step is a no-op on them) — it exists mainly to degross
|
||||
// `shippingCost` the same way, and to keep both figures derived through
|
||||
// one shared function rather than duplicating the arithmetic here.
|
||||
const finalSubtotal = exemptTotals?.subtotal ?? subtotal;
|
||||
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
|
||||
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
|
||||
|
||||
// Gated-payment branch (Kreditkarte/PayPal today) — see
|
||||
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
|
||||
// order so its id can be persisted onto the order at creation time
|
||||
// (providerReference), rather than needing a second authenticated
|
||||
// update call that doesn't otherwise exist from this service. Stripe
|
||||
// generates a PaymentIntent id independent of any order existing yet.
|
||||
const requiresPayment = paymentMethod.provider === "stripe";
|
||||
let providerReference: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
if (requiresPayment) {
|
||||
try {
|
||||
const intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: body.email,
|
||||
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
|
||||
});
|
||||
providerReference = intent.providerReference;
|
||||
clientSecret = intent.clientSecret;
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
|
||||
customerEmail: body.email,
|
||||
total,
|
||||
error: String(err),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
customerId: customer.id,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
customerEmail: body.email,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
vatIdValidatedAt,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
newsletterOptIn: Boolean(body.newsletterOptIn),
|
||||
items,
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
shippingMethodTitle: shippingMethod.title,
|
||||
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
|
||||
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
|
||||
// customer hasn't actually chosen an instrument yet at this point,
|
||||
// Stripe's Payment Element does that next. Snapshotting the specific
|
||||
// resolved row's title here would just record whichever row happened
|
||||
// to be the group's representative id, not what was really picked.
|
||||
// The webhook route refines this to the real instrument
|
||||
// ("Kreditkarte"/"PayPal") once Stripe reports it, via confirm-payment.
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
total,
|
||||
...(requiresPayment
|
||||
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
|
||||
: {}),
|
||||
});
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
// through checkout believing they bought something, and nothing was
|
||||
// persisted. Kuma's uptime checks can't see this (the site is up,
|
||||
// this route just returned a 500) — this is the one alert path that
|
||||
// can.
|
||||
sendCriticalAlert("Bestellung konnte nicht gespeichert werden", {
|
||||
customerId: customer.id,
|
||||
customerEmail: body.email,
|
||||
cart: body.cart,
|
||||
total,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
if (requiresPayment && providerReference) {
|
||||
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
|
||||
// Not fatal: the order's own `providerReference` field (already
|
||||
// persisted above) remains the source of truth for the
|
||||
// expirePendingPayments cleanup job either way; this only speeds up
|
||||
// the webhook's fast path.
|
||||
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
|
||||
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
providerReference,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
|
||||
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
|
||||
// from the backend's confirm-payment endpoint instead, at that point.
|
||||
// Unchanged for Überweisung: fires immediately, exactly as before.
|
||||
if (!requiresPayment) {
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber as string,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt as string,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget, same reasoning as the confirmation email above — a
|
||||
// failed marketing sync is not worth failing checkout over, and doesn't
|
||||
// even need a critical alert (nothing customer-facing depends on it).
|
||||
// Not gated on payment confirmation — a newsletter signup intent isn't
|
||||
// an order-fulfillment concern, unlike the confirmation email/invoice.
|
||||
if (body.newsletterOptIn) {
|
||||
upsertNewsletterContact(body.email, "checkout").catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderNumber: order.orderNumber,
|
||||
orderId: order.id,
|
||||
orderDateIso: order.createdAt,
|
||||
...(requiresPayment
|
||||
? {
|
||||
requiresPayment: true as const,
|
||||
clientSecret,
|
||||
testMode: isPaymentTestMode,
|
||||
// Only surfaced in test mode — PaymentStep's "Testzahlung"
|
||||
// buttons need it to call the test-confirm route directly,
|
||||
// since there's no real Stripe redirect to carry it back
|
||||
// through. A real PaymentIntent id isn't secret (only its
|
||||
// client_secret is), but there's no reason to expose it to the
|
||||
// client outside test mode either.
|
||||
...(isPaymentTestMode ? { providerReference } : {}),
|
||||
}
|
||||
: {}),
|
||||
shippingCost: finalShippingCost,
|
||||
paymentMethodTitle: requiresPayment ? "Online-Zahlung" : paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// Polled by /checkout/verarbeitung after a Payment Element redirect
|
||||
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
|
||||
// session (checkout is "Konto Pflicht", so one always exists by the time
|
||||
// this page is reachable) rather than accepting a bare orderNumber, so a
|
||||
// guessed/leaked order number can't be used to probe another customer's
|
||||
// payment status.
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
|
||||
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus,
|
||||
// Refined from the checkout-time "Online-Zahlung" placeholder to the
|
||||
// actual instrument (Kreditkarte/PayPal) once confirm-payment sets it
|
||||
// — see resolveStripePaymentMethodLabel's own comment. Returned here
|
||||
// so VerarbeitungContent can patch the pending sessionStorage snapshot
|
||||
// before promoting it, so /bestellbestaetigung shows the real one.
|
||||
paymentMethodTitle: order.paymentMethodTitle,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../../lib/vies";
|
||||
|
||||
// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever
|
||||
// the billing country is Österreich — the only cross-border-EU option this
|
||||
// checkout offers besides Deutschland (domestic, exemption never applies)
|
||||
// and Schweiz (non-EU export, a different exemption entirely, out of
|
||||
// scope here). Gives the shopper immediate feedback on whether their VAT
|
||||
// ID actually qualifies for the innergemeinschaftliche-Lieferung
|
||||
// exemption, before they even submit — api/checkout/route.ts re-runs this
|
||||
// exact same check server-side at submit time regardless (never trusts
|
||||
// this response), since a VIES result could theoretically change between
|
||||
// blur and submit.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const vatId = typeof body?.vatId === "string" ? body.vatId : "";
|
||||
if (!vatId) return NextResponse.json({ ok: false, reason: "USt-IdNr. fehlt." }, { status: 400 });
|
||||
|
||||
const normalized = normalizeVatId(vatId);
|
||||
if (!isValidVatId(normalized)) {
|
||||
return NextResponse.json({ ok: true, valid: false, reason: "Ungültiges USt-IdNr.-Format." });
|
||||
}
|
||||
|
||||
const result = await checkVatIdViaVies(normalized);
|
||||
if (!result.ok) {
|
||||
// `ok: false` here means "VIES couldn't confirm this one way or the
|
||||
// other" (unreachable, or the member state's own gateway is briefly
|
||||
// down — `MS_UNAVAILABLE`, which VIES itself answers 200 for, not an
|
||||
// error status) — NOT "confirmed invalid". Previously this branch
|
||||
// still answered `{ ok: true, valid: false }`, which the client reads
|
||||
// as a rejected VAT ID (`vatIdViesStatus = "invalid"`) instead of
|
||||
// "couldn't check right now" (`"unavailable"`) — a real, currently
|
||||
// registered VAT ID looked wrong to the customer whenever VIES (or
|
||||
// just Germany's own national gateway) had a hiccup.
|
||||
return NextResponse.json({ ok: false, reason: result.reason });
|
||||
}
|
||||
return NextResponse.json({ ok: true, valid: result.valid, name: result.name });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode, redeemDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called once, from CheckoutContent.tsx's handlePurchase(), right before
|
||||
// the OrderSnapshot is written — re-validates (the window/limit may have
|
||||
// changed since the cart-side /validate check, however unlikely) and only
|
||||
// then increments the redemption counter. If this fails, the caller must
|
||||
// not complete the purchase with a dead code silently still showing as
|
||||
// applied.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ redeemed: false, reason: "Kein Code angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json({ redeemed: false, reason: result.reason });
|
||||
|
||||
const ok = await redeemDiscountCode(result.doc);
|
||||
if (!ok) return NextResponse.json({ redeemed: false, reason: "Rabattcode konnte nicht eingelöst werden." });
|
||||
|
||||
return NextResponse.json({ redeemed: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDiscountCode } from "../../../lib/discountServer";
|
||||
|
||||
// Called from CartContent.tsx when a shopper clicks "Anwenden" — read-only
|
||||
// check (active/window/minOrderValue/remaining-redemptions), does NOT
|
||||
// increment the redemption counter. That only happens in /redeem, at
|
||||
// actual purchase time (see CheckoutContent.tsx's handlePurchase()).
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const subtotal = typeof body?.subtotal === "number" ? body.subtotal : 0;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ valid: false, reason: "Bitte einen Code eingeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDiscountCode(code, subtotal);
|
||||
if (!result.valid) return NextResponse.json(result);
|
||||
|
||||
return NextResponse.json({ valid: true, type: result.doc.type, value: result.doc.value });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Deliberately checks Payload connectivity, not just "did this route
|
||||
// handler run" — the site can return 200s from every static/ISR page
|
||||
// while Payload itself is unreachable (stale cached content masks it for
|
||||
// a while). Meant for a Kuma HTTP monitor, added to the existing "Content
|
||||
// & API" group alongside the direct Payload monitors (see ~/dev/README.md).
|
||||
export async function GET() {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/posts?limit=1`, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { upsertNewsletterContact, type NewsletterOptInSource } from "../../../lib/brevo";
|
||||
import { isValidEmail } from "../../../lib/email";
|
||||
|
||||
type SubscribeBody = {
|
||||
email?: string;
|
||||
consent?: boolean;
|
||||
source?: NewsletterOptInSource;
|
||||
};
|
||||
|
||||
const VALID_SOURCES: NewsletterOptInSource[] = ["newsletter-page", "newsletter-modal", "newsletter-hero", "challenge"];
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body: SubscribeBody = await req.json();
|
||||
const email = body.email?.trim() ?? "";
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
|
||||
}
|
||||
if (!body.consent) {
|
||||
return NextResponse.json({ ok: false, reason: "Bitte akzeptiere die Datenschutzerklärung." }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
|
||||
const result = await upsertNewsletterContact(email, source);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { draftMode } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Entered only via the `livePreview.url` link Payload puts in its admin
|
||||
// (posts/legal-pages/testimonials, see payload.config.ts and those
|
||||
// collections' own `admin.livePreview.url` resolvers) — enables Draft Mode
|
||||
// so the target page renders its Live-Preview-aware components (see the
|
||||
// `isPreview` checks in those pages' page.tsx), then redirects into the
|
||||
// actual page. `path` is never redirected to as-is (open-redirect risk);
|
||||
// it's validated to be an internal path first.
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const secret = searchParams.get("secret");
|
||||
const path = searchParams.get("path");
|
||||
|
||||
if (!secret || secret !== process.env.PAYLOAD_PREVIEW_SECRET) {
|
||||
return new Response("Invalid secret", { status: 401 });
|
||||
}
|
||||
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
||||
return new Response("Invalid path", { status: 400 });
|
||||
}
|
||||
|
||||
const draft = await draftMode();
|
||||
draft.enable();
|
||||
|
||||
redirect(path);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { verifyStripeWebhookSignature, resolveStripePaymentMethodLabel } from "../../../lib/payments/stripeProvider";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
|
||||
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
|
||||
// here then), but left unconditional rather than gated on the env var —
|
||||
// an invalid/missing signature already fails closed on its own.
|
||||
export async function POST(request: Request) {
|
||||
// Raw body only — request.json() would consume/reparse the stream and
|
||||
// Stripe's signature is computed over the exact original bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
|
||||
const event = verifyStripeWebhookSignature(rawBody, signature);
|
||||
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
|
||||
|
||||
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
|
||||
// Stripe sends many event types we don't act on (e.g.
|
||||
// payment_intent.created, charge.*) — ack them so Stripe stops
|
||||
// retrying something we were never going to process.
|
||||
return NextResponse.json({ ok: true, ignored: event.type });
|
||||
}
|
||||
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
const providerReference = intent.id;
|
||||
const orderId = intent.metadata?.orderId;
|
||||
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
|
||||
|
||||
if (!orderId) {
|
||||
// stripeProvider.attachOrderMetadata (called right after order
|
||||
// creation in /api/checkout) failed to complete for this
|
||||
// PaymentIntent — the order's own `providerReference` field is still
|
||||
// the source of truth and expirePendingPayments will reconcile it
|
||||
// eventually, but that's a multi-hour fallback, not instant. Alert
|
||||
// now rather than silently relying on the cleanup job.
|
||||
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
|
||||
// Non-2xx so Stripe retries — a later retry might land after the
|
||||
// metadata attach (which races the checkout response) has caught up.
|
||||
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Best-effort — see resolveStripePaymentMethodLabel's own comment. Only
|
||||
// meaningful on the "paid" path; a failed payment never gets a
|
||||
// paymentMethodTitle refinement (the order becomes 'cancelled' outright).
|
||||
const paymentMethodTitle = paymentStatus === "paid" ? await resolveStripePaymentMethodLabel(intent) : undefined;
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentStatus,
|
||||
providerReference,
|
||||
paidAt: new Date().toISOString(),
|
||||
...(paymentMethodTitle ? { paymentMethodTitle } : {}),
|
||||
}),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
|
||||
// provide resilience instead of building an internal retry queue.
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Fire-and-forget, same reasoning as the checkout route's own send: a
|
||||
// failed confirmation email must never turn an already-successful
|
||||
// payment confirmation into a non-2xx response (that would make Stripe
|
||||
// retry a webhook we've already fully processed). `alreadyProcessed`/
|
||||
// missing `order` means this is a repeat delivery — see confirmPayment.ts's
|
||||
// own comment on why the email must not be sent twice.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPaymentTestMode } from "../../../../lib/payments";
|
||||
import { sendConfirmedPaymentEmail, type ConfirmPaymentOrderSnapshot } from "../../../../lib/payments/confirmPaymentEmail";
|
||||
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Test-mode stand-in for the real Stripe webhook — see
|
||||
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
|
||||
// endpoint the real webhook calls, just without a real Stripe event/
|
||||
// signature (there is none to verify in test mode). Hard-gated: must
|
||||
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
|
||||
// become an unauthenticated "mark any order paid" endpoint in production.
|
||||
export async function POST(request: Request) {
|
||||
if (!isPaymentTestMode) {
|
||||
return NextResponse.json({ ok: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderId = body?.orderId;
|
||||
const providerReference = body?.providerReference;
|
||||
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
|
||||
if (!orderId || !providerReference) {
|
||||
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
|
||||
}
|
||||
|
||||
const data: { ok: boolean; alreadyProcessed?: boolean; order?: ConfirmPaymentOrderSnapshot } = await res.json();
|
||||
|
||||
// Same email-send as the real webhook route — see its own comment and
|
||||
// confirmPaymentEmail.ts. Reproduces today's "immediate confirmation"
|
||||
// behavior on a test click, exercising the real send path rather than a
|
||||
// separate short-circuit.
|
||||
if (data.order && !data.alreadyProcessed) {
|
||||
void sendConfirmedPaymentEmail(data.order);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CartItem } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { computeExemptTotals } from "../../lib/vatExemption";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
|
||||
// Rejects (rather than silently patching with fallback values) anything
|
||||
// that doesn't match the current OrderSnapshot shape — e.g. a snapshot
|
||||
// left over from before shippingCost/paymentMethodTitle were added to it.
|
||||
// Fabricating "Kostenlos"/"—" for missing fields would render a receipt
|
||||
// that looks real but states things about an order that were never true;
|
||||
// treating it as no-order-found and clearing it is the honest fallback.
|
||||
function parseOrderSnapshot(raw: string): OrderSnapshot | null {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (
|
||||
!data ||
|
||||
!Array.isArray(data.items) ||
|
||||
data.items.length === 0 ||
|
||||
typeof data.orderNumber !== "string" ||
|
||||
typeof data.orderDateIso !== "string" ||
|
||||
typeof data.shippingCost !== "number" ||
|
||||
typeof data.paymentMethodTitle !== "string" ||
|
||||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
|
||||
typeof data.discountAmount !== "number" ||
|
||||
typeof data.vatExempt !== "boolean" ||
|
||||
typeof data.kleinunternehmer !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return data as OrderSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: number }) {
|
||||
const products = useProducts();
|
||||
const [order, setOrder] = useState<OrderSnapshot | null>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
// The snapshot was already written (and the cart already cleared) by
|
||||
// /checkout's "Jetzt kaufen" click, before it ever navigated here — see
|
||||
// CheckoutContent.tsx's handlePurchase(). This is a pure read of that
|
||||
// browser-only value, deferred to an effect (not a lazy useState
|
||||
// initializer) purely to avoid an SSR/hydration mismatch: the server
|
||||
// has no sessionStorage, so it must render the same "nothing yet" state
|
||||
// the client shows before this effect runs.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(ORDER_KEY);
|
||||
const parsed = raw ? parseOrderSnapshot(raw) : null;
|
||||
if (parsed) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading a browser-only store on mount to avoid an SSR/hydration mismatch, see comment above
|
||||
setOrder(parsed);
|
||||
} else if (raw) {
|
||||
// Present but invalid/outdated — drop it so it doesn't keep
|
||||
// failing validation on every future visit either.
|
||||
window.sessionStorage.removeItem(ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore — falls through to the "no order" state below
|
||||
}
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
if (!checked) return null;
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-center text-center pt-20 pb-16 px-[var(--layout-padding-x)] w-full">
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Keine Bestellung gefunden
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-[28rem]">
|
||||
Hier gibt es gerade nichts zu bestätigen — vielleicht ist die Sitzung abgelaufen.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
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"
|
||||
>
|
||||
Zum Shop
|
||||
</Link>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
const productsLoading = products.length === 0;
|
||||
const items = order.items
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: CartItem; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
// Displays the *persisted* discount/shippingCost from the snapshot, not
|
||||
// a fresh re-derivation — the purchase already happened, this page is a
|
||||
// receipt, not a live cart, so it doesn't re-validate the code at all.
|
||||
// order.shippingCost is already the actual (possibly de-grossed, if
|
||||
// vatExempt) figure charged at checkout — see api/checkout/route.ts's
|
||||
// own response. `subtotal`/`taxBreakdown` below still need their own
|
||||
// exempt branch, though: computeCartTotals/computeTaxBreakdown build
|
||||
// `subtotal` from each item's *current catalog* gross price via
|
||||
// effectivePrice(), which for an exempt order was never what was
|
||||
// actually charged (the catalog price includes VAT; the exempt order
|
||||
// paid the de-grossed net price instead).
|
||||
const { subtotal: catalogSubtotal, totalSavings, total: catalogTotal } = computeCartTotals(items, order.shippingCost, {
|
||||
type: "fixed",
|
||||
value: order.discountAmount,
|
||||
});
|
||||
const exemptTotals = order.vatExempt
|
||||
? computeExemptTotals(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
grossUnitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
order.shippingCost,
|
||||
defaultTaxRate,
|
||||
order.discountAmount,
|
||||
)
|
||||
: null;
|
||||
const subtotal = exemptTotals?.subtotal ?? catalogSubtotal;
|
||||
const total = exemptTotals?.total ?? catalogTotal;
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
catalogSubtotal,
|
||||
order.discountAmount,
|
||||
order.shippingCost,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* items-start + same pt-8/px as /checkout's own header block — not
|
||||
centered, so the bar sits at the exact same left edge/position a
|
||||
shopper just saw on the previous step. */}
|
||||
<Reveal className="flex flex-col items-start pt-8 pb-2 px-[var(--layout-padding-x)] w-full">
|
||||
<CheckoutSteps current={5} />
|
||||
</Reveal>
|
||||
|
||||
{/* Hero — headline, short recap. No separate big checkmark badge —
|
||||
all four steps in the bar right above already render as
|
||||
checkmarks (current={5}), a second one here would just repeat
|
||||
it; pt-24 (up from the badge-era pt-12) keeps real breathing
|
||||
room between the bar and the headline now that the badge isn't
|
||||
filling that space itself. */}
|
||||
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Vielen Dank!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Deine Bestellung ist bei uns eingegangen.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
|
||||
{!productsLoading && (
|
||||
<div className="flex flex-col gap-1 max-w-[26rem]">
|
||||
<p className="text-body text-text-muted">
|
||||
Du erhältst in Kürze eine Bestellbestätigung per E-Mail mit allen Details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
{!productsLoading && (
|
||||
<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"
|
||||
>
|
||||
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
|
||||
{/* Order meta */}
|
||||
<div className="flex flex-row flex-wrap sm:flex-col gap-6 sm:w-36 sm:shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">{order.orderNumber}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Bestelldatum</p>
|
||||
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">
|
||||
{formatDate(order.orderDateIso)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Zahlungsmethode</p>
|
||||
<p className="font-bold text-body-sm text-text-primary whitespace-nowrap">{order.paymentMethodTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items + totals */}
|
||||
<div className="flex-1 flex flex-col gap-5 min-w-0">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Bestellübersicht
|
||||
</p>
|
||||
|
||||
{items.map(({ entry, product }) => {
|
||||
const unitPrice = effectivePrice(entry, product);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
|
||||
return (
|
||||
<div key={lineKey} className="flex gap-4 items-center w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{totalSavings > 0 && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Du sparst</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.discountCode && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Versand</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span
|
||||
className="font-semibold text-h4 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Gesamtbetrag
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
{order.kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : order.vatExempt ? (
|
||||
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery status panel */}
|
||||
<div className="w-full lg:w-[18rem] lg:shrink-0 bg-bg-muted p-7 flex flex-col items-center text-center gap-3 justify-center">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-text-primary shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
|
||||
<path d="M2 6.5h11v10H2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M13 10.5h4l3.5 3v3H13z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="17.5" r="1.6" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="17" cy="17.5" r="1.6" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-body text-text-primary">Deine Bestellung wird bearbeitet.</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Wir versenden in der Regel innerhalb von 1–2 Werktagen.
|
||||
</p>
|
||||
<p className="text-body-sm text-text-muted">
|
||||
Du erhältst eine E-Mail, sobald dein Paket unterwegs ist.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.08} className="flex items-center justify-center pb-4 px-[var(--layout-padding-x)] w-full">
|
||||
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Bestellungen ansehen
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
{/* Testimonial band — same proven structure as /not-found's version
|
||||
(see that page's own comment), not a new layout: w-[45%] image
|
||||
column, narrow 40px edge gradient into bg-muted, quote in the
|
||||
flex-1 remainder. Only real change from that version is a fixed
|
||||
h- instead of min-h-, so both columns are pinned to an
|
||||
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">
|
||||
{/* -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
|
||||
(or from ordinary sub-pixel rounding). Overflowing the
|
||||
image 4px past the container on every side means any such
|
||||
gap shows the image itself, not whatever's behind it — the
|
||||
parent's overflow-hidden clips the small overflow back
|
||||
down to a clean box either way. */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/bestellbestaetigung-testimonial-photo.jpg"
|
||||
width={366}
|
||||
height={126}
|
||||
sizes="(min-width: 768px) 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>
|
||||
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary leading-[1.3] max-w-[28rem]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
„Produktivität beginnt nicht mit mehr – sondern mit dem, was wirklich zählt.“
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
<p className="text-body text-text-muted">Björn</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BestellbestaetigungContent } from "./components/BestellbestaetigungContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getDefaultTaxRatePercent } from "../lib/payload";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart and
|
||||
// /checkout (this one doubles as a receipt, not something to surface in
|
||||
// search results).
|
||||
export const metadata: Metadata = {
|
||||
title: "Bestellbestätigung",
|
||||
description: "Deine Bestellung bei einfach produktiv wurde erfolgreich aufgegeben.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BestellbestaetigungPage() {
|
||||
const defaultTaxRate = await getDefaultTaxRatePercent();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<BestellbestaetigungContent defaultTaxRate={defaultTaxRate} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { RichText } from "../../../components/RichText";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live-previewable subset of the blog detail page: title/category/readTime/
|
||||
// excerpt/byline, the thumbnail, and the RichText body — the fields an
|
||||
// editor actually watches update while typing. The author bio card,
|
||||
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
|
||||
// aren't post-specific content (bio) or are about a *different* post
|
||||
// (nextPost), not the document currently open in the admin.
|
||||
export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
|
||||
const { data } = useLivePreview<PayloadPostDetail>({
|
||||
initialData: initialPost as unknown as PayloadPostDetail,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
const post = data?.slug ? mapPayloadPost(data) : initialPost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{post.thumbnail && (
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} quoteLabel={post.quoteLabel} />
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return { title: "Beitrag nicht gefunden" };
|
||||
|
||||
// Each falls back to the normal field when its SEO override (Posts.ts's
|
||||
// "SEO" collapsible group) is empty — filling those in is optional, a
|
||||
// post already has sensible metadata without them.
|
||||
const title = post.seoTitle || post.title;
|
||||
const description = post.seoDescription || post.excerpt;
|
||||
const image = post.seoImage || post.thumbnail;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: `${title} | einfach produktiv.`,
|
||||
description,
|
||||
url: `/blog/${post.slug}`,
|
||||
type: "article",
|
||||
images: image ? [{ url: image }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const post = await getPostBySlug(slug, { draft: isPreview });
|
||||
if (!post) notFound();
|
||||
|
||||
// "Weiterlesen" — any other post, most recent first. Not the current
|
||||
// one; falls back to nothing (section just doesn't render) rather than
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
) : (
|
||||
<>
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
{/* Playfair (not Lora), matching the actual Figma title's font —
|
||||
same "hero headline" family as Hero.tsx/TodoKartenHero.tsx/
|
||||
WeeklyImpulsesHero.tsx use, not the Lora section-heading style
|
||||
the legal pages use. Sized down from text-display (Figma's
|
||||
literal 44px), but text-h-feature (max 40px) read too small —
|
||||
no named token sits between the two, so this is a one-off
|
||||
fluid(36, 48) clamp using the project's own fluid.ts formula
|
||||
(768px Tablet floor → 1440px Desktop cap), landing between
|
||||
them instead of jumping all the way back to text-display. */}
|
||||
<p
|
||||
className="font-semibold text-[clamp(2.25rem,1.393rem+1.786vw,3rem)] text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{post.thumbnail && (
|
||||
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
|
||||
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
|
||||
// edge (an earlier version guessed that, which came out far too
|
||||
// wide) and not capped to the body column either.
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} quoteLabel={post.quoteLabel} />
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
{/* "Passend dazu" — per-post CMS content now (Posts.relatedProduct),
|
||||
not a hardcoded flagship-product link. Hidden entirely if the
|
||||
post has no related product, or that product has no detail
|
||||
page to send "Entdecken" to. Matches the actual built Figma
|
||||
frame exactly (node 4674:349, fetched via get_design_context) —
|
||||
1px border-border, rounded-md, px-9/py-7 padding; an earlier
|
||||
version guessed a plain borderless row instead, which
|
||||
get_metadata's structural dump didn't reveal (frame-level
|
||||
stroke/padding/radius aren't visible there, only
|
||||
get_design_context shows those). */}
|
||||
{post.relatedProduct?.href && (
|
||||
<Link
|
||||
href={post.relatedProduct.href}
|
||||
className="group flex items-center gap-4 sm:gap-6 border border-border rounded-md px-5 py-5 sm:px-9 sm:py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="relative w-16 h-[4.6875rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image alt="" src={post.relatedProduct.image} fill sizes="64px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
{/* Stacked below sm: — the fixed w-[19rem] title column plus
|
||||
"Entdecken" on the same row overflowed a mobile-width
|
||||
card (fixed 2026-07-24). "Entdecken" wraps to its own
|
||||
line with a little space above it; back to the
|
||||
side-by-side row (matching Figma) from sm: up, where
|
||||
there's room for both. */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) only from sm: — matches Figma's
|
||||
title-col exactly there, so the description wraps at
|
||||
the same point instead of stretching out to fill the
|
||||
space before "Entdecken"; full width below sm:. */}
|
||||
<div className="flex flex-col gap-2 items-start w-full sm:w-[19rem] sm:shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary sm:whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.relatedProduct.name}
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">{post.relatedProduct.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>
|
||||
|
||||
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
|
||||
this is a single-author blog, there's no author field on the
|
||||
Posts collection to read from. */}
|
||||
<Reveal delay={0.2} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14">
|
||||
<div className="flex items-center gap-4 bg-bg-muted rounded-md p-6">
|
||||
<div className="relative size-14 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="56px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-semibold text-body text-text-primary">Geschrieben von Björn.</p>
|
||||
<p className="text-body-sm text-text-muted">Führungskraft. Familienmensch.</p>
|
||||
<p className="text-body-sm text-text-muted">Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{nextPost && (
|
||||
// max-w-[58.75rem] (940px) — exact Figma value (node 4667:382),
|
||||
// deliberately wider than the 48rem body column (widened past
|
||||
// the 43.75rem/700px Figma value per user preference), matching
|
||||
// the original build's "Weiterlesen" card being wider than the
|
||||
// text column rather than symmetric to it.
|
||||
<Reveal delay={0.25} className="w-full max-w-[58.75rem] mx-auto px-[var(--layout-padding-x)] pb-16">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary mb-4"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Weiterlesen
|
||||
</p>
|
||||
<Link
|
||||
href={`/blog/${nextPost.slug}`}
|
||||
className="group flex flex-col sm:flex-row items-stretch border border-border rounded-md overflow-hidden transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full sm:w-64 aspect-video sm:aspect-auto shrink-0 bg-bg-muted">
|
||||
{nextPost.thumbnail && (
|
||||
<Image alt="" src={nextPost.thumbnail} fill sizes="(min-width: 640px) 16rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</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>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{nextPost.title}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-body-sm text-text-muted line-clamp-1">
|
||||
{nextPost.excerpt}
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
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 { formatDate } from "../lib/format";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
alternates: { canonical: "/blog" },
|
||||
openGraph: {
|
||||
title: "Blog | einfach produktiv.",
|
||||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||||
url: "/blog",
|
||||
type: "website",
|
||||
images: ["/blog-featured.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogOverviewPage() {
|
||||
const posts = await getBlogPosts(100);
|
||||
const [featured, ...rest] = posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{/* Header — copy left, photo bleeds to the viewport edge on the
|
||||
right with a left-edge fade into bg-base, matching Figma's
|
||||
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
|
||||
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Blog
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
|
||||
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{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">
|
||||
<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"
|
||||
>
|
||||
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
|
||||
{featured.thumbnail && (
|
||||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 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>
|
||||
<span>•</span>
|
||||
<span>{featured.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{featured.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted line-clamp-3">{featured.excerpt}</p>
|
||||
<span className="flex items-center gap-1.5 font-bold text-body text-text-primary mt-1">
|
||||
Zum Beitrag
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-4 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>
|
||||
</Link>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{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">
|
||||
<div className="relative w-full sm:w-[18.75rem] aspect-video sm:aspect-[300/192] shrink-0 rounded-md overflow-hidden bg-bg-muted">
|
||||
{post.thumbnail && (
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 640px) 18.75rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
<span>•</span>
|
||||
<span className="uppercase">{formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<span className="flex items-center gap-1.5 font-bold text-body-sm text-text-primary mt-1">
|
||||
Zum Beitrag
|
||||
<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>
|
||||
</Link>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
)}
|
||||
|
||||
<Newsletter
|
||||
title="Neue Impulse im richtigen Moment."
|
||||
description="Melde dich zum Newsletter an und erhalte regelmäßig Gedanken, Methoden und praktische Tipps."
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } 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 { FreeShippingBanner } from "./FreeShippingBanner";
|
||||
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
|
||||
|
||||
export function CartContent() {
|
||||
export function CartContent({
|
||||
trustBadges,
|
||||
shippingCost,
|
||||
freeShippingThreshold,
|
||||
shippingSettings,
|
||||
defaultTaxRate,
|
||||
kleinunternehmer,
|
||||
showDiscountField,
|
||||
}: {
|
||||
trustBadges: TrustBadge[];
|
||||
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
|
||||
* estimate, since the cart doesn't ask which method the shopper wants
|
||||
* yet (that's /checkout). */
|
||||
shippingCost: number;
|
||||
/** Lowest freeShippingThreshold among active ShippingMethods, or null if
|
||||
* none has one (in which case FreeShippingBanner just doesn't render). */
|
||||
freeShippingThreshold: number | null;
|
||||
/** Delivery-time disclosure (Payload's Shipping Settings), fetched by the
|
||||
* page and threaded down here — this is a Client Component, so it can't
|
||||
* fetch it itself. Also passed straight through to VersandModal. Named
|
||||
* "shippingSettings", not "shipping" — that name is already the local
|
||||
* computed shipping-cost value below. */
|
||||
shippingSettings: ShippingSettings;
|
||||
/** Tenant's default VAT rate (Company Settings), for products that don't
|
||||
* override taxRatePercent themselves — see lib/cartTotals.ts's
|
||||
* effectiveTaxRate(). */
|
||||
defaultTaxRate: number;
|
||||
/** §19 UStG — this tenant's company-settings.kleinunternehmer (Payload's
|
||||
* lib/payload.ts's getKleinunternehmer(), same ISR freshness as
|
||||
* defaultTaxRate above). Drops the "inkl. X% MwSt." hints and the VAT
|
||||
* breakdown in favor of the §19 notice below. */
|
||||
kleinunternehmer: boolean;
|
||||
/** Whether Payload currently has at least one active discount code at
|
||||
* all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
|
||||
* showing an open "enter a code" field when nothing could ever validate
|
||||
* against it. Only gates the manual-entry form; a code already applied
|
||||
* (e.g. from an earlier session, or one deactivated after being shared)
|
||||
* still shows its own result row regardless. */
|
||||
showDiscountField: boolean;
|
||||
}) {
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
const discount = useDiscount();
|
||||
const [discountInput, setDiscountInput] = useState("");
|
||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||
const [discountLoading, setDiscountLoading] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
// While the /api/products fetch is still pending, treat a non-empty
|
||||
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
|
||||
// lookup was synchronous, so this distinction didn't exist before;
|
||||
@@ -26,9 +75,60 @@ export function CartContent() {
|
||||
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
|
||||
const total = subtotal + shipping;
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
quantity: entry.qty,
|
||||
unitPrice: effectivePrice(entry, product),
|
||||
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
|
||||
})),
|
||||
subtotal,
|
||||
discountAmount,
|
||||
shipping,
|
||||
);
|
||||
|
||||
async function handleApplyDiscount(code: string) {
|
||||
if (!code) return;
|
||||
setDiscountLoading(true);
|
||||
setDiscountError(null);
|
||||
try {
|
||||
const res = await fetch("/api/discount/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, subtotal }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
|
||||
setDiscountInput("");
|
||||
} else {
|
||||
setDiscountError(data.reason || "Dieser Code ist ungültig.");
|
||||
}
|
||||
} catch {
|
||||
setDiscountError("Rabattcode konnte gerade nicht geprüft werden.");
|
||||
} finally {
|
||||
setDiscountLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// No manual input field anymore (see the Rabattcode section below) —
|
||||
// codes are shared as a direct link instead (e.g. "/cart?code=SAVE10"),
|
||||
// auto-applied once on arrival. Only fires while nothing's applied yet
|
||||
// and there's actually something in the cart to validate a minimum-order
|
||||
// value against.
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
if (code && !discount && items.length > 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from the URL's ?code= param to app state on arrival, via an async server validation call, not a render-cascade
|
||||
handleApplyDiscount(code);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only ever re-run when the cart finishes loading or the URL's code param itself changes, not on every discount/handleApplyDiscount identity change
|
||||
}, [searchParams, items.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,7 +170,7 @@ export function CartContent() {
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<FreeShippingBanner subtotal={subtotal} />
|
||||
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
|
||||
</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
|
||||
@@ -80,12 +180,59 @@ export function CartContent() {
|
||||
have had room for a real 2-column layout at Tablet widths
|
||||
anyway). */}
|
||||
<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) => (
|
||||
<div key={product.id} className="w-full">
|
||||
{items.map(({ entry, product }, i) => {
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const unitPrice = effectivePrice(entry, product);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// (id, variant) together, not id alone — two lines for the
|
||||
// same product with different variants need distinct React
|
||||
// keys/element ids and must each only affect their own line
|
||||
// when the quantity or remove control is used, same "full
|
||||
// key" reasoning as cart.ts's own sameLine().
|
||||
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
|
||||
// The exact variant this line is for, not "any variant low"
|
||||
// like the product-grid cards use — a cart line already has
|
||||
// its variant chosen, so it should only warn when that
|
||||
// specific variant (not some other one) is running low.
|
||||
const lowStock = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
|
||||
: product.lowStock;
|
||||
// Same per-line resolution as lowStock above — caps how high
|
||||
// the quantity stepper below can go, instead of only finding
|
||||
// out at checkout that this many aren't actually available
|
||||
// (api/checkout/route.ts's own stock check stays as the
|
||||
// authoritative server-side guard). null (no cap) falls back
|
||||
// to the stepper's original fixed 1-9 range; at least 1 is
|
||||
// always offered even if maxQty is somehow lower than the
|
||||
// qty already in this line, so the remove (×) button stays
|
||||
// the only way down, never an empty <select>.
|
||||
const maxQty = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
|
||||
: product.maxQty;
|
||||
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
|
||||
return (
|
||||
<div key={lineKey} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
|
||||
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
|
||||
{/* Full-width on mobile (stacked layout) instead of the
|
||||
fixed 150px square — a small square floating above
|
||||
the text looked cramped on a narrow column that has
|
||||
the width to spare; fixed 150px square again from
|
||||
sm: once the row layout kicks in and the image sits
|
||||
beside the text instead. */}
|
||||
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
|
||||
<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}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
@@ -93,37 +240,46 @@ export function CartContent() {
|
||||
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. */}
|
||||
{lowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="text-label text-text-muted">Einzelpreis</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
{discount !== null && (
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
|
||||
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<label className="sr-only" htmlFor={`qty-${product.id}`}>
|
||||
<label className="sr-only" htmlFor={`qty-${lineKey}`}>
|
||||
Menge für {product.name}
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</label>
|
||||
<select
|
||||
id={`qty-${product.id}`}
|
||||
id={`qty-${lineKey}`}
|
||||
value={entry.qty}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
{qtyOptions.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * product.price)}
|
||||
{formatPrice(entry.qty * unitPrice)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromCart(product.id)}
|
||||
aria-label={`${product.name} entfernen`}
|
||||
onClick={() => removeFromCart(product.id, entry.variant)}
|
||||
aria-label={`${product.name}${entry.variant ? ` (${entry.variant})` : ""} entfernen`}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
|
||||
>
|
||||
×
|
||||
@@ -131,7 +287,8 @@ export function CartContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -160,6 +317,79 @@ export function CartContent() {
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
{totalSavings > 0 && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Du sparst</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(totalSavings)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rabattcode — manual input when nothing's applied yet AND
|
||||
Payload actually has at least one active code right now
|
||||
(showDiscountField — no point offering an open field
|
||||
that could never validate against anything); once
|
||||
active, always shows the result + "Entfernen" regardless
|
||||
of showDiscountField (also reached via a direct link
|
||||
with a prefilled code, see the useEffect above).
|
||||
/checkout mirrors this exact block, sharing state
|
||||
through lib/discount.ts's localStorage store. */}
|
||||
{discount ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDiscount}
|
||||
className="self-start text-label text-text-muted hover:text-text-primary underline transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : showDiscountField ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleApplyDiscount(discountInput.trim());
|
||||
}}
|
||||
className="flex flex-col gap-2 w-full"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<label className="sr-only" htmlFor="cart-discount-code">Rabattcode</label>
|
||||
<input
|
||||
id="cart-discount-code"
|
||||
type="text"
|
||||
value={discountInput}
|
||||
onChange={(e) => setDiscountInput(e.target.value)}
|
||||
placeholder="Rabattcode"
|
||||
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!discountInput.trim() || discountLoading}
|
||||
className="shrink-0 rounded-sm border border-border px-4 py-2 text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand transition-colors disabled:opacity-50"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
{discountError && <p className="text-label text-red-600">{discountError}</p>}
|
||||
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft…</p>}
|
||||
</form>
|
||||
) : (
|
||||
// No manual field to attach an error to (no active codes
|
||||
// exist at all right now) — but a ?code= URL param can
|
||||
// still trigger the auto-apply attempt above regardless
|
||||
// of showDiscountField, so its failure needs somewhere to
|
||||
// show.
|
||||
<>
|
||||
{discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
|
||||
{discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft…</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
@@ -179,10 +409,13 @@ export function CartContent() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0
|
||||
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
|
||||
{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" />
|
||||
@@ -198,7 +431,11 @@ export function CartContent() {
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
{kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
@@ -208,21 +445,23 @@ export function CartContent() {
|
||||
Zur Kasse gehen
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-[0.625rem] items-center justify-center w-full">
|
||||
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
|
||||
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
|
||||
{/* "Sichere SSL-Verschlüsselung", not "Sichere Zahlung" (what
|
||||
used to be here) — matches /checkout's identical note
|
||||
under its own buy button; the old wording duplicated the
|
||||
"Sichere Zahlung" trustBadges entry right below. */}
|
||||
<div className="flex gap-2 items-center justify-center w-full">
|
||||
<Image alt="" src="/icon-lock.svg" width={14} height={16} className="w-3.5 h-4" />
|
||||
<span className="text-body-sm text-text-muted">Sichere SSL-Verschlüsselung</span>
|
||||
</div>
|
||||
</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">
|
||||
{[
|
||||
{ icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" },
|
||||
{ icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" },
|
||||
{ icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" },
|
||||
].map((b) => (
|
||||
<div key={b.text} className="flex gap-3 items-center w-full">
|
||||
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
|
||||
{trustBadges.map((b) => (
|
||||
<div key={b.id} className="flex gap-3 items-center 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>
|
||||
))}
|
||||
</div>
|
||||
@@ -235,12 +474,19 @@ export function CartContent() {
|
||||
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
|
||||
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
|
||||
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
|
||||
<Image alt="" src="/icon-envelope-hint.png" width={64} height={45} className="h-[2.8125rem] w-16 object-contain" />
|
||||
<div className="h-[0.1875rem] w-6 bg-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
|
||||
{/* Framed as an upcoming option, not a given — the real
|
||||
opt-in checkbox lives on /checkout (unchecked by
|
||||
default), and this page doesn't show one at all, so
|
||||
stating regular emails as an already-decided outcome
|
||||
here would overstate consent that hasn't been given
|
||||
yet (UWG/DSGVO — pre-supposed opt-in is unlawful, same
|
||||
reasoning as /checkout's own newsletter hint). */}
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail.
|
||||
Beim Checkout kannst du dich für unsere Impulse & Tipps per E-Mail anmelden.
|
||||
</p>
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Für mehr Klarheit, Fokus und Struktur – jede Woche.
|
||||
@@ -250,7 +496,7 @@ export function CartContent() {
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useInView } from "motion/react";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
|
||||
const SUCCESS_VISIBLE_MS = 2500;
|
||||
@@ -14,9 +13,22 @@ type Phase = "progress" | "success" | "hidden";
|
||||
* /shop, per the deliberately narrower scope agreed with the user (a cart
|
||||
* item can leave/re-enter the threshold as quantities change, so this
|
||||
* needs to react to that, not just fire once).
|
||||
*
|
||||
* `threshold` is the lowest freeShippingThreshold among /checkout's active
|
||||
* ShippingMethods (computed by the caller) — not every method necessarily
|
||||
* has one (Express never does, it always costs extra), so this shows the
|
||||
* easiest one to reach rather than an arbitrary/average value. `null`
|
||||
* means no active method has a threshold at all, so there's nothing to
|
||||
* nudge toward — the banner just doesn't render.
|
||||
*/
|
||||
export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
|
||||
const reached = subtotal >= FREE_SHIPPING_THRESHOLD;
|
||||
export function FreeShippingBanner({ subtotal, threshold }: { subtotal: number; threshold: number | null }) {
|
||||
if (threshold === null) return null;
|
||||
|
||||
return <FreeShippingBannerInner subtotal={subtotal} threshold={threshold} />;
|
||||
}
|
||||
|
||||
function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; threshold: number }) {
|
||||
const reached = subtotal >= threshold;
|
||||
const [phase, setPhase] = useState<Phase>(reached ? "success" : "progress");
|
||||
|
||||
// React's documented pattern for "adjust state when a prop changes" —
|
||||
@@ -41,17 +53,32 @@ export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gates the hide-timer on the banner being CURRENTLY visible — deliberately
|
||||
// not `{ once: true }`: since the banner sits at the very top of the cart
|
||||
// page, it's already visible the instant the page loads (well before the
|
||||
// user ever scrolls anywhere), so a lifetime "has this ever been seen"
|
||||
// flag flips true immediately and defeats the whole point — reaching the
|
||||
// threshold later while scrolled away would still start the timer right
|
||||
// then, exactly the bug this was meant to fix. Continuous tracking
|
||||
// instead: the effect below only runs the countdown while `isInView` is
|
||||
// true, and its own cleanup cancels it the moment the banner scrolls back
|
||||
// out of view — so it always takes a full uninterrupted 2.5s of the
|
||||
// banner actually being on screen before it's allowed to hide, restarting
|
||||
// if the user looks away mid-countdown and comes back.
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
|
||||
// Effect's own cleanup (not a ref) cancels the pending hide-timer
|
||||
// whenever phase changes away from "success" (e.g. dropping back below
|
||||
// the threshold before the timer fires) or on unmount.
|
||||
// whenever phase changes away from "success", the banner scrolls out of
|
||||
// view, or on unmount.
|
||||
useEffect(() => {
|
||||
if (phase !== "success") return;
|
||||
if (phase !== "success" || !isInView) return;
|
||||
const t = setTimeout(() => setPhase("hidden"), SUCCESS_VISIBLE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [phase]);
|
||||
}, [phase, isInView]);
|
||||
|
||||
const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
|
||||
const progressPct = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
|
||||
const remaining = Math.max(0, threshold - subtotal);
|
||||
const progressPct = Math.min(100, (subtotal / threshold) * 100);
|
||||
|
||||
return (
|
||||
// AnimatePresence + exit, not a plain `if (phase === "hidden") return
|
||||
@@ -61,6 +88,7 @@ export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
|
||||
<AnimatePresence>
|
||||
{phase !== "hidden" && (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { useCart } from "../../lib/cart";
|
||||
@@ -16,19 +17,36 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
export function RelatedProducts() {
|
||||
// Picks up to `count` active products not already in the cart, on top of
|
||||
// whatever's already in `keep`. Deliberately does NOT fall back to
|
||||
// re-suggesting a cart item when the non-cart pool runs short (e.g. 2
|
||||
// active products total, 1 already in the cart) — the grid just renders
|
||||
// fewer, genuinely-relevant cards instead (see the centering logic in the
|
||||
// component below), rather than padding itself out with something the
|
||||
// shopper has already added.
|
||||
function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
|
||||
const missing = count - keep.length;
|
||||
if (missing <= 0) return keep;
|
||||
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
|
||||
}
|
||||
|
||||
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
|
||||
const cart = useCart();
|
||||
const products = useProducts();
|
||||
// Cart/checkout resolve any product regardless of `active` (see
|
||||
// Product's own comment in lib/payload.ts) — this is the one discovery
|
||||
// surface among the useProducts() consumers, so it filters here itself.
|
||||
const activeProducts = useMemo(() => products.filter((p) => p.active), [products]);
|
||||
const hasItems = cart.length > 0;
|
||||
const cartKey = cart
|
||||
.map((i) => i.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
// useMemo, not a plain .map() — .map() would return a new array
|
||||
// reference on every render regardless of whether `products` itself
|
||||
// reference on every render regardless of whether `activeProducts` itself
|
||||
// changed, which would make the effect below re-run (and re-pick) every
|
||||
// single render if `productIds` were listed as its dependency.
|
||||
const productIds = useMemo(() => products.map((p) => p.id), [products]);
|
||||
const productIds = useMemo(() => activeProducts.map((p) => p.id), [activeProducts]);
|
||||
|
||||
// Starts empty — the catalog itself is now fetched (useProducts()), so
|
||||
// there's nothing to pick a random set from until that resolves. The
|
||||
@@ -55,7 +73,7 @@ export function RelatedProducts() {
|
||||
|
||||
if (!pickedRef.current) {
|
||||
pickedRef.current = true;
|
||||
setDisplayIds(pickRandom(productIds, cartIds, DISPLAY_COUNT));
|
||||
setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,33 +85,19 @@ export function RelatedProducts() {
|
||||
swapTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayIds((prev) => {
|
||||
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
|
||||
const missing = DISPLAY_COUNT - stillRelevant.length;
|
||||
if (missing <= 0) return stillRelevant;
|
||||
|
||||
let replacements = pickRandom(productIds, [...cartIds, ...stillRelevant], missing);
|
||||
|
||||
// The catalog only has a handful of products — once the cart
|
||||
// holds enough distinct ones, "N recommendations that aren't
|
||||
// already in the cart" can become impossible. Falling back to
|
||||
// re-suggesting something already in the cart (a normal "grab
|
||||
// another one" pattern) beats silently shrinking the grid below
|
||||
// DISPLAY_COUNT.
|
||||
if (replacements.length < missing) {
|
||||
const stillMissing = missing - replacements.length;
|
||||
const fallback = pickRandom(productIds, [...stillRelevant, ...replacements], stillMissing);
|
||||
replacements = [...replacements, ...fallback];
|
||||
}
|
||||
|
||||
return [...stillRelevant, ...replacements];
|
||||
return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
|
||||
});
|
||||
}, FEEDBACK_MS);
|
||||
}, [cartKey, productIds]);
|
||||
|
||||
const displayProducts = displayIds
|
||||
.map((id) => products.find((p) => p.id === id))
|
||||
.map((id) => activeProducts.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
||||
|
||||
if (displayProducts.length === 0) return null;
|
||||
// Section-wide gate, independent of cart contents: a "related products"
|
||||
// section makes no sense with fewer than 2 active products total to
|
||||
// ever offer, even before considering what's already in the cart.
|
||||
if (activeProducts.length < 2 || displayProducts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
@@ -109,12 +113,7 @@ export function RelatedProducts() {
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* No separate price-disclosure footnote here — the single
|
||||
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
|
||||
the cart's own product table instead (CartContent.tsx), close
|
||||
enough on the same page view to cover these cards too.
|
||||
|
||||
Plain divs, not RevealGroup/RevealItem — this is the one grid on
|
||||
{/* Plain divs, not RevealGroup/RevealItem — this is the one grid on
|
||||
the site whose items get swapped after the initial mount (see
|
||||
the swap-in-place effect above). RevealItem has no viewport
|
||||
trigger of its own; it only ever renders visible because it
|
||||
@@ -125,10 +124,30 @@ export function RelatedProducts() {
|
||||
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) => (
|
||||
{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
|
||||
key={product.id}
|
||||
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
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 " +
|
||||
// Center the row when there are fewer than 3 cards to show
|
||||
// (e.g. only 1 active product left once the others are
|
||||
// already in the cart) — only the first card needs an
|
||||
// explicit start column, later ones auto-flow right after
|
||||
// it. 3-card case keeps the default left-to-right flow.
|
||||
(i === 0
|
||||
? displayProducts.length === 1
|
||||
? "md:col-start-5"
|
||||
: displayProducts.length === 2
|
||||
? "md:col-start-3"
|
||||
: ""
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
@@ -138,6 +157,24 @@ export function RelatedProducts() {
|
||||
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
|
||||
@@ -146,11 +183,26 @@ export function RelatedProducts() {
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={product.id} />
|
||||
<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>
|
||||
);
|
||||
|
||||
+42
-3
@@ -1,8 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { hasActiveDiscountCode } from "../lib/discountServer";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
|
||||
@@ -16,12 +19,48 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
export default async function CartPage() {
|
||||
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([
|
||||
getCartTrustBadges(),
|
||||
getShippingMethods(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
hasActiveDiscountCode(),
|
||||
]);
|
||||
|
||||
// The cart doesn't ask which shipping method the shopper wants yet
|
||||
// (that's /checkout) — it just estimates using the first active method
|
||||
// (Standard, by sortOrder) for the sidebar's "Versand" line, and shows
|
||||
// the FreeShippingBanner toward whichever active method's threshold is
|
||||
// lowest/easiest to reach (Express has none — it never goes free).
|
||||
const defaultShipping = shippingMethods[0] ?? null;
|
||||
const thresholds = shippingMethods
|
||||
.map((m) => m.freeShippingThreshold)
|
||||
.filter((t): t is number => t !== null);
|
||||
const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CartContent />
|
||||
<RelatedProducts />
|
||||
{/* Suspense required — CartContent uses useSearchParams() (?code=
|
||||
auto-apply, see its own comment) which opts any consumer into
|
||||
client-side rendering unless wrapped. fallback={null}: the cart
|
||||
itself is entirely client-rendered from localStorage anyway
|
||||
(see CartContent's own productsLoading handling), so there's no
|
||||
meaningful server-rendered content this would flash away from. */}
|
||||
<Suspense fallback={null}>
|
||||
<CartContent
|
||||
trustBadges={trustBadges}
|
||||
shippingCost={defaultShipping?.price ?? 0}
|
||||
freeShippingThreshold={freeShippingThreshold}
|
||||
shippingSettings={shipping}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
showDiscountField={showDiscountField}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useNewsletterSignup } from "../../lib/useNewsletterSignup";
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, 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 (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
|
||||
{/* Stacked full-width below sm: — side by side, the button's own
|
||||
content width plus the input's min-w-0 squeeze left it cramped
|
||||
on a narrow phone. Default align-items: stretch in flex-col
|
||||
mode is what makes both the input and the button (shrink-0,
|
||||
fixed to its label's width) fill the row once stacked, no
|
||||
explicit w-full needed on either. */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-white border rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-[#d9d9d9] focus:border-[#f6a701]"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2 disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && <p className="text-[0.8rem] text-red-600">{emailError}</p>}
|
||||
{/* Consent checkbox — this signup's legal basis is consent (email
|
||||
marketing), same wording as the other newsletter forms; colors
|
||||
match this page's own hardcoded palette instead of the shared
|
||||
design tokens, consistent with the rest of the page. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[#444] leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#f6a701]"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && <p className="text-[0.8rem] text-red-600">{error}</p>}
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+50
-127
@@ -1,7 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { StepArrow } from "../components/StepArrow";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { EmailCapture } from "./components/EmailCapture";
|
||||
|
||||
const title = "7-Tage-Challenge – Mehr Klarheit in 7 Tagen";
|
||||
const description =
|
||||
@@ -71,29 +78,12 @@ function IconCheckCircle() {
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-0.5">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 mt-1">
|
||||
<path d="M3 9.5l4 4L15 4" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowRight() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-[#ccc] shrink-0">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: <IconEnvelope />,
|
||||
@@ -125,52 +115,10 @@ const benefits = [
|
||||
{ title: "Gelassener leben", desc: "Weniger Stress, mehr Zeit für die Dinge, die dir wichtig sind." },
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
avatar: "/avatar-1.jpg",
|
||||
quote: "„Die 7-Tage-Challenge hat mir geholfen, wieder klar zu sehen und mit kleinen Schritten wirklich etwas zu verändern.“",
|
||||
name: "Sarah M.",
|
||||
role: "Marketing Managerin",
|
||||
},
|
||||
{
|
||||
avatar: "/avatar-2.jpg",
|
||||
quote: "„Kurz, konkret und unglaublich wirkungsvoll. Ich habe direkt mehr Fokus und weniger Druck im Kopf.“",
|
||||
name: "Thomas K.",
|
||||
role: "Selbständiger Berater",
|
||||
},
|
||||
{
|
||||
avatar: "/avatar-3.jpg",
|
||||
quote: "„Endlich eine Challenge, die nicht überfordert, sondern genau die richtigen Impulse gibt – jeden Tag.“",
|
||||
name: "Miriam L.",
|
||||
role: "Projektleiterin",
|
||||
},
|
||||
];
|
||||
export default async function ChallengePage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("challenge", { draft: isPreview });
|
||||
|
||||
function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-3 w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-white border border-[#d9d9d9] rounded-lg px-4 py-3 text-[1rem] text-[#868686] outline-none focus:border-[#f6a701] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-[#f6a701] rounded-lg px-5 py-3 font-bold text-[1rem] text-[#222221] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f6a701] focus-visible:ring-offset-2"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengePage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-[#f5f0e8]">
|
||||
@@ -217,7 +165,7 @@ export default function ChallengePage() {
|
||||
>
|
||||
7 Tage.<br />
|
||||
Mehr Klarheit.<br />
|
||||
Weniger Stress.
|
||||
Weniger Stress<span className="text-brand">.</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
@@ -249,10 +197,12 @@ export default function ChallengePage() {
|
||||
todo-cards hero images; delay={0.15} mirrors their text→image
|
||||
stagger. */}
|
||||
<Reveal className="hidden lg:block group flex-1 relative overflow-hidden" delay={0.15}>
|
||||
<img
|
||||
<Image
|
||||
alt="7-Tage-Challenge"
|
||||
src="/blog-featured.jpg"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
|
||||
fill
|
||||
sizes="48vw"
|
||||
className="object-cover object-center pointer-events-none transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Badge */}
|
||||
<div className="absolute top-8 right-8 w-[7.5rem] h-[7.5rem] rounded-full bg-white shadow-md flex flex-col items-center justify-center text-center p-3 gap-1">
|
||||
@@ -282,20 +232,34 @@ export default function ChallengePage() {
|
||||
<p className="text-[1rem] text-[#666]">Jeden Tag ein Impuls. In nur wenigen Minuten.</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-start lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{/* items-center below lg: (was items-start) — the step blocks
|
||||
are centered columns now (see RevealItem below), so the
|
||||
connector arrows between them need to be centered too,
|
||||
not flush against the left edge. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row items-center lg:items-start gap-8 lg:gap-2 w-full">
|
||||
{steps.flatMap((step, i) => [
|
||||
<RevealItem key={step.title} className="group flex lg:flex-col items-start lg:items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
// Icon-above-text, centered, at every breakpoint now
|
||||
// (previously a left-aligned icon+text row below lg: —
|
||||
// fixed 2026-07-24 to match the lg: layout instead of
|
||||
// diverging from it).
|
||||
<RevealItem key={step.title} className="group flex flex-col items-center gap-4 lg:gap-5 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-16 h-14 shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
{step.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 lg:text-center">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="font-semibold text-[#222221] text-[1rem]">{step.title}</p>
|
||||
<p className="text-[0.875rem] text-[#666] leading-[1.5]">{step.desc}</p>
|
||||
</div>
|
||||
</RevealItem>,
|
||||
i < steps.length - 1 ? (
|
||||
<div key={`arrow-${i}`} className="hidden lg:flex items-center shrink-0 mt-5">
|
||||
<ArrowRight />
|
||||
// Shared StepArrow component (see its own file) — same
|
||||
// rotate-on-stack pattern as todo-cards/newsletter's
|
||||
// HowItWorks. Always visible (rotated 90° while stacked
|
||||
// below lg, this page's own structural breakpoint) rather
|
||||
// than hidden below lg like before. Bigger below lg:
|
||||
// (w-8 h-8, was w-6 h-6) per explicit feedback.
|
||||
<div key={`arrow-${i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
|
||||
<StepArrow className="w-8 h-8 rotate-90 lg:w-10 lg:h-4 lg:rotate-0" />
|
||||
</div>
|
||||
) : null,
|
||||
])}
|
||||
@@ -322,14 +286,15 @@ export default function ChallengePage() {
|
||||
|
||||
{/* Image */}
|
||||
<Reveal
|
||||
className="group w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
|
||||
className="group relative w-full lg:w-[44%] lg:shrink-0 rounded-xl overflow-hidden"
|
||||
style={{ minHeight: "18rem" }}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
alt="Notizbuch"
|
||||
src="/challenge-content.jpg"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ minHeight: "18rem" }}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 44vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
@@ -353,64 +318,22 @@ export default function ChallengePage() {
|
||||
</section>
|
||||
|
||||
{/* ── Was andere sagen ── */}
|
||||
{/* max-w-[1600px], not this page's other sections' 1280px — a
|
||||
deliberate compromise with /todo-cards's and /newsletter's
|
||||
unbounded fluid width, so all three testimonial sections cap
|
||||
at the same width instead of Challenge's reading narrower on
|
||||
wide viewports. Only this one section's cap changed, not the
|
||||
rest of the page. */}
|
||||
<section className="bg-white w-full py-14 lg:py-20">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
|
||||
|
||||
<Reveal
|
||||
className="font-semibold text-[#222221] text-center"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(1.5rem, 3vw, 2rem)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
{/* Same style + micro-interactions as /todo-cards's and
|
||||
/newsletter's identically-styled testimonial cards (kept in
|
||||
sync deliberately): decorative quote-mark, hover-lift on
|
||||
the card, avatar scale on the same hover via `group`. */}
|
||||
<RevealGroup className="flex flex-col lg:flex-row gap-6">
|
||||
{testimonials.map((t) => (
|
||||
<RevealItem
|
||||
key={t.name}
|
||||
className="group relative flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
alt={t.name}
|
||||
src={t.avatar}
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
|
||||
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{isPreview ? (
|
||||
<LiveTestimonialsGrid testimonials={testimonials} />
|
||||
) : (
|
||||
<TestimonialsGrid testimonials={testimonials} />
|
||||
)}
|
||||
|
||||
{/* ── Bottom CTA ── */}
|
||||
<section className="w-full py-14 lg:py-16">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1280px] mx-auto">
|
||||
<Reveal className="bg-[#f8f3ec] rounded-xl flex flex-col lg:flex-row gap-8 lg:gap-[3.5rem] items-start lg:items-center px-6 lg:px-10 py-8">
|
||||
|
||||
{/* Left: icon + copy */}
|
||||
<div className="flex gap-5 items-start flex-1 min-w-0">
|
||||
{/* Left: icon + copy — icon above text, centered, below lg:
|
||||
(matches the Home Newsletter card's icon-above-text
|
||||
pattern), row layout again from lg: up alongside the
|
||||
outer Reveal's own flex-col -> lg:flex-row switch. */}
|
||||
<div className="flex flex-col items-center text-center gap-5 lg:flex-row lg:items-start lg:text-left flex-1 min-w-0">
|
||||
<div className="shrink-0 -rotate-4">
|
||||
<svg width="52" height="44" viewBox="0 0 52 44" fill="none">
|
||||
<rect x="2" y="2" width="48" height="40" rx="3" stroke="#f6a701" strokeWidth="2" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
|
||||
// Loaded once at module scope (not per-render) — same reasoning as any
|
||||
// other client-side SDK singleton. Never called at all in test mode
|
||||
// (mounted conditionally below), so an unset publishable key there is
|
||||
// harmless.
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function getStripe(): Promise<Stripe | null> {
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
clientSecret: string;
|
||||
orderNumber: string;
|
||||
orderId: number;
|
||||
testMode: boolean;
|
||||
/** Only present in test mode — see api/checkout/route.ts's own comment. */
|
||||
providerReference?: string;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
if (testMode) {
|
||||
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
|
||||
}
|
||||
return (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret }}>
|
||||
<StripePaymentForm orderNumber={orderNumber} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handlePay(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
// Redirect-based (PayPal always redirects; cards may need a
|
||||
// 3-D-Secure redirect too) — confirmation itself is never trusted
|
||||
// client-side, see /checkout/verarbeitung's own comment. `if_required`
|
||||
// would skip the redirect for methods that don't need one, but the
|
||||
// return_url page's polling handles both cases identically either way,
|
||||
// so there's no benefit to branching here.
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
|
||||
},
|
||||
});
|
||||
// Only reached for immediate client-side failures (e.g. invalid card
|
||||
// number) — a redirect on success/pending never returns here at all.
|
||||
if (confirmError) {
|
||||
setError(confirmError.message ?? "Die Zahlung konnte nicht bestätigt werden.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handlePay} className="flex flex-col gap-4">
|
||||
<PaymentElement />
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || submitting}
|
||||
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function confirm(paymentStatus: "paid" | "failed") {
|
||||
setSubmitting(paymentStatus);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/webhooks/stripe/test-confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId, providerReference, paymentStatus }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Testzahlung fehlgeschlagen.");
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
|
||||
} catch {
|
||||
setError("Testzahlung konnte nicht ausgeführt werden.");
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-amber-500 bg-amber-50 p-4">
|
||||
<p className="text-sm font-semibold text-amber-800">PAYMENT_TEST_MODE aktiv — kein echtes Stripe-Konto verbunden.</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("paid")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-green-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "paid" ? "Wird bestätigt…" : "Testzahlung erfolgreich"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm("failed")}
|
||||
disabled={submitting !== null}
|
||||
className="rounded-full bg-red-600 px-5 py-2 text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting === "failed" ? "Wird bestätigt…" : "Testzahlung fehlgeschlagen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CheckoutContent } from "./components/CheckoutContent";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /cart.
|
||||
export const metadata: Metadata = {
|
||||
title: "Checkout",
|
||||
description: "Schließe deine Bestellung bei einfach produktiv ab.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, kleinunternehmer, session] = await Promise.all([
|
||||
getShippingMethods(),
|
||||
getShippingCountries(),
|
||||
getPaymentMethods(),
|
||||
getCartTrustBadges(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
getSessionCustomer(),
|
||||
]);
|
||||
// Full profile (incl. saved address) only fetched when a session exists
|
||||
// — pre-fills Card 1 for a returning customer instead of leaving it blank.
|
||||
const profile = session ? await getCustomerProfile(session.token) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CheckoutContent
|
||||
shippingMethods={shippingMethods}
|
||||
shippingCountries={shippingCountries}
|
||||
paymentMethods={paymentMethods}
|
||||
trustBadges={trustBadges}
|
||||
shippingSettings={shippingSettings}
|
||||
defaultTaxRate={defaultTaxRate}
|
||||
kleinunternehmer={kleinunternehmer}
|
||||
customerEmail={session?.customer.email ?? null}
|
||||
savedProfile={profile}
|
||||
/>
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY } from "../../lib/order";
|
||||
import { clearCart } from "../../lib/cart";
|
||||
import { clearDiscount } from "../../lib/discount";
|
||||
import { clearCheckoutDraft } from "../../lib/checkoutDraft";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_TIMEOUT_MS = 15000;
|
||||
|
||||
// The Payment Element's return_url target (see PaymentStep.tsx) — reached
|
||||
// after a card confirms client-side or a PayPal redirect completes.
|
||||
// Neither of those is trustworthy proof of payment on its own (see
|
||||
// spicy-leaping-pizza.md §3's own reasoning: a closed tab mid-PayPal-
|
||||
// redirect looks identical to success from here) — this page polls the
|
||||
// order's actual `paymentStatus`, which only the webhook-driven
|
||||
// confirm-payment endpoint ever sets, and only promotes the pending
|
||||
// sessionStorage snapshot to the confirmed one once that's true.
|
||||
export function VerarbeitungContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const orderNumber = searchParams.get("orderNumber");
|
||||
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
|
||||
const startedAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNumber) return;
|
||||
startedAt.current = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/checkout/status?orderNumber=${encodeURIComponent(orderNumber!)}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!data.ok) {
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "paid") {
|
||||
try {
|
||||
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (pending) {
|
||||
// Patch in the real instrument (Kreditkarte/PayPal) now
|
||||
// that it's known — the pending snapshot was written at
|
||||
// checkout submission time with the neutral "Online-
|
||||
// Zahlung" placeholder, before the customer had actually
|
||||
// picked one on the Payment Element.
|
||||
const snapshot = JSON.parse(pending);
|
||||
if (data.paymentMethodTitle) snapshot.paymentMethodTitle = data.paymentMethodTitle;
|
||||
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
|
||||
window.sessionStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Same private-browsing fallback as everywhere else this
|
||||
// sessionStorage snapshot is written — /bestellbestaetigung
|
||||
// has its own empty state.
|
||||
}
|
||||
clearCart();
|
||||
clearDiscount();
|
||||
clearCheckoutDraft();
|
||||
dispatchAuthChanged();
|
||||
router.push("/bestellbestaetigung");
|
||||
return;
|
||||
}
|
||||
if (data.paymentStatus === "failed" || data.status === "cancelled") {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
if (startedAt.current != null && Date.now() - startedAt.current > POLL_TIMEOUT_MS) {
|
||||
setState("timeout");
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderNumber]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col flex-1 items-center justify-center gap-6 py-24 px-[var(--layout-padding-x)] text-center">
|
||||
{state === "polling" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung wird bestätigt…
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Einen Moment bitte, das dauert normalerweise nur wenige Sekunden.</p>
|
||||
</>
|
||||
)}
|
||||
{state === "timeout" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Das dauert etwas länger
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung wird noch verarbeitet. Sobald sie bestätigt ist, schicken wir dir eine Bestätigungs-E-Mail — du musst hier nicht warten.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Zahlung fehlgeschlagen
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/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
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<>
|
||||
<p className="text-h-feature font-semibold text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Status konnte nicht geladen werden
|
||||
</p>
|
||||
<p className="text-body text-text-muted max-w-md">
|
||||
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
|
||||
</p>
|
||||
<Link
|
||||
href="/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
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { VerarbeitungContent } from "./VerarbeitungContent";
|
||||
|
||||
// robots: noindex — transactional page, same reasoning as /checkout itself.
|
||||
export const metadata: Metadata = {
|
||||
title: "Zahlung wird bestätigt",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function VerarbeitungPage() {
|
||||
// useSearchParams (reading ?orderNumber=) requires a Suspense boundary
|
||||
// in the App Router — this page has no meaningful loading state of its
|
||||
// own beyond what VerarbeitungContent already renders.
|
||||
return (
|
||||
<Suspense>
|
||||
<VerarbeitungContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "@einfach-produktiv/invoicing";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// @react-pdf/renderer's PDFViewer renders into an <iframe> via direct DOM
|
||||
// access — it has to be excluded from the server render pass entirely
|
||||
// (ssr: false), unlike the HTML-string email previews elsewhere in this
|
||||
// app, which can render server-side fine since they're just
|
||||
// dangerouslySetInnerHTML.
|
||||
const PDFViewer = dynamic(() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer), { ssr: false });
|
||||
|
||||
// Same useLivePreview() mechanism as LiveEmailPreviewClient.tsx — connects
|
||||
// to the Payload admin's iframe via postMessage and updates `data` as the
|
||||
// admin edits company-settings fields, no save required. Renders through
|
||||
// the exact same InvoiceDocument component the real invoice PDF uses
|
||||
// (app/lib/invoicePdf.tsx), against a fixed sample order
|
||||
// (SAMPLE_INVOICE_ORDER) — there's no "current" real order to preview
|
||||
// against generically, same reasoning as the email-templates preview's
|
||||
// own SAMPLE_ORDER.
|
||||
export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialSettings: CompanySettings }) {
|
||||
const { data } = useLivePreview<CompanySettings>({
|
||||
initialData: initialSettings,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
|
||||
{/* kleinunternehmer isn't part of InvoiceSeller (it's snapshotted
|
||||
per-order, not read live off the seller — see invoicePdf.tsx's
|
||||
own comment) — merged onto the sample order here only, so an
|
||||
admin toggling the checkbox sees the §19 notice reflected live
|
||||
without this preview needing its own separate mechanism. */}
|
||||
<InvoiceDocument order={{ ...SAMPLE_INVOICE_ORDER, kleinunternehmer: data.kleinunternehmer }} seller={data} />
|
||||
</PDFViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { draftMode } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCompanySettings, type CompanySettings } from "../lib/payload";
|
||||
import { LiveCompanySettingsPreviewClient } from "./components/LiveCompanySettingsPreviewClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Firmendaten-Vorschau",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const FALLBACK: CompanySettings = {
|
||||
sellerName: "",
|
||||
legalForm: "sole-proprietorship",
|
||||
registerCourt: null,
|
||||
registerNumber: null,
|
||||
managingDirector: null,
|
||||
shareCapital: null,
|
||||
sellerStreet: "",
|
||||
sellerZip: "",
|
||||
sellerCity: "",
|
||||
sellerCountry: "",
|
||||
sellerEmail: "",
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
|
||||
// gated on Draft Mode actually being enabled, unlike email-templates'
|
||||
// preview: this data includes a real bank IBAN/address once filled in,
|
||||
// not just marketing email copy, so this page must not render for an
|
||||
// unauthenticated visitor who happens to find the URL. Unlike
|
||||
// email-templates there's no draft/published distinction in the data
|
||||
// itself (company-settings has no content-versioning concept, it's just
|
||||
// the current row) — the initial fetch is the same live data
|
||||
// getCompanySettings() always returns, useLivePreview() takes over from
|
||||
// there as the admin edits fields.
|
||||
export default async function CompanySettingsPreviewPage() {
|
||||
const draft = await draftMode();
|
||||
if (!draft.isEnabled) notFound();
|
||||
const initialSettings = (await getCompanySettings()) ?? FALLBACK;
|
||||
return <LiveCompanySettingsPreviewClient initialSettings={initialSettings} />;
|
||||
}
|
||||
+34
-17
@@ -1,3 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
export function About() {
|
||||
@@ -5,9 +6,16 @@ export function About() {
|
||||
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
|
||||
|
||||
{/* Text content — relative + z-10 so it renders above the overlapping
|
||||
photo at md+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake). */}
|
||||
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1_0_0] min-w-0 relative z-10">
|
||||
photo at lg+. Comes first in DOM at every breakpoint (no reorder
|
||||
here — unlike Hero, there's no conversion CTA at stake).
|
||||
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">
|
||||
|
||||
{/* Large serif statement — width-constrained as per design */}
|
||||
<p
|
||||
@@ -18,8 +26,13 @@ export function About() {
|
||||
</p>
|
||||
|
||||
{/* Quote row: script quote / divider / author bio — side-by-side
|
||||
from md+, stacked with a horizontal divider below md */}
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6 md:gap-0 w-full">
|
||||
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
|
||||
divider) only once there's real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
|
||||
|
||||
{/* Caveat script text with signature positioned below */}
|
||||
<div className="relative flex-1" style={{ minHeight: "8rem" }}>
|
||||
@@ -50,12 +63,12 @@ export function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider — horizontal full-width line below md, vertical
|
||||
gold line beside the author bio from md+ */}
|
||||
<div className="bg-brand w-full h-px md:w-[2px] md:h-24 md:mx-6 shrink-0" />
|
||||
{/* Divider — horizontal full-width line below lg:, vertical
|
||||
gold line beside the author bio from lg: */}
|
||||
<div className="bg-brand w-full h-px lg:w-[2px] lg:h-24 lg:mx-6 shrink-0" />
|
||||
|
||||
<div
|
||||
className="text-bg-white font-normal whitespace-nowrap md:shrink-0"
|
||||
className="text-bg-white font-normal whitespace-nowrap lg:shrink-0"
|
||||
style={{ fontSize: "1rem", lineHeight: "1.5rem" }}
|
||||
>
|
||||
<p>Björn.</p>
|
||||
@@ -67,21 +80,25 @@ export function About() {
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Author photo — overlaps the text column via -ml-48 from md+ only
|
||||
(that overlap trick has nothing to blend into once stacked);
|
||||
plain full-width photo below the text on Mobile. */}
|
||||
{/* Author photo — 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. */}
|
||||
<Reveal
|
||||
className="relative overflow-hidden w-full md:flex-[1.4_0_0] md:-ml-48"
|
||||
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
|
||||
style={{ minHeight: "14rem" }}
|
||||
delay={0.15}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
alt="Björn"
|
||||
src="/about-author.jpg"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center pointer-events-none"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
|
||||
className="object-cover object-center pointer-events-none"
|
||||
/>
|
||||
{/* Left gradient: wide enough to cover the text-column overlap — md+ only */}
|
||||
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
|
||||
{/* Left gradient: 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" />
|
||||
</Reveal>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
const PRODUCT_ID = "todo-karten";
|
||||
|
||||
/**
|
||||
* Shared by /todo-cards's Hero + pricing panel and Home's product
|
||||
@@ -17,19 +16,46 @@ const PRODUCT_ID = "todo-karten";
|
||||
export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
productId = "todo-karten",
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
/** Defaults to "todo-karten" for /todo-cards' own hardcoded usage — Home's
|
||||
* ProductSpotlight passes the actual CMS-selected spotlight product's id
|
||||
* explicitly, since that can now be a different product. */
|
||||
productId?: string;
|
||||
/** Product-level — only meaningful when `variants` is empty, same split as
|
||||
* AddToCartInlineButton. */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap. */
|
||||
maxQty?: number | null;
|
||||
/** Optional — same shape/semantics as AddToCartInlineButton's own
|
||||
* `variants` prop; all three callers already fetch the full product
|
||||
* server-side, so this is just threaded straight through. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
addToCart(PRODUCT_ID);
|
||||
if (disabled) return;
|
||||
addToCart(productId, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -45,31 +71,86 @@ export function AddToCartButton({
|
||||
// into `base`, since appending on top can't rely on CSS source order
|
||||
// the way branching a whole className (AddToCartInlineButton's
|
||||
// approach) can when the base itself varies per caller.
|
||||
const stateClasses = added ? "bg-success! hover:bg-success! text-white!" : "";
|
||||
// Pale success-subtle fill + success text + a success-colored border, not
|
||||
// a solid success-green fill with white text — same restrained pairing
|
||||
// AddToCartInlineButton already uses (border-success + bg-success-subtle),
|
||||
// a solid bright-green button read as too loud here. `border` (width) is
|
||||
// added here too since `base` has none by default, unlike
|
||||
// AddToCartInlineButton's own base which already carries a plain border.
|
||||
const stateClasses = disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
|
||||
: "";
|
||||
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
button visibly shrink while showing the success state. Stacking
|
||||
both possible texts in the same grid cell (both invisible ones
|
||||
still contribute to sizing) reserves width for whichever is
|
||||
wider, so the button's box never changes size either way. */}
|
||||
<span className="relative grid">
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
{label}
|
||||
// Low stock is deliberately NOT surfaced here as its own text line
|
||||
// (it used to be) — that made this block's height vary card-to-card
|
||||
// in every grid that renders this component, breaking equal-height
|
||||
// card alignment (ProductSpotlight's CTA row, RelatedProducts' grid).
|
||||
// The image-overlaid pill badge (ProductGrid.tsx/ProductSpotlight.tsx/
|
||||
// RelatedProducts.tsx, position: absolute, doesn't participate in
|
||||
// layout flow) is the one place this now shows, same as
|
||||
// Ausverkauft/discount already do. The variant-select suffix below is
|
||||
// unaffected — a native <select>'s own height doesn't vary with its
|
||||
// option text.
|
||||
<div className="flex flex-col gap-2">
|
||||
{variants.length > 0 && (
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
aria-label="Variante auswählen"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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}
|
||||
</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>
|
||||
<span className="invisible [grid-area:1/1]" aria-hidden="true">
|
||||
Hinzugefügt ✓
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import Image from "next/image";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
@@ -19,20 +20,51 @@ export function AddToCartInlineButton({
|
||||
id,
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
maxQty = null,
|
||||
variants = [],
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Product-level — only meaningful when `variants` is empty. A varianted
|
||||
* product's buyability is entirely per-variant instead (see below). */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level cap on total cart quantity — only meaningful when
|
||||
* `variants` is empty, same split as `outOfStock`. null means no cap
|
||||
* (backorder allowed / inventory untracked). See lib/payload.ts's
|
||||
* maxPurchasableQty(). */
|
||||
maxQty?: number | null;
|
||||
/** Optional — products.variants (name + optional priceOverride + its own
|
||||
* outOfStock). When non-empty, a variant must be picked (defaults to the
|
||||
* first *in-stock* one, or just the first if all are out) before "add to
|
||||
* cart" is enabled — the selected variant's name is snapshotted onto the
|
||||
* cart line and, later, the order itself. */
|
||||
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
const cart = useCart();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
// Whichever is actually being offered right now — the selected variant's
|
||||
// own flag if there are variants, otherwise the plain product-level one.
|
||||
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
|
||||
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
|
||||
// How much of this exact (id, variant) line is already sitting in the
|
||||
// cart — capped adds mean "In den Warenkorb" must go disabled once this
|
||||
// reaches currentMaxQty, not just when the product is fully sold out.
|
||||
const qtyInCart = cart.find((i) => i.id === id && i.variant === selectedVariant)?.qty ?? 0;
|
||||
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
|
||||
const disabled = currentlyOutOfStock || limitReached;
|
||||
|
||||
function handleClick() {
|
||||
addToCart(id);
|
||||
if (disabled) return;
|
||||
addToCart(id, 1, selectedVariant);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -47,21 +79,51 @@ export function AddToCartInlineButton({
|
||||
// anymore (it's a trailing `!` now), so two conflicting utilities like
|
||||
// border-border/border-success both being present would silently race on
|
||||
// CSS source order instead of one cleanly winning.
|
||||
const stateClasses = added
|
||||
? "border-success bg-success-subtle"
|
||||
: "border-border hover:border-brand";
|
||||
const stateClasses = disabled
|
||||
? "border-border opacity-60 cursor-not-allowed"
|
||||
: added
|
||||
? "border-success bg-success-subtle"
|
||||
: "border-border hover:border-brand";
|
||||
|
||||
return (
|
||||
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
// Low stock isn't shown as its own text line here (see
|
||||
// AddToCartButton.tsx's identical comment on why) — the image-overlaid
|
||||
// pill badge (ProductGrid.tsx/RelatedProducts.tsx, position: absolute,
|
||||
// outside layout flow) is where this shows now, same as
|
||||
// Ausverkauft/discount already do.
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{variants.length > 0 && (
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
aria-label="Variante auswählen"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<img alt="" src="/icon-cart-outline.png" 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")
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-4
@@ -63,11 +63,14 @@ export async function Blog() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see Tools.tsx's comment on
|
||||
this same fix (→'s glyph baseline sits low next to text). */}
|
||||
<Link
|
||||
href={featured.href}
|
||||
className="font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
@@ -109,11 +112,14 @@ export async function Blog() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* flex + arrow as its own span — see the featured post's
|
||||
own Link above / Tools.tsx's comment on this same fix. */}
|
||||
<Link
|
||||
href={post.href}
|
||||
className="font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ Zum Beitrag
|
||||
<span aria-hidden>→</span>
|
||||
<span>Zum Beitrag</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCart } from "../lib/cart";
|
||||
|
||||
// Mirrors the local cart to the server whenever it changes, so a logged-in
|
||||
// customer's cart follows them across devices (see Customers.ts's `cart`
|
||||
// field). Renders nothing — mounted once in the root layout. Debounced
|
||||
// (not fired on every keystroke-equivalent quantity bump) and silently a
|
||||
// no-op when logged out — POST /api/account/cart 401s in that case, which
|
||||
// this component doesn't need to distinguish from success; there's simply
|
||||
// nothing to keep in sync yet.
|
||||
export function CartSync() {
|
||||
const cart = useCart();
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the mount-time fire — this would otherwise POST on every page
|
||||
// load even when nothing actually changed.
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fetch("/api/account/cart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cart }),
|
||||
}).catch(() => {
|
||||
// Best-effort — a failed sync just means the next cart change (or
|
||||
// the next login-time merge) tries again.
|
||||
});
|
||||
}, 800);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [cart]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
const STEP_LABELS = ["Warenkorb", "Adresse", "Zahlung", "Abschluss"];
|
||||
|
||||
type StepState = "done" | "active" | "upcoming";
|
||||
|
||||
function StepCircle({ state, number }: { state: StepState; number: number }) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-brand text-text-primary">
|
||||
<svg viewBox="0 0 20 20" className="size-4" fill="none" aria-hidden="true">
|
||||
<path d="m4 10 4 4 8-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "active") {
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-brand font-bold text-body-sm text-bg-white">
|
||||
{number}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// Back to the shared border-border (reverted 2026-07-24 per feedback —
|
||||
// only the connector line below should be the darker #c4b8a0).
|
||||
<div className="flex size-9 items-center justify-center rounded-full border border-border font-bold text-body-sm text-text-muted">
|
||||
{number}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared by /checkout (mid-flow, one step active) and /bestellbestaetigung
|
||||
// (every step done — `current` past the last step number makes every
|
||||
// stepNum < current true, so passing STEP_LABELS.length + 1 there marks
|
||||
// the whole bar complete without a separate "all done" branch).
|
||||
export function CheckoutSteps({ current }: { current: number }) {
|
||||
return (
|
||||
<div className="flex items-center w-full max-w-[50rem]">
|
||||
{STEP_LABELS.map((label, i) => {
|
||||
const stepNum = i + 1;
|
||||
const state: StepState = stepNum < current ? "done" : stepNum === current ? "active" : "upcoming";
|
||||
return (
|
||||
<div key={label} className="flex items-center flex-1 last:flex-initial">
|
||||
<div className="flex flex-col items-center gap-0 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<StepCircle state={state} number={stepNum} />
|
||||
<span
|
||||
className={`hidden sm:block text-body-sm whitespace-nowrap ${
|
||||
state === "upcoming" ? "text-text-muted" : "font-semibold text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{i < STEP_LABELS.length - 1 && <div className="h-px bg-[#c4b8a0] flex-1 mx-4 min-w-4" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
function Word({ children }: { children: string }) {
|
||||
return (
|
||||
<p
|
||||
className="font-bold leading-normal text-text-primary text-h2 whitespace-nowrap"
|
||||
className="font-bold leading-normal text-text-primary text-[length:var(--divider-word-size)] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
@@ -16,7 +17,7 @@ function Arrow() {
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
<div className="-scale-y-100 rotate-180">
|
||||
<div className="relative" style={{ height: "var(--divider-arrow-h)", width: "var(--divider-arrow-w)" }}>
|
||||
<img alt="" src="/icon-separator.svg" className="absolute inset-0 w-full h-full" />
|
||||
<Image alt="" src="/icon-separator.svg" fill sizes="48px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,24 +26,32 @@ function Arrow() {
|
||||
|
||||
export function Divider() {
|
||||
return (
|
||||
<Reveal className="flex items-center justify-center flex-wrap gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center">
|
||||
<Reveal
|
||||
delay={0.3}
|
||||
className="flex items-center justify-center flex-wrap gap-x-3 sm:gap-x-8 gap-y-3 pb-5 pt-12 px-[var(--layout-padding-x)] w-full bg-bg-base text-center"
|
||||
>
|
||||
|
||||
{/* Word + its trailing icon are grouped into one shrink-0 flex unit
|
||||
so flex-wrap only ever breaks BETWEEN pairs, never leaving an
|
||||
arrow stranded alone on its own line — the arrows are always
|
||||
visible now (previously hidden below md: entirely to sidestep
|
||||
that exact problem), this fixes the root cause instead. */}
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
that exact problem), this fixes the root cause instead.
|
||||
gap-3/sm:gap-8 (not a flat gap-8): below 640px the words and
|
||||
icons already shrink via --divider-word-size/--divider-arrow-*
|
||||
(see globals.css), tightening the gaps too is what gets the
|
||||
whole phrase close to fitting on one row instead of each pair
|
||||
wrapping to its own line. */}
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Klarheit</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Fokus</Word>
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 shrink-0">
|
||||
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
|
||||
<Word>Entlastung</Word>
|
||||
|
||||
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
|
||||
@@ -57,7 +66,7 @@ export function Divider() {
|
||||
className="relative"
|
||||
style={{ height: "var(--divider-sparkle-inner-h)", width: "var(--divider-sparkle-inner-w)" }}
|
||||
>
|
||||
<img alt="" src="/icon-separator-right.svg" className="absolute inset-0 w-full h-full" />
|
||||
<Image alt="" src="/icon-separator-right.svg" fill sizes="48px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,12 @@ export function Footer() {
|
||||
{/* Footer inner — max-width 1280px, centered */}
|
||||
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
|
||||
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below md */}
|
||||
<div className="flex flex-col md:flex-row items-center md:justify-between gap-6 md:gap-0 px-8 md:px-16 w-full">
|
||||
{/* Three groups: logo | @handle | links — stacked + centered below
|
||||
lg: (was md:). Logo + handle + 5 legal links all side by side
|
||||
with justify-between read too cramped on Tablet — stacked
|
||||
through that range instead, side by side again once there's
|
||||
real room at lg:. */}
|
||||
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
|
||||
|
||||
{/* Logo: "einfach produktiv" white + "." gold */}
|
||||
<div className="flex items-center p-2 shrink-0">
|
||||
@@ -33,12 +37,15 @@ export function Footer() {
|
||||
</div>
|
||||
|
||||
{/* Social handle — Lora Regular (weight 400) */}
|
||||
<p
|
||||
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0"
|
||||
<a
|
||||
href="https://www.instagram.com/einfach.produktiv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-normal text-bg-white text-h-small text-center leading-[1.2] whitespace-nowrap shrink-0 hover:text-brand transition-colors"
|
||||
style={{ fontFamily: "var(--font-lora)", fontWeight: 400 }}
|
||||
>
|
||||
@einfach.produktiv
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{/* Legal links — wrap + center below md instead of a rigid row */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 shrink-0">
|
||||
|
||||
+97
-61
@@ -2,75 +2,118 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PopIn, Reveal } from "./Reveal";
|
||||
|
||||
// Shared between the plain (below lg:) and Reveal-wrapped (lg:+) render —
|
||||
// see the two call sites' own comment on why this needs two wrappers.
|
||||
function HeroImage() {
|
||||
return (
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Structural breakpoint is lg: (1024px) here, not the site-wide md:
|
||||
(768px) — a documented exception (see Gotcha in the figma-to-nextjs
|
||||
skill). At md:col-span-5 the text column was only ~320px at
|
||||
768-1023px viewports, too narrow for the heading/CTA/social-proof
|
||||
row (which wrapped to 3 cramped lines). Staying stacked full-width
|
||||
through the whole Tablet range and only splitting into the 5/7
|
||||
grid once there's real room (≥1024px) fixes that without touching
|
||||
the 5/7 ratio itself, which is fine once it has space. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 lg:items-center gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12 lg:pt-0">
|
||||
{/* Structural breakpoint is md: (768px) for the GRID only — the text
|
||||
column stays ~283-320px wide through the whole 768-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">
|
||||
|
||||
{/* Text content — first in DOM/visual order at every breakpoint so
|
||||
the CTA stays above the fold on Mobile (deliberate exception to
|
||||
the "keep DOM order" default, see Hero decision in the plan).
|
||||
Reveal fires ~immediately since Hero is already in the initial
|
||||
viewport — this doubles as the page's entrance animation. */}
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
<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">
|
||||
|
||||
{/* Heading — forced break after "darf" below lg: (1024px),
|
||||
natural wrap from lg: up. Below lg: the Hero is stacked
|
||||
full-width and narrower per-viewport, where natural wrap
|
||||
produced an awkward break — force it after "darf" there via
|
||||
a responsive <br/> (visible by default, turned off at lg:+).
|
||||
From lg: up the 5/12 grid's text column wraps fine on its
|
||||
own, no forced break needed. */}
|
||||
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
|
||||
real paired font-size+line-height token, not an arbitrary
|
||||
value) — text-display's own 44px floor wraps very heavily in
|
||||
a ~283-320px Tablet column (even a single word can approach
|
||||
that width). 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. */}
|
||||
<p
|
||||
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
<span className="text-display">
|
||||
Produktivität darf<br className="lg:hidden" /> sich leicht anfühlen
|
||||
<span className="text-h1 lg:text-display">
|
||||
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
|
||||
</span>
|
||||
{/* Brand's signature orange dot (also in the logo/footer) —
|
||||
bouncy pop-in once the heading scrolls into view, timed to
|
||||
land just after the Reveal's own 0.6s fade-up so it reads
|
||||
as a deliberate flourish, not simultaneous with the text.
|
||||
One-shot, not a looping pulse — continuous motion next to
|
||||
the primary CTA would be distracting rather than "cool". */}
|
||||
<PopIn className="text-display text-brand inline-block" delay={0.5}>
|
||||
the primary CTA would be distracting rather than "cool".
|
||||
Same text-h1 lg:text-display as the heading itself, so the
|
||||
dot scales down to match below lg:. */}
|
||||
<PopIn className="text-h1 lg:text-display text-brand inline-block" delay={0.5}>
|
||||
.
|
||||
</PopIn>
|
||||
</p>
|
||||
|
||||
{/* Subheading */}
|
||||
<p className="font-semibold leading-[2.375rem] min-w-full shrink-0 text-text-primary text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
|
||||
{/* 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>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/challenge"
|
||||
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
|
||||
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"
|
||||
>
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-h3 whitespace-nowrap not-italic">
|
||||
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
|
||||
put the icon beside a two-line text block, which read as
|
||||
broken rather than intentional. Smaller fixed size below
|
||||
lg: instead, so the full phrase fits on one line within
|
||||
the column's width — text-h3's own 19px floor was still
|
||||
too wide for that, both on a 375px phone AND in the
|
||||
~283-320px Tablet grid column. */}
|
||||
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
|
||||
Starte mit der 7-Tage-Challenge
|
||||
</span>
|
||||
<div className="relative h-[1.1875rem] w-[1.5625rem] shrink-0">
|
||||
<img
|
||||
alt=""
|
||||
src="/icon-check.svg"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
{/* Scaled down to match the smaller CTA text (same ~0.76
|
||||
aspect ratio as the lg: size), full size again from lg: up
|
||||
alongside text-h3. */}
|
||||
<div className="relative h-[0.8125rem] w-[1.0625rem] lg:h-[1.1875rem] lg:w-[1.5625rem] shrink-0">
|
||||
<Image alt="" src="/icon-check.svg" fill sizes="(min-width: 1024px) 26px, 17px" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex gap-3 items-start overflow-clip shrink-0 w-full">
|
||||
{/* Social proof — always avatars-then-text on two lines below
|
||||
lg: (not just when it happens to overflow), single row again
|
||||
from lg: up where the real column width fits it fine. */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 items-center 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) => (
|
||||
@@ -83,46 +126,39 @@ export function Hero() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body [word-break:break-word]">
|
||||
<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>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Image — bleeds to the true edge at every breakpoint (never
|
||||
padded). Below lg: (stacked layout) the full 887:583 aspect
|
||||
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 lg:+ (grid, image only
|
||||
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. A small negative top margin below lg: pulls it up to
|
||||
slightly tuck under the text block (deliberately less than the
|
||||
social-proof row's height, so it never covers the avatars/text).
|
||||
Scoped to md:-only (mt-0 at base and again at lg:) — it's a
|
||||
Tablet-specific touch, not a permanent effect. No shadow: a
|
||||
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. */}
|
||||
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
|
||||
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
|
||||
plenty of room and this was never an issue. */}
|
||||
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
|
||||
<HeroImage />
|
||||
</div>
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 relative w-full aspect-[887/583] max-h-[16rem] md:max-h-[22rem] lg:max-h-none mt-0 md:-mt-6 lg:mt-0"
|
||||
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
WebkitMaskComposite: "destination-in",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0%, black 14%), linear-gradient(to bottom, transparent 0%, black 10%)",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
<HeroImage />
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { RichText } from "./RichText";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Wraps just the RichText body of a legal page (/agb, /datenschutz,
|
||||
// /impressum, /widerruf) for Payload Live Preview — those 4 pages' own
|
||||
// headings/sidebars/TOC are hardcoded per page, not sourced from
|
||||
// `page.title` at all, so `content` is the only field that actually
|
||||
// benefits from real-time editing preview. Falls back to the
|
||||
// server-fetched `initialContent` until a postMessage arrives, which only
|
||||
// happens at all while this page is open inside the Payload admin's Live
|
||||
// Preview iframe — ordinary visitors never mount this differently from a
|
||||
// plain <RichText>.
|
||||
export function LiveRichText({ initialContent, quoteLabel }: { initialContent: unknown; quoteLabel?: string }) {
|
||||
const { data } = useLivePreview<{ content: unknown }>({
|
||||
initialData: { content: initialContent },
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
return <RichText content={data.content} quoteLabel={quoteLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import { TestimonialsGrid } from "./TestimonialsGrid";
|
||||
import { mapPayloadTestimonial, type PayloadTestimonial, type Testimonial } from "../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Live Preview is document-scoped (Payload's admin has exactly one
|
||||
// testimonial open at a time), but this page renders a *grid* of several —
|
||||
// so unlike LiveRichText/LivePostContent (which map 1:1 to a single
|
||||
// document), this only swaps in the one testimonial currently being edited
|
||||
// (matched by id) and leaves the rest of the grid as initially fetched.
|
||||
// initialData starts empty since we don't know which of the `testimonials`
|
||||
// is open until the first postMessage arrives — acceptable because this
|
||||
// component only ever mounts inside the Payload admin's own preview
|
||||
// iframe (see the `isPreview` gate in todo-cards/newsletter/challenge's
|
||||
// page.tsx), never for ordinary site visitors.
|
||||
export function LiveTestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
|
||||
const { data } = useLivePreview<Partial<PayloadTestimonial>>({
|
||||
initialData: {},
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const merged =
|
||||
data.id !== undefined
|
||||
? testimonials.map((t) => (t.id === data.id ? mapPayloadTestimonial(data as PayloadTestimonial) : t))
|
||||
: testimonials;
|
||||
|
||||
return <TestimonialsGrid testimonials={merged} />;
|
||||
}
|
||||
+281
-93
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
|
||||
const SCROLL_DURATION = 800; // ms
|
||||
const SCROLL_DURATION = 200; // ms
|
||||
|
||||
function smoothScrollTo(targetY: number) {
|
||||
const startY = window.scrollY;
|
||||
@@ -31,16 +33,21 @@ function smoothScrollTo(targetY: number) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||||
{ label: "Blog", href: "#blog" },
|
||||
{ label: "Über Björn", href: "#ueber-bjoern" },
|
||||
{ label: "Shop", href: "/shop" },
|
||||
];
|
||||
|
||||
const anchorIds = navLinks
|
||||
.filter((l) => l.href.startsWith("#"))
|
||||
.map((l) => l.href.slice(1));
|
||||
// "Shop" becomes an in-page anchor to the homepage's ProductSpotlight
|
||||
// section (id="spotlight") instead of a real /shop navigation whenever
|
||||
// exactly 1 product is active — same reasoning as the other anchor links,
|
||||
// #werkzeuge/#ueber-bjoern already have (a full catalog grid is
|
||||
// degenerate UX with only 1 item to show). Passed down from
|
||||
// app/layout.tsx, which is the one place already fetching the product
|
||||
// catalog for this decision.
|
||||
function getNavLinks(singleActiveProduct: boolean) {
|
||||
return [
|
||||
{ label: "Werkzeuge", href: "#werkzeuge" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Über Björn", href: "#ueber-bjoern" },
|
||||
{ label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" },
|
||||
];
|
||||
}
|
||||
|
||||
// "Werkzeuge" also covers standalone tool/product pages that live under
|
||||
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
|
||||
@@ -66,6 +73,64 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
// Account icon — no Figma source exists for this yet (added outside the
|
||||
// normal Figma-first workflow, see the assistant's own project notes on
|
||||
// why: without it there was no reachable way to log in at all once the
|
||||
// cart was empty and no recent order existed — /checkout's own login
|
||||
// toggle never even renders in that state, see CheckoutContent.tsx's
|
||||
// early "Warenkorb ist leer" return). Fetches auth state client-side via
|
||||
// /api/account/me rather than through the server-rendered layout — this
|
||||
// component's parent (app/layout.tsx) is otherwise static/ISR-cacheable,
|
||||
// and reading the session cookie there (next/headers' cookies()) would
|
||||
// force the entire site into per-request dynamic rendering just for this.
|
||||
// `loggedIn === null` is the brief "not checked yet" state on first paint.
|
||||
function AccountLink() {
|
||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
fetch("/api/account/me")
|
||||
.then((res) => setLoggedIn(res.ok))
|
||||
.catch(() => setLoggedIn(false));
|
||||
}
|
||||
checkAuth();
|
||||
// Navbar lives in the root layout and never unmounts across
|
||||
// navigations, so this effect only ever runs once on its own —
|
||||
// router.refresh() (called after login/logout) re-fetches Server
|
||||
// Component data but doesn't re-run an already-mounted Client
|
||||
// Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by
|
||||
// every login/logout call site (see dispatchAuthChanged() in
|
||||
// ../lib/auth) so this stays in sync without a hard reload.
|
||||
window.addEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
}, []);
|
||||
|
||||
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
|
||||
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
{/* -translate-y-0.5 — the glyph's own bounding box centers fine
|
||||
mathematically, but the round head (light, isolated) versus the
|
||||
wide shoulders (heavier, at the bottom) reads as optically
|
||||
bottom-heavy next to the cart icon, sitting visibly lower.
|
||||
Nudged up to match (fixed 2026-07-24). */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7 text-text-primary -translate-y-0.5" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
{/* Only real "am I logged in?" signal on the site outside /konto
|
||||
itself — same brand-colored underline language as the desktop
|
||||
nav links' active-state indicator, so it reads as consistent
|
||||
rather than a new visual idiom. */}
|
||||
{loggedIn && <span aria-hidden className="absolute bottom-1 h-[2px] w-4 bg-brand rounded-full" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -145,7 +210,7 @@ function CartLink() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
@@ -154,6 +219,12 @@ export function Navbar() {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const navLinks = useMemo(() => getNavLinks(singleActiveProduct), [singleActiveProduct]);
|
||||
const anchorIds = useMemo(
|
||||
() => navLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.slice(1)),
|
||||
[navLinks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
@@ -170,10 +241,25 @@ export function Navbar() {
|
||||
// whenever pathname becomes "/" (covers both the initial load and a
|
||||
// client-side transition landing here), a short delay lets layout
|
||||
// settle first.
|
||||
//
|
||||
// The no-hash branch below matters just as much: since the Navbar lives
|
||||
// in the root layout (never unmounts across navigations), Next.js's
|
||||
// default Link scroll behavior treats "/" as already-visible and leaves
|
||||
// the current scrollY untouched instead of resetting to top (see
|
||||
// next/dist/docs .../link.md's "maintain scroll position" default).
|
||||
// Landing on Home from a page scrolled halfway down (e.g. clicking the
|
||||
// logo from a scrolled /shop) then visually "lands" wherever that old
|
||||
// offset happens to fall in Home's layout — often right around the
|
||||
// Werkzeuge section — instead of at the top. Forcing scrollTo(0, 0) here
|
||||
// makes a plain logo/Home navigation always start at the top, exactly
|
||||
// like the same-page click handler below already does.
|
||||
useEffect(() => {
|
||||
if (pathname !== "/") return;
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (!anchorIds.includes(hash)) return;
|
||||
if (!anchorIds.includes(hash)) {
|
||||
window.scrollTo(0, 0);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
const el = document.getElementById(hash);
|
||||
if (el) {
|
||||
@@ -182,7 +268,7 @@ export function Navbar() {
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname]);
|
||||
}, [pathname, anchorIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
@@ -201,7 +287,7 @@ export function Navbar() {
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [anchorIds]);
|
||||
|
||||
// Close on viewport resize past the structural breakpoint, so the drawer
|
||||
// never lingers open behind the (now visible) desktop nav. The hamburger
|
||||
@@ -224,13 +310,13 @@ export function Navbar() {
|
||||
const focusables = panel?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled])'
|
||||
);
|
||||
focusables?.[0]?.focus();
|
||||
focusables?.[0]?.focus({ preventScroll: true });
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMobileOpen(false);
|
||||
hamburgerRef.current?.focus();
|
||||
hamburgerRef.current?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !focusables || focusables.length === 0) return;
|
||||
@@ -238,10 +324,10 @@ export function Navbar() {
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,11 +335,55 @@ export function Navbar() {
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Background scroll lock while the fullscreen panel is open — same
|
||||
// wheel/touchmove interception as NewsletterModal.tsx (see that
|
||||
// component's own comment on why this approach over overflow:hidden or
|
||||
// position:fixed on body). Needed now that the panel actually covers the
|
||||
// viewport instead of pushing page content down in normal flow.
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
|
||||
const isInsidePanel = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!panelRef.current?.contains(target);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", onWheel, { passive: false });
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
return () => {
|
||||
document.removeEventListener("wheel", onWheel);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const closeMobile = () => setMobileOpen(false);
|
||||
|
||||
return (
|
||||
// Fragment, not just the <header> — NewsletterModal must NOT be a
|
||||
// descendant of it. `backdrop-blur-md` below is a `backdrop-filter`,
|
||||
// which (like `transform`) makes its element a new containing block
|
||||
// for `position: fixed` descendants per spec. Since the header only
|
||||
// gets that class once `scrolled` is true, the modal's `fixed inset-0`
|
||||
// backdrop silently stopped being fixed to the viewport and became
|
||||
// fixed to the 100px-tall header instead — centering math then ran
|
||||
// against that instead of the viewport, clipping the modal to the top
|
||||
// of the page. Exactly reproduced whenever the modal was opened while
|
||||
// scrolled (e.g. after clicking an anchor link), never at the very
|
||||
// top of the page (scrollY <= 8, no backdrop-blur yet) — which is why
|
||||
// it looked tied to "clicked an anchor first" rather than to scroll
|
||||
// position itself.
|
||||
<>
|
||||
<header
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
|
||||
// The mobile panel used to be a child of this element and needed the
|
||||
// header itself to be a flexible column that could grow for it — it's
|
||||
// now a fixed-position sibling instead (see that panel's own comment
|
||||
// on why), so this is back to a plain fixed-height bar.
|
||||
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"
|
||||
@@ -350,7 +480,18 @@ export function Navbar() {
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CartLink />
|
||||
{/* 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">
|
||||
<AccountLink />
|
||||
<CartLink />
|
||||
</div>
|
||||
|
||||
{/* CTA buttons — inline from md (768px) up, i.e. through both
|
||||
"Collapsed-CTA" and full Desktop tiers */}
|
||||
@@ -409,80 +550,127 @@ export function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer panel — toggleable below lg (see hamburger above) */}
|
||||
<div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className={`lg:hidden w-full overflow-hidden transition-[max-height] duration-300 ease-in-out ${
|
||||
mobileOpen ? "max-h-[30rem]" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
return link.href.startsWith("#") ? (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
</header>
|
||||
|
||||
{/* Fullscreen mobile panel — a sibling of <header>, deliberately NOT
|
||||
nested inside it (same reason as NewsletterModal, see the
|
||||
top-of-file comment: `mobileOpen` gives the header its own
|
||||
backdrop-blur, which would make it a new containing block for any
|
||||
`position: fixed` descendant and break the panel's fixed-to-
|
||||
viewport positioning). Circular clip-path reveal expanding from
|
||||
the hamburger's own corner (top-right) — the growing circle
|
||||
naturally sweeps toward the opposite corner (bottom-left) last,
|
||||
reading as the diagonal wipe this is going for without needing a
|
||||
literal diagonal clip polygon. `vmax` (not %) for the radius so
|
||||
full coverage holds regardless of viewport aspect ratio. */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
|
||||
{navLinks.map((link, i) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
// Staggered fade+rise entrance, timed to land after the
|
||||
// clip-path reveal has visibly opened up — same
|
||||
// "fancy but restrained" register as the rest of this
|
||||
// codebase's motion usage (Reveal.tsx et al.), not a
|
||||
// separate animation language just for this panel.
|
||||
const linkMotionProps = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
|
||||
};
|
||||
return link.href.startsWith("#") ? (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h-feature font-semibold text-text-primary w-fit"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
|
||||
className="flex flex-col gap-3 px-8 pb-16 pt-10"
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* md:hidden — these two duplicate the inline CTA pair that's
|
||||
already visible in the header itself from md (768px) up (see
|
||||
"Trailing controls" above); only genuinely missing below
|
||||
that, where the inline pair is hidden and the panel is
|
||||
these buttons' only way to reach them. No login/account CTA
|
||||
here (removed — Nutzer-Entscheidung: that's already reachable
|
||||
via the account icon in the header itself, outside this
|
||||
panel, no need to duplicate it inside). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
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
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+119
-28
@@ -1,7 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
// Same lock icon + copy as /challenge's and /newsletter's EmailCapture —
|
||||
// unified across all newsletter-signup forms instead of each having its
|
||||
// own wording/color for this trust note.
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
|
||||
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type NewsletterProps = {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
@@ -13,9 +31,12 @@ type NewsletterProps = {
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = "Starte mit einer Woche voller Klarheit",
|
||||
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.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
<section className="py-16 w-full">
|
||||
|
||||
@@ -25,16 +46,31 @@ export function Newsletter({
|
||||
{/* 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">
|
||||
|
||||
{/* Left: copy — fixed width from md+ so the form always gets the remaining space */}
|
||||
<div className="flex gap-8 items-start w-full md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
|
||||
{/* Left: copy — fixed width from 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">
|
||||
|
||||
{/* Decorative envelope icon, tilted -4° as per design */}
|
||||
{/* Decorative envelope icon, tilted -4° as per design.
|
||||
w-[4rem], not w-16 — this project's --spacing-16 is a
|
||||
fluid token (floors to 40px below 768px, see globals.css),
|
||||
so pairing w-16 with the fixed h-[3.438rem] squished the
|
||||
icon to a 40:55 box on mobile instead of the SVG's native
|
||||
64:55.0096 (it has preserveAspectRatio="none", so it
|
||||
actually stretches to whatever box it's given — fixed
|
||||
2026-07-24). */}
|
||||
<div className="flex items-center justify-center shrink-0 w-[4.23rem] h-[3.71rem]">
|
||||
<div className="-rotate-4 -scale-y-100">
|
||||
<img
|
||||
<Image
|
||||
alt=""
|
||||
src="/newsletter-icon.svg"
|
||||
className="w-16 h-[3.438rem] block"
|
||||
width={64}
|
||||
height={55}
|
||||
className="w-[4rem] h-[3.438rem] block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,29 +91,84 @@ export function Newsletter({
|
||||
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
<div className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
{/* Input + submit button — stacked below md, side by side from md+ */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="flex-1 min-w-0 bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<p className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich achte auf deine Daten. Kein Spam, jederzeit abbestellbar.
|
||||
{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>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
</div>
|
||||
{/* 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:. */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`flex-1 min-w-0 bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="shrink-0 bg-brand rounded-sm px-5 py-3 font-bold text-h4 text-text-primary tracking-[0.18px] whitespace-nowrap hover:brightness-95 active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
|
||||
{/* Consent checkbox — required since this signup's legal
|
||||
basis is consent (email marketing), not the "Ich achte
|
||||
auf deine Daten" trust note alone. Same wording/pattern
|
||||
as NewsletterModal's checkbox. */}
|
||||
<label className="flex gap-2 items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary font-normal leading-normal">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Privacy note — same icon/copy/color as the other
|
||||
newsletter forms (see /challenge's EmailCapture). */}
|
||||
<p className="flex items-center gap-1.5 text-label text-[#888] font-normal leading-normal">
|
||||
<LockIcon />
|
||||
Keine Werbung. Jederzeit abbestellbar.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Reveal>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useNewsletterSignup } from "../lib/useNewsletterSignup";
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -33,14 +35,43 @@ 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 } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Body scroll lock while open.
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
// touch input that would cause scrolling, instead of toggling
|
||||
// overflow/position on body or html. Two earlier approaches (plain
|
||||
// overflow:hidden on body; position:fixed with a negative top offset to
|
||||
// compensate) each fixed one symptom while causing another — overflow
|
||||
// alone reset scrollY to 0 for a frame when opened away from the top of
|
||||
// the page (e.g. parked at #ueber-bjoern), and position:fixed took body
|
||||
// out of normal flow, which detached the Navbar's `position: sticky`
|
||||
// from its scrolling container and made it visibly snap. Neither is a
|
||||
// risk here: scrollY, overflow and layout are never touched at all, so
|
||||
// there's nothing that can jump, reflow, or need restoring on close —
|
||||
// the events that would move the page just never get to.
|
||||
// `{ passive: false }` is required for preventDefault() to have any
|
||||
// effect on wheel/touchmove. Events that originate inside the dialog
|
||||
// (which has its own overflow-y-auto) are let through untouched, so the
|
||||
// modal's own content still scrolls normally.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const isInsideDialog = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!dialogRef.current?.contains(target);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!isInsideDialog(e.target)) e.preventDefault();
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isInsideDialog(e.target)) e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", onWheel, { passive: false });
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("wheel", onWheel);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -50,7 +81,16 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
// preventScroll: true — otherwise the browser's implicit
|
||||
// scrollIntoView on focus() sees this button sitting close to the
|
||||
// viewport's top edge and "corrects" for the global
|
||||
// scroll-padding-top (reserved for the sticky Navbar, see
|
||||
// globals.css) by nudging the whole page upward — pointless here
|
||||
// since the modal is position:fixed and always fully in view
|
||||
// regardless of document scroll, but that nudge is exactly the
|
||||
// "page scrolls up a bit and the modal lands in a weird position"
|
||||
// jank on open.
|
||||
closeButtonRef.current?.focus({ preventScroll: true });
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -67,10 +107,10 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +153,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; 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"
|
||||
>
|
||||
<img alt="" src="/icon-close.png" className="size-full object-contain" />
|
||||
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
@@ -132,9 +172,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. */}
|
||||
<div className="w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<img alt="" src="/newsletter-icon.svg" className="w-full h-full" />
|
||||
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
|
||||
<div className="hidden md: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>
|
||||
|
||||
<p
|
||||
@@ -142,37 +182,70 @@ 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
|
||||
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<form className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full bg-bg-white border border-border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die Datenschutzerklärung.
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
{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>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
onBlur={(e) => handleEmailBlur(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
aria-invalid={Boolean(emailError)}
|
||||
className={`w-full bg-bg-white border rounded-sm px-6 py-3 text-body text-text-muted font-normal outline-none transition-colors ${
|
||||
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{status === "submitting" ? "Wird gesendet…" : "Jetzt anmelden"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die{" "}
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{status === "error" && (
|
||||
<p className="text-label text-red-600 font-normal">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,8 +261,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<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">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
<img alt="" src={f.icon} className="max-h-10 max-w-10 object-contain" />
|
||||
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
<Image alt="" src={f.icon} fill sizes="40px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0">
|
||||
<p
|
||||
|
||||
@@ -2,69 +2,119 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { getProductBySlug } from "../lib/payload";
|
||||
import { formatPrice } from "../lib/format";
|
||||
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../lib/format";
|
||||
import { effectiveTaxRate } from "../lib/cartTotals";
|
||||
|
||||
/**
|
||||
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
|
||||
* right after the Hero — that already has its own primary CTA, the
|
||||
* 7-Tage-Challenge, and a second strong purchase CTA competing with it
|
||||
* there would dilute focus). Werkzeuge already introduces ToDo-Karten as
|
||||
* a concept with an "Entdecken" link; this is the natural next step —
|
||||
* a concrete way to buy it, right where interest was just built, rather
|
||||
* than dropped at the very top before the page has earned any trust.
|
||||
* Not derived from a Figma frame (page-home never had this section) —
|
||||
* a deliberate, code-only addition, styled to match the /todo-cards
|
||||
* pricing panel it's a teaser for. Price/photo come from Payload (same
|
||||
* "todo-karten" product the shop/cart use) rather than being duplicated
|
||||
* here as a hardcoded literal, so they can never silently drift apart —
|
||||
* the marketing headline/copy below stays hand-written, since it's
|
||||
* deliberately punchier than the plain catalog description.
|
||||
* Product teaser for whichever product is marked `spotlight` in Payload
|
||||
* (defaults to none — the section just doesn't render until one is set),
|
||||
* placed after the Werkzeuge section (not right after the Hero — that
|
||||
* already has its own primary CTA, the 7-Tage-Challenge, and a second
|
||||
* strong purchase CTA competing with it there would dilute focus).
|
||||
* Werkzeuge already introduces the flagship tool as a concept with an
|
||||
* "Entdecken" link; this is the natural next step — a concrete way to buy
|
||||
* it, right where interest was just built, rather than dropped at the
|
||||
* very top before the page has earned any trust. Not derived from a
|
||||
* Figma frame (page-home never had this section) — a deliberate,
|
||||
* code-only addition, styled to match /todo-cards' pricing panel.
|
||||
* Headline/copy/photo are the product's own dedicated spotlight* fields
|
||||
* (deliberately separate from its plain catalog name/description/image —
|
||||
* see Products.ts), not duplicated here as hardcoded literals.
|
||||
*/
|
||||
export async function ProductSpotlight() {
|
||||
const product = await getProductBySlug("todo-karten");
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getSpotlightProduct(),
|
||||
getShippingSettings(),
|
||||
getDefaultTaxRatePercent(),
|
||||
getKleinunternehmer(),
|
||||
]);
|
||||
if (!product) return null;
|
||||
|
||||
const image = product.spotlightImage || product.image;
|
||||
const discount = discountPercent(product.price, product.compareAtPrice);
|
||||
const taxRate = effectiveTaxRate(product, defaultTaxRate);
|
||||
// Same "any vs. every" split as ProductGrid.tsx.
|
||||
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
|
||||
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
// 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">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt="ToDo-Karten Set"
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{fullyOutOfStock ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-bold text-h-small text-brand">Neu im Shop</p>
|
||||
<p className="font-bold text-h-small text-brand">{product.spotlightEyebrow || "Neu im Shop"}</p>
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten – Kleine Karten. Große Wirkung.
|
||||
{product.spotlightHeadline || product.name}
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen — analog, minimalistisch, für jeden Tag.
|
||||
{product.spotlightText || product.description}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
|
||||
{/* MwSt./Versand disclosure on its own line, not crammed into
|
||||
the price row itself — same reasoning as todo-cards'
|
||||
Pricing.tsx (identical text, same narrow-column risk). */}
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex gap-2 items-baseline">
|
||||
{discount !== null && (
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
a plain conditional line instead of a reserved-height slot. */}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
{/* items-start at sm: — without it, the default cross-axis
|
||||
stretch makes "Mehr erfahren" grow to match
|
||||
AddToCartButton's own height whenever that one gets taller
|
||||
(e.g. the low-stock hint line pushing its content down), so
|
||||
a plain text link visibly ends up "fatter" than the actual
|
||||
button next to it. */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-start gap-3 w-full sm:w-auto">
|
||||
{/* No className override — the section's bg is bg-bg-base now
|
||||
(matches Tools/Blog above/below), same as AddToCartButton's
|
||||
own default styling/ring-offset, so no override is needed
|
||||
here. */}
|
||||
<AddToCartButton label="In den Warenkorb" />
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Mehr erfahren
|
||||
</Link>
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Mehr erfahren
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// Native size of the hand-drawn underline texture (icon-merke-dir-underline.png,
|
||||
// exported from the Figma "label-underline" node) — used as the SSR/pre-hydration
|
||||
// fallback width before the label's actual rendered width is measured.
|
||||
const UNDERLINE_NATIVE_WIDTH = 136;
|
||||
|
||||
// Blockquote "Merke dir:" label + sparkle icon + underline (see RichText.tsx's
|
||||
// "quote" case). Split out as its own client component because the underline
|
||||
// needs to be stretched to match the label's actual rendered width — a fixed
|
||||
// width only ever matched the one label length it was eyeballed against,
|
||||
// leaving the underline too short (overflowing labels) or too long (sparse-
|
||||
// looking short labels) for anything else.
|
||||
export function QuoteLabel({ label }: { label: string }) {
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const [underlineWidth, setUnderlineWidth] = useState(UNDERLINE_NATIVE_WIDTH);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = labelRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setUnderlineWidth(el.offsetWidth);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* object-fill (not cover) — the box's height stays fixed, only the
|
||||
width tracks the label, so the texture stretches horizontally to
|
||||
match rather than getting cropped. */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={UNDERLINE_NATIVE_WIDTH}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] h-[1.4375rem] object-fill pointer-events-none"
|
||||
style={{ width: underlineWidth }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,14 @@
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
// Plain fade, no y-translate — fixed 2026-07-24. Used to animate opacity
|
||||
// 0→1 *and* y 28→0 together ("fade up"), which read as the whole section
|
||||
// visibly hopping/jumping into place on top of the fade — one motion cue
|
||||
// too many. The fade alone is already a clear enough "this just appeared"
|
||||
// signal without the extra jump.
|
||||
const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] } },
|
||||
};
|
||||
|
||||
type RevealProps = {
|
||||
@@ -16,7 +21,7 @@ type RevealProps = {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
/** Fades a section up into place once, the first time it scrolls into view. */
|
||||
/** Fades a section into view once, the first time it scrolls into view. */
|
||||
export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
@@ -25,7 +30,7 @@ export function Reveal({ children, className, style, delay = 0 }: RevealProps) {
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
variants={fadeUp}
|
||||
variants={fadeIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
{children}
|
||||
@@ -53,10 +58,10 @@ export function RevealGroup({ children, className }: { children: ReactNode; clas
|
||||
);
|
||||
}
|
||||
|
||||
/** Child item for use inside a RevealGroup — same fade-up motion, driven by the parent's stagger. */
|
||||
/** Child item for use inside a RevealGroup — same fade motion, driven by the parent's stagger. */
|
||||
export function RevealItem({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div className={className} variants={fadeUp}>
|
||||
<motion.div className={className} variants={fadeIn}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -115,8 +120,17 @@ export function PopIn({ children, className, delay = 0 }: RevealProps) {
|
||||
<motion.span
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
// animate, not whileInView — its only caller (Hero.tsx's brand dot)
|
||||
// sits above the fold, already visible on load, so there's no real
|
||||
// "scrolls into view" moment to gate on. whileInView's -80px viewport
|
||||
// margin also broke on some phones: the popIn variant's own "hidden"
|
||||
// state translates x:+140, and on a narrow mobile viewport that could
|
||||
// push the dot's pre-animation bounding box past the right edge —
|
||||
// IntersectionObserver then never reports it as visible, so
|
||||
// whileInView never fires and the dot stays stuck off-screen
|
||||
// (reported 2026-07-24: dot invisible on a real phone). Firing on
|
||||
// mount sidesteps that geometry entirely.
|
||||
animate="show"
|
||||
variants={popIn}
|
||||
transition={{ delay }}
|
||||
>
|
||||
|
||||
+194
-69
@@ -1,30 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
|
||||
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
|
||||
// Deliberately small and dependency-free (matches the project's existing
|
||||
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
|
||||
// side) rather than pulling in @payloadcms/richtext-lexical's full React
|
||||
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
|
||||
// the node types real content actually uses; add more only when a page
|
||||
// genuinely needs them.
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
// once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/
|
||||
// Video/Zitat, see payload/src/collections/Posts.ts), which the old
|
||||
// hand-rolled switch had no case for at all. extractHeadings()/headingId()
|
||||
// below are kept as an independent, minimal walk over the raw JSON (same
|
||||
// as before) — they only ever need to find h2 headings for SectionTOC and
|
||||
// never touch Blocks, no reason to route that through the new renderer too.
|
||||
|
||||
type LexicalNode = {
|
||||
type: string;
|
||||
children?: LexicalNode[];
|
||||
text?: string;
|
||||
format?: number;
|
||||
tag?: string;
|
||||
listType?: "bullet" | "number";
|
||||
fields?: { url?: string };
|
||||
};
|
||||
|
||||
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
|
||||
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
|
||||
const BOLD = 1;
|
||||
const ITALIC = 2;
|
||||
const UNDERLINE = 8;
|
||||
|
||||
function plainText(node: LexicalNode): string {
|
||||
if (node.type === "text") return node.text ?? "";
|
||||
return (node.children ?? []).map(plainText).join("");
|
||||
@@ -34,7 +28,13 @@ function plainText(node: LexicalNode): string {
|
||||
// "section-1" id from the leading number — immune to copy edits changing
|
||||
// the heading text later, unlike a text-derived slug. Anything else
|
||||
// (headings with no leading number) falls back to a plain slugify.
|
||||
function headingId(text: string): string {
|
||||
// Exported — the Impressum page renders some of its own headings outside
|
||||
// this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/
|
||||
// "Verantwortlich für den Inhalt" sections come straight from
|
||||
// company-settings, not the richText field, see app/impressum/page.tsx)
|
||||
// and needs the exact same id-assignment logic so its SectionTOC entries
|
||||
// actually match the ids those headings render with.
|
||||
export function headingId(text: string): string {
|
||||
const numbered = text.match(/^(\d+)\./);
|
||||
if (numbered) return `section-${numbered[1]}`;
|
||||
return text
|
||||
@@ -63,84 +63,209 @@ export function extractHeadings(content: unknown): TOCSection[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
|
||||
// Payload upload relations resolve to the full media doc when fetched at
|
||||
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
|
||||
// already uses depth >= 2), or fall back to a bare id if not — only
|
||||
// render when actually populated.
|
||||
type MediaRef = { url?: string | null } | number | null | undefined;
|
||||
function mediaUrl(ref: MediaRef): string | null {
|
||||
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string): ReactNode {
|
||||
switch (node.type) {
|
||||
case "linebreak":
|
||||
return <br key={key} />;
|
||||
case "text": {
|
||||
let el: ReactNode = node.text;
|
||||
const format = node.format ?? 0;
|
||||
if (format & BOLD) el = <strong key={key}>{el}</strong>;
|
||||
if (format & ITALIC) el = <em key={key}>{el}</em>;
|
||||
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={node.fields?.url ?? "#"}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node);
|
||||
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
|
||||
// link edge cases) — good enough for a "paste a link" editor field; a
|
||||
// URL that doesn't match either pattern just doesn't render rather than
|
||||
// guessing wrong.
|
||||
function toEmbedUrl(url: string): string | null {
|
||||
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
|
||||
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
|
||||
const vimeo = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-6">
|
||||
{/* Label/icon/underline are optional — if empty, only the divider +
|
||||
quote text render. The quote itself is never optional, just this
|
||||
framing around it. */}
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A factory, not a module-level constant — needs to close over each
|
||||
// call's own `quoteLabel` (the native "quote" converter reads it). Server
|
||||
// Components can render multiple posts concurrently in the same process,
|
||||
// so a shared module-level variable set right before rendering would be
|
||||
// a real race condition, not just a style choice.
|
||||
function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
return ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => (
|
||||
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
|
||||
),
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node as unknown as LexicalNode);
|
||||
return (
|
||||
<Tag
|
||||
key={key}
|
||||
id={Tag === "h2" ? headingId(text) : undefined}
|
||||
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
},
|
||||
list: ({ node, nodesToJSX }) => {
|
||||
const ListTag = node.listType === "number" ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag
|
||||
key={key}
|
||||
className={
|
||||
"flex flex-col gap-2 text-body text-text-body " +
|
||||
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
||||
}
|
||||
>
|
||||
{renderChildren(node.children, key)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key)}
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key);
|
||||
}
|
||||
},
|
||||
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
|
||||
link: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical's native blockquote feature — used by every post written
|
||||
// before Blocks existed. Kept working exactly as before (own comment on
|
||||
// Posts.ts's `content` field editor config on why this stays enabled
|
||||
// alongside the new QuoteBlock) rather than migrating old content.
|
||||
quote: ({ node, nodesToJSX }) => (
|
||||
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
|
||||
),
|
||||
blocks: {
|
||||
image: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
imageGallery: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageGalleryBlockFields;
|
||||
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{images.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image
|
||||
alt=""
|
||||
src={mediaUrl(row.image)!}
|
||||
fill
|
||||
sizes="(min-width: 768px) 24rem, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={row.caption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as VideoEmbedBlockFields;
|
||||
const embedUrl = toEmbedUrl(fields.url);
|
||||
if (!embedUrl) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={fields.caption ?? "Video"}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
|
||||
// every native blockquote in the post) — new quotes going forward use
|
||||
// this instead of the native blockquote feature.
|
||||
quote: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as QuoteBlockFields;
|
||||
const lines = fields.text.split("\n");
|
||||
return (
|
||||
<Quote label={fields.label ?? undefined}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function RichText({ content }: { content: unknown }) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
export function RichText({
|
||||
content,
|
||||
quoteLabel = "Merke dir:",
|
||||
}: {
|
||||
content: unknown;
|
||||
/** Label for any native blockquote's callout — defaults to "Merke dir:"
|
||||
* for callers that don't pass one (legal pages never use blockquotes,
|
||||
* so this only actually matters for blog posts). Pass "" to hide the
|
||||
* label/icon/underline for every native blockquote here. New content
|
||||
* should use the Zitat block instead, which carries its own label. */
|
||||
quoteLabel?: string;
|
||||
}) {
|
||||
const root = (content as { root?: { children?: unknown[] } })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root")}
|
||||
<LexicalRichText
|
||||
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
|
||||
converters={buildConverters(quoteLabel)}
|
||||
disableContainer
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,24 +10,12 @@ export type TOCSection = { id: string; title: string };
|
||||
// active state away from what was actually clicked.
|
||||
const CLICK_OVERRIDE_MS = 1000;
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — Impressum/Datenschutz put an extra card below this
|
||||
// in the same sidebar column, and if only this <nav> were sticky, the
|
||||
// card (a plain-flow sibling) would scroll away independently instead of
|
||||
// travelling with it. The caller wraps whatever the sidebar column
|
||||
// contains (this alone, or this + more) in `lg:sticky lg:top-32
|
||||
// lg:self-start` so the whole column moves as one unit.
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
// Shared between SectionTOC (desktop sidebar nav) and MobileSectionTOC
|
||||
// (below lg: collapsible accordion, added 2026-07-24) — both need the same
|
||||
// scroll-spy "active" state and click-override handling, just render it
|
||||
// completely differently, so the logic lives here once instead of being
|
||||
// duplicated per component.
|
||||
function useActiveSection(sections: TOCSection[]) {
|
||||
const [active, setActive] = useState<string>(sections[0]?.id ?? "");
|
||||
// Not state — read inside the IntersectionObserver callback without
|
||||
// needing to re-subscribe it on every click, and cleared by its own
|
||||
@@ -67,6 +55,28 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
}, CLICK_OVERRIDE_MS);
|
||||
}
|
||||
|
||||
return { active, handleClick };
|
||||
}
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
//
|
||||
// Generic over `sections` — originally written just for /versand
|
||||
// (VersandTOC), generalized once /datenschutz needed the identical
|
||||
// scroll-spy sidebar but driven by CMS-authored headings instead of a
|
||||
// hardcoded array. Any future long legal/content page reuses this too.
|
||||
//
|
||||
// Not sticky itself — every caller wraps this in its own `hidden lg:flex
|
||||
// ... lg:sticky lg:top-32 lg:self-start` div (Impressum/Datenschutz also
|
||||
// stack a second "Nachhaltigkeit" card below this in that same wrapper, so
|
||||
// the sticky behavior has to live on the wrapper for the two to travel
|
||||
// together as one unit — putting it on this <nav> instead would leave
|
||||
// that card behind as a plain-flow sibling scrolling past a now-fixed nav).
|
||||
export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -92,3 +102,44 @@ export function SectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Below lg: only — a collapsible accordion instead of the sidebar nav
|
||||
// (which is `hidden` entirely below lg:, see SectionTOC's own comment on
|
||||
// why a real 2-column split doesn't fit there). Added 2026-07-24: these
|
||||
// legal pages had no on-page navigation aid at all on Mobile/Tablet, which
|
||||
// is exactly where scanning a long legal document by scrolling is hardest.
|
||||
// Native <details>/<summary> — no extra open/close state needed, and it
|
||||
// stays open after a click so jumping between sections doesn't require
|
||||
// reopening it each time. Render this as its own element in the page
|
||||
// (typically right after the heading, before the two-column content row),
|
||||
// not nested inside a parent that's itself `hidden lg:...` — that would
|
||||
// hide this too regardless of its own lg:hidden class.
|
||||
export function MobileSectionTOC({ sections }: { sections: TOCSection[] }) {
|
||||
const { active, handleClick } = useActiveSection(sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details className="lg:hidden w-full bg-bg-base border border-border rounded-md p-4 open:pb-2">
|
||||
<summary className="text-label font-semibold text-text-muted uppercase tracking-wide cursor-pointer select-none">
|
||||
Inhaltsübersicht
|
||||
</summary>
|
||||
<div className="flex flex-col gap-1 mt-3">
|
||||
{sections.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
onClick={() => handleClick(id)}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Shared "how it works" step connector — used by Challenge's, /todo-cards's,
|
||||
// and /newsletter's ("Impulse & Tipps") step sections. Used to be
|
||||
// /icon-arrow-connector.svg (a thin gray line+chevron) loaded via next/image;
|
||||
// replaced 2026-07-24 for two reasons that both needed an inline SVG to fix:
|
||||
// 1. It read as a faint gray line, not a real arrow, even after the
|
||||
// object-contain aspect-ratio fix — too thin/subtle at these sizes.
|
||||
// 2. Its color lives in a `var(--stroke-0, #C9C9C9)` CSS custom property
|
||||
// that's scoped to the SVG file's own document when loaded via <img
|
||||
// src>/next/image — un-recolorable from the host page's CSS. Inline SVG
|
||||
// sidesteps that entirely. Stroke color: tried brand orange, then
|
||||
// near-black, settled on the same light gray (#C9C9C9) the original
|
||||
// asset's own fallback used, per feedback the same day — just bolder
|
||||
// (strokeWidth 2.5 vs. the original's thin 1.5) and better-shaped.
|
||||
export function StepArrow({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg width="40" height="16" viewBox="0 0 40 16" fill="none" aria-hidden="true" className={className}>
|
||||
<path
|
||||
d="M1 8H33M26 14.5L34.5 8L26 1.5"
|
||||
stroke="#C9C9C9"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import type { Testimonial } from "../lib/payload";
|
||||
|
||||
// Shared by /todo-cards, /newsletter, and /challenge — all three were
|
||||
// already pixel-identical (bg-muted filled card, no border, decorative
|
||||
// quote-mark, quote on top with flex-1 pushing the avatar/name row to the
|
||||
// bottom, hover-lift + avatar-scale), kept in sync deliberately as one
|
||||
// visual pattern across pages rather than each page's own (differing)
|
||||
// Figma spec for this one section. max-w-[1600px] matches Challenge's
|
||||
// testimonial container cap, the widest of the three original values —
|
||||
// a deliberate compromise so all three read consistently across viewports
|
||||
// instead of one looking narrower than the others past ~1440px.
|
||||
export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[] }) {
|
||||
if (testimonials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md: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"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden transition-transform duration-300 group-hover:scale-110">
|
||||
<Image src={t.avatar} alt={t.name} fill sizes="40px" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-body-sm text-text-primary">{t.name}</p>
|
||||
<p className="text-body-sm text-text-muted">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+34
-36
@@ -1,28 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
|
||||
import { getWerkzeugeCards } from "../lib/payload";
|
||||
|
||||
const tools = [
|
||||
{
|
||||
icon: { src: "/icon-rocket.svg", w: "3.375rem", h: "4.122rem", transform: "-scale-y-100" },
|
||||
title: "Mini-Challenge",
|
||||
description: "In 7 Tagen zu mehr Klarheit. Kleine Gewohnheiten, die Großes bewirken.",
|
||||
cta: { label: "Starten", href: "/challenge" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-todo.svg", w: "3rem", h: "3.879rem", transform: "-scale-y-100" },
|
||||
title: "ToDo-Karten",
|
||||
description: "Das Werkzeug für Fokus im Alltag. bringe Struktur in deine Aufgaben und gewinne Zeit zurück.",
|
||||
cta: { label: "Entdecken", href: "/todo-cards" },
|
||||
},
|
||||
{
|
||||
icon: { src: "/icon-newsletter.svg", w: "3rem", h: "2.579rem", transform: "-rotate-4 -scale-y-100" },
|
||||
title: "Impulse & Tipps",
|
||||
description: "Wöchentliche Impulse mit konkreten Ideen und erprobten Tipps für weniger Reibung und mehr Leichtigkeit.",
|
||||
cta: { label: "Anmelden", href: "/newsletter" },
|
||||
},
|
||||
];
|
||||
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
|
||||
// uniform box here (object-contain) rather than the previous hardcoded
|
||||
// per-card hand-tuned width/height/rotation — that only made sense for a
|
||||
// fixed, known set of 3 SVGs authored upside-down for a specific
|
||||
// hand-drawn look, which doesn't generalize to a real CMS field. The 3
|
||||
// original icons were re-exported pre-flipped/rotated as PNGs so they
|
||||
// still display correctly with plain object-contain.
|
||||
export async function Tools() {
|
||||
const tools = await getWerkzeugeCards();
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
export function Tools() {
|
||||
return (
|
||||
<div id="werkzeuge" className="flex flex-col gap-12 items-start pb-16 pt-8 w-full bg-bg-base">
|
||||
|
||||
@@ -44,20 +35,20 @@ export function Tools() {
|
||||
<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.map((tool) => (
|
||||
<RevealItem
|
||||
key={tool.title}
|
||||
key={tool.id}
|
||||
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
<div className={tool.icon.transform}>
|
||||
<div className="relative" style={{ width: tool.icon.w, height: tool.icon.h }}>
|
||||
<img
|
||||
alt=""
|
||||
src={tool.icon.src}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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,
|
||||
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. */}
|
||||
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
|
||||
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Card content — self-stretch + h-full + justify-between so
|
||||
@@ -84,11 +75,18 @@ export 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). */}
|
||||
<Link
|
||||
href={tool.cta.href}
|
||||
className="font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
href={tool.ctaHref}
|
||||
className="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
|
||||
>
|
||||
→ {tool.cta.label}
|
||||
<span aria-hidden>→</span>
|
||||
<span>{tool.ctaLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</RevealItem>
|
||||
|
||||
+16
-20
@@ -1,31 +1,27 @@
|
||||
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
|
||||
import { formatPrice } from "../lib/format";
|
||||
import Image from "next/image";
|
||||
import { getTrustBadges } from "../lib/payload";
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: "/icon-trust-shipping.png",
|
||||
title: "Schneller Versand",
|
||||
desc: `In ${TOTAL_DAYS_DE.min}–${TOTAL_DAYS_DE.max} Werktagen bei dir.`,
|
||||
},
|
||||
{
|
||||
icon: "/icon-trust-free-shipping.png",
|
||||
title: "Versandkostenfrei",
|
||||
desc: `Ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} Bestellwert innerhalb DE.`,
|
||||
},
|
||||
{ icon: "/icon-trust-heart.png", title: "Mit Liebe verpackt", desc: "Für mehr Freude beim Auspacken." },
|
||||
];
|
||||
// Content now lives in Payload (TrustBadges collection) instead of being
|
||||
// hardcoded here, so copy (e.g. the shipping timeframe/threshold numbers)
|
||||
// can be updated without a code deploy. If the fetch fails or nothing is
|
||||
// seeded yet, the row just doesn't render rather than showing stale
|
||||
// hardcoded fallback text that could drift from the real numbers.
|
||||
export async function TrustRow() {
|
||||
const items = await getTrustBadges();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
export function TrustRow() {
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
<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.title} className="flex items-center gap-6 md:gap-12">
|
||||
<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">
|
||||
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
|
||||
<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.desc}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { formatPrice } from "../lib/format";
|
||||
import type { TaxBreakdownGroup } from "@einfach-produktiv/invoicing";
|
||||
|
||||
// The actual amount of VAT included in a total — not just a disclosure
|
||||
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
|
||||
// "which %" shown next to each line item elsewhere). One line per rate
|
||||
// when a cart/order spans more than one; a single line otherwise.
|
||||
//
|
||||
// Same row shape as the Gesamtsumme total line right above this
|
||||
// (`flex w-full` + a `flex-1` spacer): a label on the left, flush with
|
||||
// "Gesamtsumme", and the rate/amount pushed flush right so they land
|
||||
// directly under the total's own € amount — not tucked in right next to
|
||||
// the label. The rate itself gets a fixed-width right-aligned column
|
||||
// (`w-8`, `tabular-nums`) so a single-digit rate ("7%") still lines up
|
||||
// under a two-digit one ("19%") across rows instead of shifting the
|
||||
// amount that follows it. Only the first row carries the "enthält
|
||||
// MwSt.:" label; further rates repeat just the rate/amount pair.
|
||||
export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) {
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
{groups.map((g, i) => (
|
||||
<div key={g.rate} className="flex items-baseline w-full">
|
||||
<span className="text-label text-text-muted">
|
||||
{groups.length === 1 ? `enthält ${g.rate}% MwSt.` : i === 0 ? "enthält MwSt.:" : ""}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{groups.length > 1 && (
|
||||
<span className="w-8 shrink-0 text-right text-label text-text-muted tabular-nums">{g.rate}%</span>
|
||||
)}
|
||||
<span className="ml-1.5 text-label text-text-muted tabular-nums">{formatPrice(g.tax)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { VersandSections } from "../versand/components/VersandSections";
|
||||
import type { ShippingSettings } from "../lib/payload";
|
||||
|
||||
/**
|
||||
* Quick-reference version of /versand, opened from the cart's order
|
||||
* summary "Versand" info link — a full page navigation would pull you out
|
||||
* of checkout, which is exactly what the link is there to avoid. Reuses
|
||||
* VersandSections so the two never carry different numbers/copy.
|
||||
* `shipping` is threaded down from CartContent/CheckoutContent's own page
|
||||
* (a Server Component), not fetched here — this is a Client Component.
|
||||
*/
|
||||
export function VersandModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
export function VersandModal({
|
||||
open,
|
||||
onClose,
|
||||
shipping,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shipping: ShippingSettings;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -26,7 +37,7 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
closeButtonRef.current?.focus({ preventScroll: true });
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -43,10 +54,10 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +109,7 @@ export function VersandModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 pb-8">
|
||||
<VersandSections />
|
||||
<VersandSections shipping={shipping} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,7 +16,8 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const page = await getLegalPage("datenschutz");
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const page = await getLegalPage("datenschutz", { draft: isPreview });
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
|
||||
return (
|
||||
@@ -34,6 +38,13 @@ export default async function DatenschutzPage() {
|
||||
<p className="text-body text-text-muted">Stand: Juli 2026</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
@@ -43,7 +54,7 @@ export default async function DatenschutzPage() {
|
||||
callout doesn't belong in the generic LegalPages richText
|
||||
field shared across all 4 legal page types. */}
|
||||
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
|
||||
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
|
||||
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -59,7 +70,7 @@ export default async function DatenschutzPage() {
|
||||
|
||||
<div className="w-full lg:flex-1 min-w-0">
|
||||
{page ? (
|
||||
<RichText content={page.content} />
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useLivePreview } from "@payloadcms/live-preview-react";
|
||||
import {
|
||||
renderOrderConfirmationHtml,
|
||||
renderPasswordResetHtml,
|
||||
renderOrderStatusHtml,
|
||||
ORDER_STATUS_EMAIL_ICON,
|
||||
SAMPLE_ORDER,
|
||||
type EmailTemplateContent,
|
||||
} from "../../../lib/emailTemplates";
|
||||
import type { EmailTemplateType } from "../../../lib/payload";
|
||||
|
||||
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Same useLivePreview() mechanism as LivePostContent.tsx (blog) — connects
|
||||
// to the Payload admin's iframe via postMessage and updates `data` as the
|
||||
// admin edits fields, no save required. Renders through the exact same
|
||||
// renderOrderConfirmationHtml/renderPasswordResetHtml functions that build
|
||||
// the real sent email (app/lib/orderEmail.ts, Customers.ts's forgotPassword
|
||||
// hook on the Payload side uses its own simple template instead — see that
|
||||
// hook's own comment on why the two aren't pixel-identical for
|
||||
// password-reset specifically) — order-confirmation previews exactly.
|
||||
export function LiveEmailPreviewClient({
|
||||
type,
|
||||
initialTemplate,
|
||||
}: {
|
||||
type: EmailTemplateType;
|
||||
initialTemplate: EmailTemplateContent;
|
||||
}) {
|
||||
const { data } = useLivePreview<EmailTemplateContent>({
|
||||
initialData: initialTemplate,
|
||||
serverURL: PAYLOAD_URL,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
// 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)
|
||||
: 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,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { draftMode } from "next/headers";
|
||||
import { getEmailTemplate, type EmailTemplateType } from "../../lib/payload";
|
||||
import { LiveEmailPreviewClient } from "./components/LiveEmailPreviewClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "E-Mail-Vorschau",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const VALID_TYPES: EmailTemplateType[] = [
|
||||
"order-confirmation",
|
||||
"password-reset",
|
||||
"order-shipped",
|
||||
"order-cancelled",
|
||||
"order-return-requested",
|
||||
"order-returned",
|
||||
];
|
||||
|
||||
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
|
||||
"order-shipped": "Deine Bestellung ist unterwegs",
|
||||
"order-cancelled": "Deine Bestellung wurde storniert",
|
||||
"order-return-requested": "Deine Rücksendung wurde angefragt",
|
||||
"order-returned": "Deine Retoure wurde bearbeitet",
|
||||
};
|
||||
|
||||
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
|
||||
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
|
||||
// not a page a real visitor would ever land on. Always reads with
|
||||
// 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.
|
||||
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
|
||||
const { type } = await params;
|
||||
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
|
||||
const emailType = type as EmailTemplateType;
|
||||
|
||||
await draftMode();
|
||||
const fallbackHeading =
|
||||
emailType === "order-confirmation"
|
||||
? "Vielen Dank für deine Bestellung!"
|
||||
: emailType === "password-reset"
|
||||
? "Passwort zurücksetzen"
|
||||
: STATUS_TYPE_FALLBACK_HEADING[emailType];
|
||||
const template = (await getEmailTemplate(emailType, { draft: true })) ?? {
|
||||
type: emailType,
|
||||
subject: "",
|
||||
heading: fallbackHeading,
|
||||
bodyText: "Noch kein Inhalt gespeichert — im Payload-Admin unter E-Mail-Vorlagen anlegen.",
|
||||
footerText: null,
|
||||
};
|
||||
|
||||
return <LiveEmailPreviewClient type={emailType} initialTemplate={template} />;
|
||||
}
|
||||
@@ -38,6 +38,10 @@
|
||||
--color-toc-active-border: #f6a701;
|
||||
--color-success: #2f8f4e;
|
||||
--color-success-subtle: #e8f4ea;
|
||||
/* Low-stock warning — distinct from --color-brand's golden yellow (used
|
||||
for the discount badge) so the two pills never read as the same thing. */
|
||||
--color-warning: #c2410c;
|
||||
--color-warning-subtle: #fdf1e9;
|
||||
|
||||
/* Radius */
|
||||
--radius-xs: 0.25rem;
|
||||
@@ -137,10 +141,45 @@
|
||||
--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);
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 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
|
||||
--text-h2 itself. */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
--divider-word-size: 1.125rem;
|
||||
--divider-arrow-w: 1.125rem;
|
||||
--divider-arrow-h: 0.3125rem;
|
||||
--divider-sparkle-w: 0.875rem;
|
||||
--divider-sparkle-h: 1.15rem;
|
||||
--divider-sparkle-inner-w: 0.8rem;
|
||||
--divider-sparkle-inner-h: 1.075rem;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 6.25rem; /* navbar height */
|
||||
/* Reserves the scrollbar's width permanently, so any modal's
|
||||
overflow:hidden scroll lock (NewsletterModal, VersandModal) never
|
||||
removes/re-adds the scrollbar itself — without this, losing the
|
||||
scrollbar on open widens the viewport by its width and every fixed/sticky
|
||||
element (Navbar included) visibly snaps sideways for one frame. */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { headingId } from "../../components/RichText";
|
||||
import type { CompanySettings } from "../../lib/payload";
|
||||
import type { TOCSection } from "../../components/SectionTOC";
|
||||
|
||||
// Renders "Angaben zum Anbieter"/"Umsatzsteuer"/(conditionally)
|
||||
// "Handelsregister"/"Geschäftsführung"/"Verantwortlich für den Inhalt"
|
||||
// straight from company-settings, matching RichText.tsx's own heading/
|
||||
// paragraph classes so it reads as one continuous page with the CMS
|
||||
// content below it, not a bolted-on block. This used to be hand-typed
|
||||
// prose baked into the Impressum's richText (seed-legal-pages.ts on the
|
||||
// Payload side) — duplicated, and silently out of date the moment an
|
||||
// admin changed company-settings without also remembering to re-edit the
|
||||
// Impressum text by hand. Single-sourced here instead, same "structural/
|
||||
// brand elements in code, only pull the actual numbers/copy that need
|
||||
// single-sourcing from data" pattern this page's own Nachhaltigkeit card
|
||||
// already uses (see page.tsx's comment on that).
|
||||
//
|
||||
// Also closes a real compliance gap the old hand-typed text had: it never
|
||||
// showed Handelsregister/Geschäftsführung at all, even though
|
||||
// company-settings already models both (§37a HGB/§35a GmbHG) — those
|
||||
// fields just weren't wired into the Impressum. A sole proprietorship
|
||||
// (this shop's current legalForm) has neither, so neither section shows
|
||||
// today, but the moment that changes in company-settings, the Impressum
|
||||
// picks it up automatically instead of needing a second manual edit.
|
||||
//
|
||||
// A "Gesellschafter"/Komplementäre section for OHG/KG was attempted
|
||||
// 2026-07-23 but reverted the same day — the legal basis turned out
|
||||
// genuinely unclear on research (§125a HGB's Geschäftsbriefe-naming duty
|
||||
// only applies to the narrow case where *no* partner is a natural person,
|
||||
// not the general OHG/KG case; whether §5 DDG's Impressum-specific
|
||||
// "vertretungsberechtigte Person" requirement independently mandates it
|
||||
// wasn't resolved with confidence). Deliberately not modeled until that's
|
||||
// actually clarified — don't rebuild this without re-verifying the legal
|
||||
// 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"];
|
||||
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
|
||||
if (seller.managingDirector) sections.push("Geschäftsführung");
|
||||
sections.push("Verantwortlich für den Inhalt");
|
||||
return sections.map((title) => ({ id: headingId(title), title }));
|
||||
}
|
||||
|
||||
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: ReactNode }) {
|
||||
return <p className="text-body text-text-body">{children}</p>;
|
||||
}
|
||||
|
||||
export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Heading>Angaben zum Anbieter</Heading>
|
||||
{/* gap-1, not the outer container's own gap-4 — these 5 lines are one
|
||||
continuous address block, not 5 separate paragraphs; the large
|
||||
inter-section gap only belongs between a heading's own block and
|
||||
the next, not between lines that visually belong together
|
||||
(fixed 2026-07-24, same fix applied to every block below). */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.sellerName}</P>
|
||||
<P>{seller.sellerStreet}</P>
|
||||
<P>
|
||||
{seller.sellerZip} {seller.sellerCity}
|
||||
</P>
|
||||
<P>{seller.sellerCountry}</P>
|
||||
<P>E-Mail: {seller.sellerEmail}</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.registerCourt && seller.registerNumber && (
|
||||
<>
|
||||
<Heading>Handelsregister</Heading>
|
||||
<div className="flex flex-col gap-1">
|
||||
<P>{seller.registerCourt}</P>
|
||||
<P>{seller.registerNumber}</P>
|
||||
{/* Optional/voluntary, not a Pflichtangabe — see
|
||||
CompanySettings.ts's own comment on shareCapital. Only shows
|
||||
if an admin deliberately filled it in. */}
|
||||
{seller.shareCapital ? <P>Stammkapital: {seller.shareCapital.toLocaleString("de-DE")} €</P> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{seller.managingDirector && (
|
||||
<>
|
||||
<Heading>Geschäftsführung</Heading>
|
||||
<P>{seller.managingDirector}</P>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Heading>Verantwortlich für den Inhalt</Heading>
|
||||
{/* §18 Abs. 2 MStV wants a natural person — managingDirector first
|
||||
(Kapitalgesellschaften), falling back to sellerName itself (sole
|
||||
proprietorship/e.K., already a natural person's own name). No
|
||||
OHG/KG general-partner fallback here — see this file's top
|
||||
comment on why that field doesn't exist yet. */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-9
@@ -1,20 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { draftMode } from "next/headers";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { RichText, extractHeadings } from "../components/RichText";
|
||||
import { SectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage } from "../lib/payload";
|
||||
import { LiveRichText } from "../components/LiveRichText";
|
||||
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
|
||||
import { getLegalPage, getCompanySettings } from "../lib/payload";
|
||||
import { AnbieterAngaben, anbieterAngabenHeadings } from "./components/AnbieterAngaben";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Impressum",
|
||||
description: "Angaben gemäß § 5 TMG für einfach produktiv.",
|
||||
description: "Angaben gemäß § 5 DDG für einfach produktiv.",
|
||||
alternates: { canonical: "/impressum" },
|
||||
};
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
const page = await getLegalPage("impressum");
|
||||
const headings = page ? extractHeadings(page.content) : [];
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const [page, seller] = await Promise.all([getLegalPage("impressum", { draft: isPreview }), getCompanySettings()]);
|
||||
// Anbieter-Angaben headings first — that block renders above the CMS
|
||||
// content below, so its TOC entries need to lead too, or the sidebar
|
||||
// would list sections in a different order than they actually appear.
|
||||
const headings = [...anbieterAngabenHeadings(seller), ...(page ? extractHeadings(page.content) : [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,9 +39,16 @@ export default async function ImpressumPage() {
|
||||
>
|
||||
Impressum
|
||||
</p>
|
||||
<p className="text-body text-text-muted">Angaben gemäß § 5 TMG</p>
|
||||
<p className="text-body text-text-muted">Angaben gemäß § 5 DDG</p>
|
||||
</Reveal>
|
||||
|
||||
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
|
||||
comment. Outside the sidebar's `hidden lg:flex` wrapper below
|
||||
(that wrapper's `hidden` would hide this too otherwise). */}
|
||||
<div className="lg:hidden px-[var(--layout-padding-x)] pb-4 w-full">
|
||||
<MobileSectionTOC sections={headings} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="hidden lg:flex flex-col gap-6 w-[22.5rem] shrink-0 lg:sticky lg:top-32 lg:self-start">
|
||||
<SectionTOC sections={headings} />
|
||||
@@ -47,7 +62,7 @@ export default async function ImpressumPage() {
|
||||
the actual numbers/copy that need single-sourcing from
|
||||
data. */}
|
||||
<div className="bg-bg-muted flex flex-col gap-3 items-start p-6 rounded-md w-full">
|
||||
<img alt="" src="/icon-trust-leaf.png" className="size-7 object-contain" />
|
||||
<Image alt="" src="/icon-trust-leaf.png" width={28} height={28} className="size-7 object-contain" />
|
||||
<p
|
||||
className="font-semibold text-body text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -62,9 +77,15 @@ export default async function ImpressumPage() {
|
||||
</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">
|
||||
{/* Seller identity (name/address/USt-ID/Handelsregister/
|
||||
Geschäftsführung) comes straight from company-settings, not
|
||||
the CMS richText below — single-sourced so it can never
|
||||
drift out of sync with the same data the invoice PDFs and
|
||||
every email footer already use. See AnbieterAngaben.tsx. */}
|
||||
{seller && <AnbieterAngaben seller={seller} />}
|
||||
{page ? (
|
||||
<RichText content={page.content} />
|
||||
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
|
||||
) : (
|
||||
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const;
|
||||
|
||||
export type ReturnableItem = { product: number; productName: string; quantity: number };
|
||||
|
||||
export function OrderActionButton({
|
||||
orderNumber,
|
||||
action,
|
||||
items,
|
||||
}: {
|
||||
orderNumber: string;
|
||||
action: "cancel" | "request-return";
|
||||
items: ReturnableItem[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [returnReason, setReturnReason] = useState("");
|
||||
// Keyed by product id, string so the input can hold an empty/partial
|
||||
// value while typing — parsed to a number only on submit.
|
||||
const [quantities, setQuantities] = useState<Record<number, string>>({});
|
||||
|
||||
async function submit(body: Record<string, unknown>) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Aktion war nicht möglich.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Aktion war gerade nicht möglich.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!window.confirm("Bestellung wirklich stornieren?")) return;
|
||||
await submit({ action: "cancel" });
|
||||
}
|
||||
|
||||
async function handleReturnSubmit() {
|
||||
const trimmedReason = returnReason.trim();
|
||||
if (!trimmedReason) {
|
||||
setError("Bitte kurz einen Grund angeben.");
|
||||
return;
|
||||
}
|
||||
const returnItems = Object.entries(quantities)
|
||||
.map(([product, value]) => ({ product: Number(product), returnQuantity: Number(value) || 0 }))
|
||||
.filter((line) => line.returnQuantity > 0);
|
||||
if (returnItems.length === 0) {
|
||||
setError("Bitte mindestens einen Artikel mit Menge auswählen.");
|
||||
return;
|
||||
}
|
||||
await submit({ action: "request-return", returnReason: trimmedReason, returnItems });
|
||||
}
|
||||
|
||||
if (action === "cancel") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : LABEL.cancel}
|
||||
</button>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// request-return: a short inline form, not window.prompt() — needs a
|
||||
// per-item quantity (partial returns are supported, see the Payload
|
||||
// README's "How a Stornorechnung/Gutschrift relates..." section), which
|
||||
// a single-line browser prompt can't reasonably capture.
|
||||
if (!formOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors"
|
||||
>
|
||||
{LABEL["request-return"]}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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">Welche Artikel möchtest du zurücksenden?</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product} className="flex items-center gap-4">
|
||||
<span className="flex-1 text-body-sm text-text-primary">{item.productName}</span>
|
||||
<label className="flex items-center gap-2 text-label text-text-muted">
|
||||
Menge
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.quantity}
|
||||
value={quantities[item.product] ?? ""}
|
||||
onChange={(e) => setQuantities((prev) => ({ ...prev, [item.product]: e.target.value }))}
|
||||
placeholder="0"
|
||||
className="w-16 px-2 py-1 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-label text-text-muted">/ {item.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-label text-text-muted">Grund der Rücksendung</span>
|
||||
<textarea
|
||||
value={returnReason}
|
||||
onChange={(e) => setReturnReason(e.target.value)}
|
||||
rows={2}
|
||||
className="px-3 py-2 border border-border rounded-sm text-body-sm text-text-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReturnSubmit}
|
||||
disabled={loading}
|
||||
className={`px-5 py-3 rounded-sm bg-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loading ? "…" : "Rücksendung anfragen"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setFormOpen(false)} className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
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 { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
|
||||
// Dynamic (was a static "Bestelldetails" title despite this being a
|
||||
// per-order route) — just formats the already-known order number into
|
||||
// the title, no extra fetch needed for a noindex account page.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ orderNumber: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { orderNumber } = await params;
|
||||
return {
|
||||
title: `Bestellung ${decodeURIComponent(orderNumber)}`,
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KontoBestellungDetailPage({ params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const { orderNumber } = await params;
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) notFound();
|
||||
|
||||
const address =
|
||||
order.deliveryMethod === "address"
|
||||
? order.street
|
||||
: `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
|
||||
const shippingAddress =
|
||||
order.shippingDeliveryMethod === "packstation"
|
||||
? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}`
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
|
||||
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-[48rem] mx-auto">
|
||||
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
|
||||
← Zurück zur Bestellhistorie
|
||||
</Link>
|
||||
|
||||
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
{order.orderNumber}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{(() => {
|
||||
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
|
||||
return trackingUrl ? (
|
||||
<a href={trackingUrl} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
|
||||
{order.trackingNumber}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-body-sm text-text-primary">{order.trackingNumber}</p>
|
||||
);
|
||||
})()}
|
||||
</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
|
||||
common case (no override) keeps the original "Lieferadresse"
|
||||
label, since that's exactly what this address still is. */}
|
||||
<p className="text-label text-text-muted">{order.hasDifferentShippingAddress ? "Rechnungsadresse" : "Lieferadresse"}</p>
|
||||
{order.companyName && (
|
||||
<p className="text-body-sm text-text-primary">{order.companyName}</p>
|
||||
)}
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
</p>
|
||||
<p className="text-body-sm text-text-primary">{address}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.zip} {order.city}, {order.country}
|
||||
</p>
|
||||
{order.vatId && (
|
||||
<p className="text-body-sm text-text-muted">
|
||||
USt-IdNr. {order.vatId}
|
||||
{order.kleinunternehmer
|
||||
? " · Kleinunternehmer gem. § 19 UStG"
|
||||
: order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
<p className="text-body-sm text-text-primary">{shippingAddress}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
|
||||
{order.items.map((item, i) => {
|
||||
const imageUrl = imagesByProductId.get(item.product);
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-4 w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{imageUrl && <Image src={imageUrl} alt="" fill sizes="64px" className="object-cover" />}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{item.quantity} × {item.productName}
|
||||
{item.variantName ? ` (${item.variantName})` : ""}
|
||||
</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.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(order.subtotal)}</span>
|
||||
</div>
|
||||
{order.discountCode && (
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({order.discountCode})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-body-sm text-success">-{formatPrice(order.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Versand ({order.shippingMethodTitle})</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
{order.kleinunternehmer ? (
|
||||
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
|
||||
) : (
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.returnReason && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Grund der Rücksendung</p>
|
||||
<p className="text-body-sm text-text-primary">{order.returnReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{order.invoiceNumber && (
|
||||
<a
|
||||
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/invoice`}
|
||||
className="text-body-sm text-brand hover:underline"
|
||||
>
|
||||
Rechnung herunterladen ({order.invoiceNumber})
|
||||
</a>
|
||||
)}
|
||||
{order.correctionInvoiceNumber && (
|
||||
<a
|
||||
href={`/api/account/orders/${encodeURIComponent(order.orderNumber)}/correction-invoice`}
|
||||
className="text-body-sm text-brand hover:underline"
|
||||
>
|
||||
{order.status === "returned" ? "Gutschrift" : "Stornorechnung"} herunterladen ({order.correctionInvoiceNumber})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<OrderActionButton
|
||||
orderNumber={order.orderNumber}
|
||||
action={action}
|
||||
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Metadata } from "next";
|
||||
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 { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Bestellungen",
|
||||
description: "Deine Bestellhistorie bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function KontoBestellungenPage() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const orders = await getCustomerOrders(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>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</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"
|
||||
>
|
||||
<div className="flex flex-col gap-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">
|
||||
<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">
|
||||
<p className="text-label text-text-muted">Status</p>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 ml-auto">
|
||||
<p className="text-label text-text-muted">Gesamtbetrag</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ORDER_STATUS_LABEL } from "../../lib/customerAuth";
|
||||
|
||||
// Colors are a rough "how good is this news" scale — neutral while
|
||||
// in-progress, success once actually delivered, warm/red for anything
|
||||
// that means the order didn't complete as planned. Reuses existing tokens
|
||||
// where they exist (--color-brand, --color-success/-subtle); cancelled/
|
||||
// return_requested borrow plain Tailwind red/orange since this codebase
|
||||
// has no custom tokens for those (same reasoning as the existing
|
||||
// text-red-600 error-text convention elsewhere).
|
||||
const STYLES: Record<string, string> = {
|
||||
received: "bg-bg-muted text-text-muted",
|
||||
processing: "bg-brand/10 text-brand",
|
||||
shipped: "bg-brand/10 text-brand",
|
||||
delivered: "bg-success-subtle text-success",
|
||||
cancelled: "bg-red-50 text-red-600",
|
||||
return_requested: "bg-orange-50 text-orange-600",
|
||||
returned: "bg-bg-muted text-text-light",
|
||||
};
|
||||
|
||||
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"}`}
|
||||
>
|
||||
{ORDER_STATUS_LABEL[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
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/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Login fehlgeschlagen.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push("/konto/bestellungen");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Login 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)" }}>
|
||||
Anmelden
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
<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
|
||||
autoComplete="current-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…" : "Einloggen"}
|
||||
</button>
|
||||
</form>
|
||||
<Link href="/konto/passwort-vergessen" className="text-body-sm text-text-muted underline hover:text-brand transition-colors">
|
||||
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.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoginForm } from "./components/LoginForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
title: "Anmelden",
|
||||
description: "Melde dich bei deinem einfach produktiv-Konto an.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function KontoLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<LoginForm />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch("/api/account/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
} catch {
|
||||
// Same "always show success" reasoning as the route itself — a
|
||||
// network hiccup here shouldn't reveal anything either.
|
||||
}
|
||||
setSent(true);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-4 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)" }}>
|
||||
E-Mail unterwegs
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Falls zu <strong>{email}</strong> ein Konto existiert, haben wir dir eine E-Mail mit einem Link zum
|
||||
Zurücksetzen deines Passworts geschickt.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
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)" }}>
|
||||
Passwort vergessen
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Gib deine E-Mail-Adresse ein — wir schicken dir einen Link, mit dem du ein neues Passwort vergeben kannst.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
<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>
|
||||
<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…" : "Link anfordern"}
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ForgotPasswordForm } from "./components/ForgotPasswordForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Passwort vergessen",
|
||||
description: "Setze dein Passwort für dein einfach produktiv-Konto zurück.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function PasswortVergessenPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<ForgotPasswordForm />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
|
||||
export function ResetPasswordForm({ token }: { token: string | null }) {
|
||||
const router = useRouter();
|
||||
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();
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Passwort konnte nicht zurückgesetzt werden.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
router.push("/konto/bestellungen");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Passwort konnte gerade nicht zurückgesetzt werden.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-4 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)" }}>
|
||||
Link ungültig
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Fordere gerne einen neuen an.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
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)" }}>
|
||||
Neues Passwort vergeben
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Neues 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…" : "Passwort speichern"}
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ResetPasswordForm } from "./components/ResetPasswordForm";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Passwort zurücksetzen",
|
||||
description: "Vergib ein neues Passwort für dein einfach produktiv-Konto.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
// token read server-side from searchParams (not the client-side
|
||||
// useSearchParams() hook) — avoids needing a Suspense boundary here, same
|
||||
// reasoning as /konto/profil's ?verified= handling.
|
||||
export default async function PasswortZuruecksetzenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const { token } = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<ResetPasswordForm token={token ?? null} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
|
||||
const inputClass =
|
||||
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
|
||||
|
||||
export function AccountDataSection() {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
async function handleDelete(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Konto konnte nicht gelöscht werden.");
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Konto konnte gerade nicht gelöscht werden.");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-4 items-start w-full pt-4 border-t border-border">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Konto & Daten
|
||||
</p>
|
||||
|
||||
<a href="/api/account/export" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
|
||||
Meine Daten exportieren
|
||||
</a>
|
||||
|
||||
{!confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="text-body-sm text-red-600 underline hover:text-red-700 transition-colors"
|
||||
>
|
||||
Konto löschen
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handleDelete} className="flex flex-col gap-3 items-start w-full max-w-sm">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Dein Konto und deine gespeicherte Adresse werden gelöscht. Bereits aufgegebene Bestellungen bleiben aus
|
||||
steuerrechtlichen Gründen mit ihren eigenen Daten erhalten, sind danach aber keinem Konto mehr zugeordnet.
|
||||
</p>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Passwort zur Bestätigung</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={deleting}
|
||||
className={`px-5 py-3 rounded-sm bg-red-600 hover:bg-red-700 font-bold text-body-sm text-white transition-colors ${deleting ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{deleting ? "…" : "Konto endgültig löschen"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)} className="px-5 py-3 text-body-sm text-text-muted">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
|
||||
const inputClass =
|
||||
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
|
||||
|
||||
export function PasswordForm({ email }: { email: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formEl = e.currentTarget;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const form = new FormData(formEl);
|
||||
const currentPassword = String(form.get("currentPassword") ?? "");
|
||||
const newPassword = String(form.get("newPassword") ?? "");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Passwort konnte nicht geändert werden.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setSaving(false);
|
||||
formEl.reset();
|
||||
} catch {
|
||||
setError("Passwort konnte gerade nicht geändert werden.");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-6 items-start w-full pt-4 border-t border-border">
|
||||
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Passwort ändern
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
|
||||
{/* Hidden, but present so autofill/password managers correctly
|
||||
associate the new password with this account's email. */}
|
||||
<input type="hidden" name="email" value={email} autoComplete="username" />
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Aktuelles Passwort</span>
|
||||
<input type="password" name="currentPassword" autoComplete="current-password" required className={inputClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
|
||||
<span className="text-label text-text-muted">Neues Passwort</span>
|
||||
<input type="password" name="newPassword" autoComplete="new-password" minLength={8} required className={inputClass} />
|
||||
</label>
|
||||
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
{success && <p className="text-label text-success">Passwort geändert.</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{saving ? "Speichert…" : "Passwort ändern"}
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import type { CustomerProfile } from "../../../lib/customerAuth";
|
||||
import type { ShippingCountry } from "../../../lib/payload";
|
||||
|
||||
const inputClass =
|
||||
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
...props
|
||||
}: { label: string; wrapperClassName?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input {...props} className={inputClass} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
profile,
|
||||
shippingCountries,
|
||||
}: {
|
||||
profile: CustomerProfile;
|
||||
/** Same admin-configurable list /checkout's own "Land" <select> reads
|
||||
* (Payload's shipping-countries collection) — this form used to hardcode
|
||||
* its own Deutschland/Österreich/Schweiz options independently, so a
|
||||
* country added/removed there never reached the profile page. */
|
||||
shippingCountries: ShippingCountry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const body = {
|
||||
firstName: String(form.get("firstName") ?? ""),
|
||||
lastName: String(form.get("lastName") ?? ""),
|
||||
deliveryMethod,
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Profil konnte nicht gespeichert werden.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Profil konnte gerade nicht gespeichert werden.");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
{/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr.
|
||||
fields, same "profile default, order keeps its own snapshot"
|
||||
split as the address fields below (see Customers.ts). */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
|
||||
<Field
|
||||
label="USt-IdNr. (optional)"
|
||||
name="vatId"
|
||||
type="text"
|
||||
defaultValue={profile.vatId ?? ""}
|
||||
placeholder="DE123456789"
|
||||
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
|
||||
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
|
||||
<Field label="Ort" name="city" type="text" defaultValue={profile.city ?? ""} 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="country" defaultValue={profile.country ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
|
||||
{shippingCountries.map((c) => (
|
||||
<option key={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
{success && <p className="text-label text-success">Gespeichert.</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={`px-7 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${saving ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{saving ? "Speichert…" : "Speichern"}
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function VerificationBanner({ emailVerified, justVerified }: { emailVerified: boolean; justVerified: "1" | "0" | undefined }) {
|
||||
const [sent, setSent] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (emailVerified) {
|
||||
// Only shown right after clicking the link — not a persistent banner
|
||||
// once verified, that would just be noise on every future visit.
|
||||
if (justVerified === "1") {
|
||||
return <p className="text-label text-success w-full">E-Mail-Adresse bestätigt.</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/account/resend-verification", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setError(data.reason || "Mail konnte nicht gesendet werden.");
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
setSending(false);
|
||||
} catch {
|
||||
setError("Mail konnte gerade nicht gesendet werden.");
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-muted rounded-md p-4 flex flex-col gap-1 w-full">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{justVerified === "0"
|
||||
? "Der Bestätigungslink ist ungültig oder abgelaufen."
|
||||
: "Bitte bestätige deine E-Mail-Adresse."}{" "}
|
||||
{!sent && (
|
||||
<button type="button" onClick={handleResend} disabled={sending} className="underline font-bold hover:text-brand transition-colors">
|
||||
{sending ? "…" : "Erneut senden"}
|
||||
</button>
|
||||
)}
|
||||
{sent && <span className="text-success">Mail wurde erneut gesendet.</span>}
|
||||
</p>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { ProfileForm } from "./components/ProfileForm";
|
||||
import { PasswordForm } from "./components/PasswordForm";
|
||||
import { VerificationBanner } from "./components/VerificationBanner";
|
||||
import { AccountDataSection } from "./components/AccountDataSection";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mein Profil",
|
||||
description: "Verwalte deine Kontodaten und dein Passwort bei einfach produktiv.",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function KontoProfilPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ verified?: string }>;
|
||||
}) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const [profile, shippingCountries] = await Promise.all([getCustomerProfile(session.token), getShippingCountries()]);
|
||||
if (!profile) redirect("/konto/login");
|
||||
|
||||
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 />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+38
-18
@@ -3,6 +3,8 @@ import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings } from "./lib/payload";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -28,28 +30,45 @@ const lora = Lora({
|
||||
weight: ["400", "600"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
|
||||
title: {
|
||||
default: "einfach produktiv. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
template: "%s | einfach produktiv.",
|
||||
},
|
||||
description: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
openGraph: {
|
||||
siteName: "einfach produktiv.",
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
};
|
||||
// Backend-driven since 2026-07-24 (CompanySettings' "SEO" tab) — the
|
||||
// literal strings below are only the fallback getSeoSettings() returns if
|
||||
// that field is empty or unreachable, kept identical to what used to be
|
||||
// hardcoded here so nothing changes until an admin actually fills in the
|
||||
// new fields.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const seo = await getSeoSettings();
|
||||
return {
|
||||
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
|
||||
title: {
|
||||
default: seo.defaultTitle ?? "einfach produktiv.",
|
||||
template: seo.titleTemplate ?? "%s | einfach produktiv.",
|
||||
},
|
||||
description: seo.defaultDescription ?? undefined,
|
||||
openGraph: {
|
||||
siteName: "einfach produktiv.",
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
images: seo.defaultOgImage ? [{ url: seo.defaultOgImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Same 60s-ISR-cached call every other page already makes — reused here
|
||||
// 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 singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
@@ -57,7 +76,8 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<CartFlyProvider>
|
||||
<Navbar />
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeBundleContents } from "../bundleContents";
|
||||
import type { RawProduct } from "../productsServer";
|
||||
|
||||
const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
id: 1,
|
||||
slug: "starter-set",
|
||||
name: "Starter-Set",
|
||||
price: 29.9,
|
||||
active: true,
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
trackInventory: false,
|
||||
stock: null,
|
||||
allowBackorder: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("describeBundleContents", () => {
|
||||
it("returns null for a regular (non-bundle) product", () => {
|
||||
expect(describeBundleContents(product())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty bundleItems array", () => {
|
||||
expect(describeBundleContents(product({ bundleItems: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a single bundle item as 'qty× name'", () => {
|
||||
const result = describeBundleContents(
|
||||
product({ bundleItems: [{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 }] }),
|
||||
);
|
||||
expect(result).toBe("2× ToDo-Karten");
|
||||
});
|
||||
|
||||
it("joins multiple bundle items with a comma", () => {
|
||||
const result = describeBundleContents(
|
||||
product({
|
||||
bundleItems: [
|
||||
{ product: { id: 2, name: "ToDo-Karten" }, quantity: 2 },
|
||||
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toBe("2× ToDo-Karten, 1× Wochenplaner");
|
||||
});
|
||||
|
||||
it("skips a line whose product didn't resolve to an object (depth miss)", () => {
|
||||
const result = describeBundleContents(
|
||||
product({
|
||||
bundleItems: [
|
||||
{ product: 5, quantity: 1 },
|
||||
{ product: { id: 3, name: "Wochenplaner" }, quantity: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toBe("1× Wochenplaner");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSubtotal, computeCartTotals, type CartLine } from "../cartTotals";
|
||||
import type { Product } from "../payload";
|
||||
|
||||
const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
id: "todo-karten",
|
||||
name: "ToDo-Karten",
|
||||
description: "",
|
||||
price: 12.9,
|
||||
compareAtPrice: null,
|
||||
image: "",
|
||||
href: null,
|
||||
active: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
spotlight: false,
|
||||
spotlightEyebrow: null,
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const line = (qty: number, productOverrides: Partial<Product> = {}): CartLine => ({ entry: { qty }, product: product(productOverrides) });
|
||||
|
||||
describe("computeSubtotal", () => {
|
||||
it("sums quantity × price across lines", () => {
|
||||
expect(computeSubtotal([line(2, { price: 10 }), line(1, { price: 5 })])).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty cart", () => {
|
||||
expect(computeSubtotal([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCartTotals", () => {
|
||||
it("adds shipping on top of the subtotal with no discount", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 20 })], 2.9, null);
|
||||
expect(totals.subtotal).toBe(20);
|
||||
expect(totals.total).toBeCloseTo(22.9, 6);
|
||||
expect(totals.discountAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("applies a percent discount before adding shipping", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 100 })], 5, { type: "percent", value: 10 });
|
||||
expect(totals.discountAmount).toBe(10);
|
||||
expect(totals.total).toBe(95); // 100 - 10 + 5
|
||||
});
|
||||
|
||||
it("applies a fixed discount, clamped so the total never goes negative", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 5 })], 0, { type: "fixed", value: 50 });
|
||||
expect(totals.discountAmount).toBe(5); // clamped to subtotal
|
||||
expect(totals.total).toBe(0);
|
||||
});
|
||||
|
||||
it("computes totalSavings from compareAtPrice, separately from the discount code", () => {
|
||||
const totals = computeCartTotals([line(2, { price: 10, compareAtPrice: 15 })], 0, null);
|
||||
expect(totals.totalSavings).toBe(10); // 2 × (15 - 10)
|
||||
expect(totals.subtotal).toBe(20); // uses price, not compareAtPrice
|
||||
});
|
||||
|
||||
it("ignores compareAtPrice when it isn't actually higher than price", () => {
|
||||
const totals = computeCartTotals([line(1, { price: 10, compareAtPrice: 10 })], 0, null);
|
||||
expect(totals.totalSavings).toBe(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user