diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index 2aea62f..5a1d698 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { addToCart, useCart } from "../lib/cart"; import { useCartFly } from "./CartFly"; -import { NotifyMeForm } from "./NotifyMeForm"; +import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm"; const FEEDBACK_MS = 2000; @@ -119,19 +119,23 @@ export function AddToCartButton({ ))} )} - {currentlyOutOfStock ? ( - // Replaces the button slot entirely rather than stacking below a - // disabled "Ausverkauft" button — same reasoning as - // AddToCartInlineButton's identical swap. - 0 ? (selectedVariant ?? "") : ""} /> - ) : ( - - )} + + )} + + ); } diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx index bb08b84..cbac8e7 100644 --- a/app/components/AddToCartInlineButton.tsx +++ b/app/components/AddToCartInlineButton.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import { addToCart, useCart } from "../lib/cart"; import { useCartFly } from "./CartFly"; -import { NotifyMeForm } from "./NotifyMeForm"; +import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm"; // Exported so consumers like RelatedProducts.tsx can delay their own // follow-up UI changes (e.g. swapping out this exact card) until after @@ -113,35 +113,46 @@ export function AddToCartInlineButton({ ))} )} - {currentlyOutOfStock ? ( - // Replaces the button slot entirely rather than stacking below a - // disabled "Ausverkauft" button — this component's collapsed idle - // state is a single button, same height as the "In den Warenkorb" - // button it replaces, so an out-of-stock card doesn't end up taller - // than its in-stock siblings and stretch their own buttons down - // (plain CSS Grid rows stretch to the tallest card — see - // ProductGrid.tsx's own flex-1-spacer comment on why equal card - // height matters here). - 0 ? (selectedVariant ?? "") : ""} /> - ) : ( - - )} + {/* grid + [grid-area:1/1] stack — NotifyMeFormReservedSpace (an + invisible, non-interactive twin of NotifyMeForm's markup) always + contributes its height here, even for an in-stock card that + never shows the real form. Without it, only out-of-stock cards + would be tall enough to need the input+button, and plain CSS + Grid's row-stretch (ProductGrid.tsx has no explicit row height) + would push every sibling card's button down to match whichever + card in the row happens to be out of stock. */} +
+
+ +
+ {/* self-start, not the stretch default — the button's TOP edge is + what must align across cards (ProductGrid.tsx's flex-1 spacer + pins this whole block to a card's bottom already); the extra + reserved height below a single button just stays blank. */} +
+ {currentlyOutOfStock ? ( + 0 ? (selectedVariant ?? "") : ""} /> + ) : ( + + )} +
+
); } diff --git a/app/components/CookieBanner.tsx b/app/components/CookieBanner.tsx new file mode 100644 index 0000000..f2cf92f --- /dev/null +++ b/app/components/CookieBanner.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; +import { AnimatePresence, motion } from "motion/react"; +import { useConsent } from "../lib/useConsent"; + +/** + * Bottom banner, shown whenever useConsent()'s cookie hasn't recorded a + * decision yet — the hard prerequisite for TrackingScripts.tsx to ever + * render an analytics/marketing script (see that file's own comment). + * Two equally-sized buttons, not a prominent "Akzeptieren" next to a + * de-emphasized reject link — TTDSG requires an equally easy way to + * decline, not just technically present a way to. + */ +export function CookieBanner() { + const { consent, loaded, setConsent } = useConsent(); + const visible = loaded && consent === null; + + return ( + + {visible && ( + +
+

+ Wir nutzen Cookies, um diese Seite zu betreiben und ihre Nutzung zu verstehen. Mehr dazu in unserer{" "} + + Datenschutzerklärung + + . +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/components/NotifyMeForm.tsx b/app/components/NotifyMeForm.tsx index 720af59..2409918 100644 --- a/app/components/NotifyMeForm.tsx +++ b/app/components/NotifyMeForm.tsx @@ -7,21 +7,18 @@ import { isValidEmail } from "../lib/email"; * Replaces the (disabled) Add-to-cart button's spot once a product/variant * is out of stock — lets a visitor leave their email to be notified once * lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's - * back" mail. `productId` is the numeric Payload id (`product.numericId`), - * NOT AddToCartInlineButton/AddToCartButton's own `id`/`productId` props — - * those are the commerce slug (see lib/payload.ts's Product.id comment) — - * same "numericId, not id" split WishlistButton already uses. + * back" mail. Always shows the email input + submit button directly (no + * extra click to reveal them) — the resulting taller CTA area is + * reserved on every card via NotifyMeFormReservedSpace below, not just + * the out-of-stock one, so the grid's row-stretch never pushes sibling + * cards' buttons down (see AddToCartInlineButton.tsx/AddToCartButton.tsx's + * own comment on why that reservation lives there). `productId` is the + * numeric Payload id (`product.numericId`), NOT AddToCartInlineButton/ + * AddToCartButton's own `id`/`productId` props — those are the commerce + * slug (see lib/payload.ts's Product.id comment) — same "numericId, not + * id" split WishlistButton already uses. */ export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) { - // Collapsed by default (a single button, same height/style as - // AddToCartInlineButton's own "In den Warenkorb" button) — grid card - // layouts here rely on every card in a row being stretched to equal - // height (plain CSS Grid, no explicit height), so an always-expanded - // input+button would make an out-of-stock card taller than its row - // siblings and push THEIR buttons down as the row stretches to match. - // Expanding only after a click keeps the idle-state card height - // identical to every in-stock card next to it. - const [expanded, setExpanded] = useState(false); const [email, setEmail] = useState(""); const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle"); const [error, setError] = useState(""); @@ -57,25 +54,12 @@ export function NotifyMeForm({ productId, variantName = "" }: { productId: numbe return

Danke! Wir melden uns, sobald es wieder verfügbar ist.

; } - if (!expanded) { - return ( - - ); - } - return ( // Stacked, not side-by-side — narrow product cards (shop grid, related // products) don't leave enough width for input + button in one row // without either truncating the placeholder or squeezing the button.
setEmail(e.target.value)} @@ -88,9 +72,29 @@ export function NotifyMeForm({ productId, variantName = "" }: { productId: numbe disabled={status === "submitting"} className="w-full rounded-sm border border-border px-4 py-2 text-body-sm font-semibold text-text-primary hover:border-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed" > - {status === "submitting" ? "…" : "Absenden"} + {status === "submitting" ? "…" : "Bei Verfügbarkeit benachrichtigen"} {error &&

{error}

}
); } + +// A non-interactive, visually identical (markup/classes) twin of +// NotifyMeForm's idle state — rendered `invisible` behind every card's +// actual CTA (see the two AddToCartButton components) so an in-stock +// card's own single-button slot reserves the SAME height an out-of-stock +// sibling's input+button would need. Without this, only out-of-stock +// cards would be taller, and plain CSS Grid's row-stretch would then push +// every other card's button down to match — same class of bug as +// ProductGrid.tsx's existing min-h-reserved low-stock line, just for a +// taller block instead of one text line. +export function NotifyMeFormReservedSpace() { + return ( + + ); +} diff --git a/app/components/TrackingScripts.tsx b/app/components/TrackingScripts.tsx new file mode 100644 index 0000000..40d30d5 --- /dev/null +++ b/app/components/TrackingScripts.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Script from "next/script"; +import { useConsent } from "../lib/useConsent"; +import type { TrackingCode } from "../lib/payload"; + +// Renders one next/script per active tracking code, but only once consent +// actually allows it — `necessary` always renders (nothing in that +// category exists yet, but the field exists for future use, e.g. a +// consent-management/CMP script itself); `analytics`/`marketing` both gate +// on the same single useConsent() flag (see that hook's own comment on why +// there's no separate marketing toggle yet). Fetched server-side +// (getTrackingCodes(), passed in as a prop from layout.tsx) since this +// component itself is a client component and can't call that fetcher +// directly without losing the 60s ISR cache. +export function TrackingScripts({ codes }: { codes: TrackingCode[] }) { + const { consent, loaded } = useConsent(); + if (!loaded) return null; + + const allowed = codes.filter((code) => code.consentCategory === "necessary" || consent?.analytics === true); + + return ( + <> + {allowed.map((code) => { + if (code.provider === "google-analytics" && code.measurementId) { + return ( +
+ +
+ ); + } + if (code.provider === "facebook-pixel" && code.pixelId) { + return ( + + ); + } + if (code.provider === "google-tag-manager" && code.containerId) { + return ( + + ); + } + // 'other' — the only provider whose script content is admin- + // supplied rather than a fixed loader snippet. Trusted deliberately + // (see TrackingCodes.ts's own admin.description) — only reachable + // by someone with backend admin access in the first place. + if (code.provider === "other" && code.customScript) { + return