Compare commits

...

4 Commits

Author SHA1 Message Date
Marco af00fd091f fix(shop): match "Mehr erfahren" link style to Werkzeug-card CTAs
Was always brand-colored with an underline on hover; now black by
default and brand-colored on hover, consistent with the Werkzeuge
cards' CTA links elsewhere on the site.
2026-07-21 11:15:54 +00:00
Marco a560ec9434 fix(cart): only hide free-shipping banner after it's actually been seen
The hide-timer used to fire on a plain phase==="success" timeout,
regardless of whether the banner was actually on screen — if the
threshold was reached while scrolled away, it could hide itself before
the user ever saw it.

Gates the timer on continuous IntersectionObserver visibility
(useInView, no `once`) instead of a lifetime "ever visible" flag — the
latter flips true as soon as the page loads (the banner sits at the
top), which defeats the purpose entirely. The timer now only runs
while the banner is actually in view, and restarts if the user scrolls
away and back before it completes.
2026-07-21 11:15:51 +00:00
Marco cc9da6ac6d feat(newsletter, checkout): add consent links, unify trust-note styling, optimize images
Every email-signup form (Home/newsletter Newsletter section, /newsletter
hero, /challenge, the Newsletter overlay) now links "Datenschutzerklärung"
to /datenschutz next to its consent checkbox, opened in a new tab so a
partially filled form isn't lost. Checkout gets an equivalent AGB +
Datenschutzerklärung note under the order button, and the Versand
modal's Widerrufsbelehrung link also opens in a new tab.

Unified the "Keine Werbung. Jederzeit abbestellbar." trust note (icon +
#888 copy) across all newsletter forms, matching /challenge's existing
style instead of each form having its own wording/color.

Also converted the remaining raw <img> tags in these files to next/image
per the no-img-element lint rule (bandwidth/LCP).
2026-07-21 11:15:44 +00:00
Marco a72aea1070 fix(navbar): reset scroll to top when navigating home via logo
Since the Navbar lives in the root layout and never unmounts across
navigations, Next.js's default Link scroll behavior left the previous
page's scroll offset in place instead of resetting to top — landing
users wherever that old offset happened to fall in Home's layout
(often around the Werkzeuge section) instead of at the top.
2026-07-21 11:15:37 +00:00
9 changed files with 198 additions and 38 deletions
+22 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -53,14 +53,29 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
}
}
// 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, threshold - subtotal);
const progressPct = Math.min(100, (subtotal / threshold) * 100);
@@ -73,6 +88,7 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
<AnimatePresence>
{phase !== "hidden" && (
<motion.div
ref={ref}
initial={false}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
+44 -12
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { Footer } from "../components/Footer";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
@@ -154,6 +155,28 @@ function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: str
{buttonLabel}
</button>
</div>
{/* Consent checkbox — this signup's legal basis is consent (email
marketing), same wording as the other newsletter forms; colors
match this page's own hardcoded palette instead of the shared
design tokens, consistent with the rest of the page. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
/>
<span className="text-[0.8rem] text-[#444] leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-[#f6a701]"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
<p className="flex items-center gap-1.5 text-[0.8rem] text-[#888]">
<LockIcon />
Keine Werbung. Jederzeit abbestellbar.
@@ -241,10 +264,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">
@@ -293,9 +318,11 @@ export default function ChallengePage() {
// stacked below lg, this page's own structural
// breakpoint) rather than hidden below lg like before.
<div key={`arrow-${i}`} className="flex items-center justify-center shrink-0 lg:mt-5">
<img
<Image
alt=""
src="/icon-arrow-connector.svg"
width={24}
height={24}
className="w-6 h-6 rotate-90 lg:w-10 lg:h-3 lg:rotate-0"
/>
</div>
@@ -324,14 +351,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>
@@ -389,11 +417,15 @@ export default function ChallengePage() {
</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 className="relative size-10 shrink-0 rounded-full overflow-hidden">
<Image
alt={t.name}
src={t.avatar}
fill
sizes="40px"
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
</div>
<div>
<p className="font-semibold text-[#222221] text-[0.9rem]">{t.name}</p>
<p className="text-[#777] text-[0.8rem]">{t.role}</p>
+17 -4
View File
@@ -293,7 +293,9 @@ export function CheckoutContent({
<span className="flex-1 text-body-sm text-text-primary">{method.title}</span>
<span className="flex items-center gap-2 shrink-0">
{method.icons.map((icon, i) => (
<img key={i} alt="" src={icon} className="h-5 w-auto" />
<div key={i} className="relative h-5 w-8 shrink-0">
<Image src={icon} alt="" fill sizes="32px" className="object-contain" />
</div>
))}
</span>
</label>
@@ -307,8 +309,19 @@ export function CheckoutContent({
Jetzt kaufen (zahlungspflichtig)
</Link>
<p className="text-label text-text-muted text-center w-full">
Mit dem Kauf akzeptierst du unsere{" "}
<Link href="/agb" target="_blank" rel="noopener noreferrer" className="underline hover:text-brand">
AGB
</Link>{" "}
und die{" "}
<Link href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-brand">
Datenschutzerklärung
</Link>.
</p>
<div className="flex gap-2 items-center justify-center w-full">
<img alt="" src="/icon-lock.svg" className="w-3.5 h-4" />
<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>
</Reveal>
@@ -398,7 +411,7 @@ export function CheckoutContent({
<div className="flex flex-col gap-4 items-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-start w-full">
<img alt="" src={b.icon} className="size-5 shrink-0 object-contain mt-0.5" />
<Image alt="" src={b.icon} width={20} height={20} className="size-5 shrink-0 object-contain mt-0.5" />
<div className="flex-1 flex flex-col gap-0.5">
<p className="text-body-sm font-semibold text-text-primary">{b.title}</p>
<p className="text-label text-text-muted">{b.description}</p>
@@ -412,7 +425,7 @@ export function CheckoutContent({
which puts the divider under the icon — the two aren't meant
to be identical, see project notes on this pattern). */}
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full">
<img alt="" src="/icon-envelope-hint.png" className="h-8 w-10 shrink-0 object-contain" />
<Image alt="" src="/icon-envelope-hint.png" width={40} height={32} className="h-8 w-10 shrink-0 object-contain" />
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
{/* Conditional on the (unchecked-by-default) consent
checkbox above, not stated as a given — a pre-ticked
+16 -1
View File
@@ -170,10 +170,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) {
+45 -4
View File
@@ -1,6 +1,20 @@
import type { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image";
import { Reveal } from "./Reveal";
// 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?: ReactNode;
description?: string;
@@ -32,9 +46,11 @@ export function Newsletter({
{/* Decorative envelope icon, tilted -4° as per design */}
<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"
width={64}
height={55}
className="w-16 h-[3.438rem] block"
/>
</div>
@@ -73,9 +89,34 @@ export function Newsletter({
</button>
</div>
{/* Privacy note */}
<p className="text-label text-text-primary font-normal leading-normal">
Ich achte auf deine Daten. Kein Spam, jederzeit abbestellbar.
{/* Consent checkbox — required since this signup's legal
basis is consent (email marketing), not the "Ich achte
auf deine Daten" trust note alone. Same wording/pattern
as NewsletterModal's checkbox. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
{/* 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>
</div>
+15 -5
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import Image from "next/image";
import Link from "next/link";
import { AnimatePresence, motion } from "motion/react";
const features = [
@@ -149,7 +150,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 */}
@@ -170,7 +171,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
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" />
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
</div>
<p
@@ -205,7 +206,16 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
Ich akzeptiere die Datenschutzerklärung.
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
</form>
@@ -224,8 +234,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,6 +2,17 @@ import Link from "next/link";
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
// Same lock icon + copy as /challenge's and the shared Newsletter
// component's trust note — unified across all newsletter-signup forms.
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
<rect x="2" y="6" width="10" height="7" rx="1.5" stroke="#888" strokeWidth="1.3" />
<path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="#888" strokeWidth="1.3" />
</svg>
);
}
const checklist = [
"Jeden Mittwoch neue Impulse & Tipps",
"Kurz & knackig in 5 Minuten gelesen",
@@ -47,7 +58,7 @@ export function WeeklyImpulsesHero() {
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
{/* Pill badge */}
<div className="flex gap-2 items-center border border-border rounded-full pl-3.5 pr-4 py-2">
<img alt="" src="/icon-envelope-small.svg" className="w-4 h-3 shrink-0" />
<Image alt="" src="/icon-envelope-small.svg" width={16} height={12} className="w-4 h-3 shrink-0" />
<span className="text-body-sm font-semibold text-text-primary whitespace-nowrap">
Jeden Mittwoch in deinem Postfach
</span>
@@ -73,7 +84,7 @@ export function WeeklyImpulsesHero() {
<ul className="flex flex-col gap-3 items-start w-full">
{checklist.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-center w-full">
<img alt="" src="/icon-check.svg" className="size-5 shrink-0" />
<Image alt="" src="/icon-check.svg" width={20} height={20} className="size-5 shrink-0" />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
@@ -96,9 +107,31 @@ export function WeeklyImpulsesHero() {
</button>
</div>
{/* Consent checkbox — this signup's legal basis is consent
(email marketing), same wording/pattern as the shared
Newsletter component's and NewsletterModal's checkbox. */}
<label className="flex gap-2 items-start cursor-pointer">
<input
type="checkbox"
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
Ich akzeptiere die{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand"
>
Datenschutzerklärung
</Link>
.
</span>
</label>
<div className="flex gap-[0.375rem] items-center">
<img alt="" src="/icon-lock.svg" className="size-3.5" />
<span className="text-label text-text-muted">Keine Werbung. Jederzeit abbestellbar.</span>
<LockIcon />
<span className="text-label text-[#888]">Keine Werbung. Jederzeit abbestellbar.</span>
</div>
</div>
</Reveal>
+1 -1
View File
@@ -60,7 +60,7 @@ export async function ProductGrid() {
{product.href && (
<Link
href={product.href}
className="flex items-center gap-1 font-bold text-label text-brand hover:underline"
className="flex items-center gap-1 font-bold text-label text-text-primary hover:text-brand transition-colors"
>
<span>Mehr erfahren</span>
<span aria-hidden></span>
+1 -1
View File
@@ -102,7 +102,7 @@ export function VersandSections({ withAnchors = false }: { withAnchors?: boolean
<p>
Informationen zu Rückgabe, Rücksendekosten und deinem gesetzlichen Widerrufsrecht findest
du in unserer{" "}
<Link href="/widerruf" className="text-brand hover:underline">
<Link href="/widerruf" target="_blank" rel="noopener noreferrer" className="text-brand hover:underline">
Widerrufsbelehrung
</Link>
.