home: brand hero with in-flow splash animation, 3-product teaser, bundle imagery

Home page now opens with a BrandHero that plays the figure entrance
animation in flow (replacing the full-screen SplashIntro overlay),
followed by a 3-product Cook/Clean/Care teaser feeding the shop. Splash
paths extracted to a shared module so BrandHero can render the same
illustration without duplicating ~500KB of SVG path strings.

ProductCard gains `cream` and `brand` tones (cream/green media wash
with white card body); homepage teaser uses `brand`, shop catalogue
switches to the green wash too. Bundle cards point at the new
/bundles/background/* artwork.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-05-04 09:40:47 +01:00
parent d6a40592ff
commit ab888d99b0
86 changed files with 829 additions and 162 deletions

View File

@@ -1,26 +1,12 @@
<script setup>
import { computed, ref, defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import SplashIntro from './components/SplashIntro.vue'
const route = useRoute()
const isPreview = computed(() => route.meta.preview === true)
const isDesignRoute = computed(() => route.path.startsWith('/design'))
const inIframe = typeof window !== 'undefined' && window.self !== window.top
// Show the splash once per session, and never inside the DS iframe previews —
// it would replay every time the preview reloads, which is not what we want.
const splashAlreadyShown =
typeof window !== 'undefined' && window.sessionStorage?.getItem('splashed') === '1'
const showSplash = ref(!splashAlreadyShown && !inIframe && !isPreview.value)
function onSplashFinished() {
showSplash.value = false
try {
window.sessionStorage?.setItem('splashed', '1')
} catch {}
}
const isDev = import.meta.env.DEV
const A11yToolbar = isDev
? defineAsyncComponent(() => import('./design-system/devtools/A11yToolbar.vue'))
@@ -28,14 +14,13 @@ const A11yToolbar = isDev
</script>
<template>
<SplashIntro v-if="showSplash" @finished="onSplashFinished" />
<!-- Single router outlet. Each page (Home, Shop, /design/*) owns its
own layout chrome no app-level wrapper, so there's no frame
where an intermediate layout can flash before the route
resolves. (Previously a conditional DefaultLayout was rendered
whenever `route.meta.layout !== 'none'`, but during initial
load `route.meta` is `{}`, so the condition was truthy and the
dev sidebar showed under every page for one frame.) -->
resolves. The full-screen SplashIntro overlay was removed: the
BrandHero on the home page now plays the figure entrance
animation in flow, so the user lands on usable chrome (nav +
hero) immediately rather than waiting through a 2.8s overlay. -->
<router-view />
<A11yToolbar v-if="isDev && isDesignRoute && !isPreview && !inIframe" />
</template>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,278 @@
<!--
BrandHero.vue
-------------------------------------------------------------------
Home-page brand hero. Replaces the previous full-screen SplashIntro
overlay: the same figure-entrance animation now plays in flow on
the page itself, so the user lands on usable chrome (nav + hero)
instead of waiting through a 2.8s overlay.
Layout:
· Desktop (1218 px): the SVG sits as a centred figure inside
the LEFT half of the first fold; the tagline column sits in
the RIGHT half, also centred within its half the two halves
balance one another instead of leaving a wide trough between.
· Below 1218 px: stacked illustration above, tagline below.
Animation choreography (mirrors the old splash exactly, minus the
wordmark which has no destination on the final page):
1. left-m (woman) slides in from the left
2. right-m (landscape) slides in from the right
3. mound-m (white handful of natron) fades in
4. tagline + SINCE 1881 fade in LAST
Path data is imported from `splashPaths.js`.
-->
<template>
<section
class="brand-hero relative isolate overflow-hidden bg-brand text-cream md:min-h-[calc(100svh-var(--nav-h))]"
:class="{ 'is-portrait': isPortrait, go: started }"
>
<!-- =========================================================
DESKTOP single horizontally-centred max-width container
with figure + tagline as a 2-col flex row. Removes the
absolute-positioning trough by anchoring both halves to a
shared centred container; the figure scales with column
width so it stays balanced against the tagline at every
viewport size.
========================================================= -->
<template v-if="!isPortrait">
<div class="mx-auto flex min-h-[calc(100svh-var(--nav-h))] w-full max-w-7xl items-center px-6 md:px-10 lg:px-12">
<!-- Figure column. Width-fills its half of the container,
height auto-derives from the cropped portrait viewBox.
`max-h-[80svh]` keeps the figure from overshooting the
fold on tall ultrawide displays. -->
<div class="brand-hero__media flex w-1/2 items-center justify-center">
<svg
aria-hidden="true"
class="brand-hero__svg--bg block w-full h-auto max-h-[80svh]"
viewBox="0 380 1024 1156"
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
>
<path class="layer left-m" fill-rule="evenodd" :d="dPortLeft" />
<path class="layer right-m" fill-rule="evenodd" :d="dPortRight" />
<path class="layer mound-m" fill-rule="evenodd" :d="dPortMound" />
</svg>
</div>
<!-- Tagline column. Same width as the figure column so the
composition is symmetrically centred; inner copy block
is left-aligned and width-clamped so headline wrapping
stays predictable across breakpoints. -->
<div class="brand-hero__copy flex w-1/2 items-center justify-start pl-4 md:pl-6 lg:pl-8">
<div class="w-full max-w-md xl:max-w-lg 2xl:max-w-xl text-left">
<p class="mb-4 md:mb-5 text-sm md:text-base tracking-label uppercase text-cream/75">{{ since }}</p>
<h1 class="font-display font-normal leading-[1.06] tracking-tight text-cream text-[1.75rem] md:text-[2.25rem] lg:text-[2.5rem] xl:text-[2.75rem] 2xl:text-[3.25rem]">
{{ headlineA }}
<em class="italic font-light text-accent-soft">{{ headlineEm }}</em>
{{ headlineB }}
</h1>
<RouterLink to="/shop" class="mt-7 md:mt-8 inline-flex">
<Button variant="accent" size="lg">{{ shopLabel }}</Button>
</RouterLink>
</div>
</div>
</div>
</template>
<!-- =========================================================
PORTRAIT / MOBILE stacked, illustration above tagline
========================================================= -->
<div
v-else
class="relative mx-auto grid w-full max-w-7xl grid-cols-1 items-center gap-6 px-4 pt-2 pb-10 sm:gap-8 sm:px-6 sm:pt-4"
>
<div class="brand-hero__media">
<svg
class="brand-hero__svg brand-hero__svg--portrait"
viewBox="0 380 1024 1156"
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path class="layer left-m" fill-rule="evenodd" :d="dPortLeft" />
<path class="layer right-m" fill-rule="evenodd" :d="dPortRight" />
<path class="layer mound-m" fill-rule="evenodd" :d="dPortMound" />
</svg>
</div>
<div class="brand-hero__copy flex flex-col items-center text-center">
<h1 class="max-w-3xl font-display font-normal leading-[1.08] tracking-tight text-cream text-[1.5rem] sm:text-[2rem]">
{{ headlineA }}
<em class="italic font-light text-accent-soft">{{ headlineEm }}</em>
{{ headlineB }}
</h1>
<p class="mt-4 text-[0.95rem] tracking-label uppercase text-cream/75">{{ since }}</p>
<RouterLink to="/shop" class="mt-6 inline-flex">
<Button variant="accent" size="lg">{{ shopLabel }}</Button>
</RouterLink>
</div>
</div>
</section>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import {
dPortLeft,
dPortRight,
dPortMound,
} from '@/components/splashPaths.js'
import Button from './Button.vue'
import { useI18n } from '@/i18n/index.js'
// Bumped from the original 768/900 px split to 1218 px because at
// the desktop split layout's narrower widths the tagline column
// collides with the figure on the left.
const portraitQuery = '(max-width: 1218px)'
const isPortrait = ref(false)
// `started` flips true one RAF after mount so the layers transition
// from their initial offset/hidden state into their final position —
// the splash entrance animation, in flow on the page itself.
const started = ref(false)
let mql = null
let onChange = null
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
mql = window.matchMedia(portraitQuery)
isPortrait.value = mql.matches
onChange = (e) => { isPortrait.value = e.matches }
mql.addEventListener ? mql.addEventListener('change', onChange) : mql.addListener(onChange)
}
// Defer the `go` flip a frame so the browser commits the initial
// (offset / hidden) state before we transition out of it. Without
// the rAF the layers paint in their final position and skip the
// transition entirely.
requestAnimationFrame(() => { started.value = true })
})
onBeforeUnmount(() => {
if (mql && onChange) {
mql.removeEventListener ? mql.removeEventListener('change', onChange) : mql.removeListener(onChange)
}
})
const { t } = useI18n()
const headlineA = t('home.brand.headline.a')
const headlineEm = t('home.brand.headline.em')
const headlineB = t('home.brand.headline.b')
const since = t('home.brand.since')
// Reuses the navbar's "Shop" / "Shop" label — keeps the wording in
// step with the top-nav entry point so the user sees the same word
// for the same destination.
const shopLabel = t('nav.shop')
</script>
<style scoped>
/* Desktop SVG.
`display: block` removes the inline-svg baseline gap.
`mask-image` softens the LEFT and RIGHT edges of the artwork into
the brand-green ground while the entrance is in progress so the
mint silhouette feels less sheer at the edges. Once the entrance
has settled the feather animates back to 0 % so the figure stands
fully opaque at rest — the soft edges are an entrance effect, not
a permanent sticker frame.
The feather amount is held in `--hero-feather`; @property is what
makes the percentage interpolate (custom properties don't animate
without an explicit type registration). Browsers without
@property support snap from 12 % → 0 % at the end of the delay,
which is an acceptable graceful degradation. */
@property --hero-feather {
syntax: '<percentage>';
inherits: false;
initial-value: 12%;
}
.brand-hero__svg--bg {
display: block;
--hero-feather: 12%;
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
#000 var(--hero-feather),
#000 calc(100% - var(--hero-feather)),
transparent 100%
);
mask-image: linear-gradient(
to right,
transparent 0%,
#000 var(--hero-feather),
#000 calc(100% - var(--hero-feather)),
transparent 100%
);
transition: --hero-feather 400ms ease 1850ms;
}
.brand-hero.go .brand-hero__svg--bg {
--hero-feather: 0%;
}
.brand-hero__svg--portrait {
display: block;
width: 100%;
height: auto;
max-height: 56svh;
margin: 0 auto;
}
/* Layer fills (matches the splash's resolved palette). */
.left-m { fill: #b5d8b6; }
.right-m { fill: #b5d8b6; }
.mound-m { fill: #ffffff; }
/* ---------- Entrance animation (replaces SplashIntro) ----------
Initial state: figures translated off to their respective sides
and hidden, mound and copy hidden. Adding `.go` to the section
transitions every layer to its resting position. Delays cascade
so the figures land first, the mound fades in once they've
landed, and the copy is the last beat — the eye reaches the
tagline only after the artwork has settled. */
.layer.left-m {
opacity: 0;
transform: translateX(-14%);
transition: transform 800ms cubic-bezier(.22, .61, .36, 1) 150ms,
opacity 600ms ease 150ms;
}
.layer.right-m {
opacity: 0;
transform: translateX(14%);
transition: transform 800ms cubic-bezier(.22, .61, .36, 1) 150ms,
opacity 600ms ease 150ms;
}
.layer.mound-m {
opacity: 0;
transition: opacity 550ms ease 700ms;
}
.brand-hero__copy {
opacity: 0;
transition: opacity 700ms ease 1150ms;
}
.brand-hero.go .layer.left-m,
.brand-hero.go .layer.right-m {
opacity: 1;
transform: none;
}
.brand-hero.go .layer.mound-m {
opacity: 1;
}
.brand-hero.go .brand-hero__copy {
opacity: 1;
}
/* Reduced-motion users get the final state immediately — no slide,
no fade. The hero is purely decorative animation, so honouring
the preference doesn't cost any communicated information. */
@media (prefers-reduced-motion: reduce) {
.layer.left-m,
.layer.right-m,
.layer.mound-m,
.brand-hero__copy {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
</style>

View File

@@ -30,7 +30,7 @@ const props = defineProps({
tone: {
type: String,
default: 'paper',
validator: (t) => ['paper', 'cream'].includes(t),
validator: (t) => ['paper', 'cream', 'brand'].includes(t),
},
inStock: { type: Boolean, default: true },
href: { type: String, default: '' },
@@ -46,11 +46,16 @@ defineEmits(['add'])
const { t } = useI18n()
// Media background is pinned to `bg-paper` (white) for both tones
// while the product PNGs still carry baked-in solid backgrounds.
// Once the imagery is re-exported with transparency we can reinstate
// the tone-coupled media tint (paper→cream, cream→paper) for the
// subtle surface contrast the DS originally called for.
// Each tone pairs a media wash (the image area at the top of the
// card) with a body surface (the title / price / CTA cluster
// underneath).
// · `paper` — historic all-white card.
// · `cream` — cream image wash + white body. Homepage ProductTeaser
// uses this so the product silhouettes share the cream surface
// of the surrounding section.
// · `brand` — brand-green image wash + white body. Shop page uses
// this so the products read as the brand stage and the cards
// pop off the cream catalogue surface.
const tones = {
paper: {
surface: 'bg-paper',
@@ -58,8 +63,13 @@ const tones = {
border: 'border-line',
},
cream: {
surface: 'bg-cream',
media: 'bg-paper',
surface: 'bg-paper',
media: 'bg-cream',
border: 'border-line',
},
brand: {
surface: 'bg-paper',
media: 'bg-brand',
border: 'border-line',
},
}

View File

@@ -0,0 +1,82 @@
<!--
ProductTeaser.vue
-------------------------------------------------------------------
Three-card product teaser sitting above the primary product hero on
the homepage. Picks one product per use-case (Cook / Clean / Care)
so the row reads as a representative sample rather than a curated
bestseller list the "Shop Kaiser Natron" CTA underneath funnels
the visitor into the full catalogue.
-->
<script setup>
import { RouterLink } from 'vue-router'
import ProductCard from './ProductCard.vue'
import Button from './Button.vue'
defineProps({
eyebrow: { type: String, default: '' },
headline: { type: String, default: '' },
sub: { type: String, default: '' },
/** Three products. Card layout assumes a 3-up grid on md+. */
products: { type: Array, required: true },
ctaLabel: { type: String, default: '' },
ctaHref: { type: String, default: '/shop' },
tone: {
type: String,
default: 'cream',
validator: (t) => ['cream', 'paper', 'surface'].includes(t),
},
})
defineEmits(['add'])
const tones = {
cream: 'bg-cream',
paper: 'bg-paper',
surface: 'bg-surface',
}
</script>
<template>
<section
:class="[
'relative px-6 py-14 sm:px-8 sm:py-16 md:px-12 md:py-20 lg:px-16 lg:py-24',
tones[tone],
]"
>
<div class="mx-auto w-full max-w-6xl flex flex-col items-center gap-10 md:gap-14">
<header class="flex flex-col items-center gap-3 text-center max-w-2xl">
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h2
v-if="headline"
class="font-display font-normal leading-[1.08] tracking-tight text-ink text-[1.625rem] sm:text-[2rem] md:text-[2.5rem]"
>{{ headline }}</h2>
<p v-if="sub" class="text-lg leading-relaxed text-muted">{{ sub }}</p>
</header>
<!-- Three-up grid. Stacks on mobile, two-up on md (so the third
card doesn't get squashed below 1024px), three-up on lg+. -->
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 md:gap-7 lg:grid-cols-3 lg:gap-8">
<ProductCard
v-for="p in products"
:key="p.id"
:title="p.name || p.title"
:size="p.size || ''"
:price="p.price"
:image="p.image"
:image-alt="p.imageAlt || p.name || p.title"
:badge="p.badge || ''"
:badge-variant="p.badgeVariant || 'accent'"
:href="p.href || (p.id ? `/shop/${p.id}` : '')"
tone="brand"
@add="$emit('add', p.id)"
/>
</div>
<div v-if="ctaLabel" class="flex justify-center">
<RouterLink :to="ctaHref" class="inline-flex">
<Button variant="primary" size="lg">{{ ctaLabel }}</Button>
</RouterLink>
</div>
</div>
</section>
</template>

View File

@@ -350,6 +350,17 @@ const de = {
'home.categories.cook': 'Kochen',
'home.categories.care': 'Pflege',
// Home page brand hero (top-of-fold, splash artwork settles here).
'home.brand.headline.a': 'Kaiser Natron ',
'home.brand.headline.em': 'der Premium-Anbieter',
'home.brand.headline.b': 'für Reinigen, Kochen, Pflegen.',
'home.brand.since': 'seit 1881',
// Home page 3-product teaser sitting between brand hero and product hero.
'home.teaser.eyebrow': 'Drei Klassiker',
'home.teaser.headline': 'Für jeden Bereich das Richtige.',
'home.teaser.sub': 'Ein Beispiel aus jeder Familie Kochen, Reinigen, Pflegen.',
'home.teaser.cta': 'Kaiser Natron Shop',
// Checkout page (DE).
'checkout.eyebrow': 'Bestellung abschließen',
'checkout.headline': 'Fast geschafft —',
@@ -1012,6 +1023,17 @@ const en = {
'home.categories.cook': 'Cook',
'home.categories.care': 'Care',
// Home page brand hero (top-of-fold, splash artwork settles here).
'home.brand.headline.a': 'Kaiser Natron —',
'home.brand.headline.em': 'the premium provider',
'home.brand.headline.b': 'for Clean, Cook, Care.',
'home.brand.since': 'since 1881',
// Home page 3-product teaser sitting between brand hero and product hero.
'home.teaser.eyebrow': 'Three classics',
'home.teaser.headline': 'One pick from every family.',
'home.teaser.sub': 'A flagship for cooking, cleaning and care — three SKUs, one origin.',
'home.teaser.cta': 'Shop Kaiser Natron',
// Checkout page (EN).
'checkout.eyebrow': 'Complete your order',
'checkout.headline': 'Almost there —',

View File

@@ -3,6 +3,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Navbar from '@/design-system/components/Navbar.vue'
import Hero from '@/design-system/components/Hero.vue'
import BrandHero from '@/design-system/components/BrandHero.vue'
import ProductTeaser from '@/design-system/components/ProductTeaser.vue'
import Bundles from '@/design-system/components/Bundles.vue'
import Revitalization from '@/design-system/components/Revitalization.vue'
import About from '@/design-system/components/About.vue'
@@ -58,6 +60,30 @@ const heroProductId = 'kaiser-natron-pulver-250-g-grosspackung'
const imgBanner = '/products/kaiser-natron-bad-500-g.webp'
const bannerProductId = 'kaiser-natron-bad-500-g'
// Brand-hero → product-hero teaser: one SKU per use-case (Cook /
// Clean / Care). Avoids duplicating the Pulver 250 g (primary hero)
// and Bad 500 g (cream banner) so the row reads as new surface area
// rather than a repeat of what's already on screen.
const teaserIds = [
'kaiser-natron-tabletten-100-g-dose', // cook
'kaiser-natron-allzweck-spray-500-ml', // clean
'kaiser-natron-fussbad-500-g', // care
]
const teaserProducts = computed(() =>
teaserIds
.map((id) => products.find((p) => p.id === id))
.filter(Boolean)
.map((p) => ({
id: p.id,
name: p.title,
size: p.size,
price: p.price,
image: p.image,
imageAlt: p.title,
href: p.href,
})),
)
// Homepage top-level nav items — overrides the Navbar default so the
// homepage reads as the shop entry point (Shop / Bundles / Revitalisierung
// / Über uns) instead of the generic catalogue chrome.
@@ -132,7 +158,7 @@ const bundles = [
],
price: 24.9,
memberPrice: 21.17,
image: '/products/kaiser-natron-pulver-250-g-grosspackung.webp',
image: '/bundles/background/Haushalts-Bundle.webp',
imageAlt: 'Haushalts-Bundle mit Kaiser-Natron',
badge: 'Bestseller',
badgeVariant: 'accent',
@@ -144,7 +170,7 @@ const bundles = [
items: ['1× Holste Wasch-Soda 500 g', '1× Gazelle Wäschestärke 1 l', '1× Linda Fleckenweg 200 ml'],
price: 22.9,
memberPrice: 19.47,
image: '/products/kaiser-natron-pulver-250-g-grosspackung.webp',
image: '/bundles/background/Wäsche & Pflege-Bundle.webp',
imageAlt: 'Wäsche & Pflege Bundle',
badge: '',
badgeVariant: 'accent',
@@ -156,7 +182,7 @@ const bundles = [
items: ['1× Kaiser-Natron Tabletten 100 g', '1× Kaiser-Natron Bad 500 g', '1× Kaiser-Natron Fußbad 500 g'],
price: 29.9,
memberPrice: 25.42,
image: '/products/kaiser-natron-bad-500-g.webp',
image: '/bundles/background/Wohlfühl_Bundle.webp',
imageAlt: 'Wohlfühl-Bundle mit Kaiser-Natron Bad',
badge: '',
badgeVariant: 'accent',
@@ -173,6 +199,12 @@ async function onBannerAdd() {
cartOpen.value = true
}
async function onTeaserAdd(productId) {
if (!productId) return
await addToCart(productId, 1)
cartOpen.value = true
}
// Bundles share a single "add" handler. Until the backend exposes a
// real bundle SKU endpoint, the UI stand-in adds the bundle's anchor
// product to the cart so the user gets visible feedback. The mapping
@@ -274,42 +306,86 @@ onBeforeUnmount(() => {
/>
<!-- First-fold wrapper full viewport height, pulled up under the
sticky nav via a negative margin equal to `--nav-h`. The nav
and the wrapper share the brand green, so the overlap reads as
a single continuous surface, and the hero centers at the TRUE
viewport vertical midpoint (50svh) rather than the midpoint of
the nav-offset space below it. The wave divider sits OUTSIDE
this wrapper so it never eats vertical space from the centering
calculation. `--nav-h` is defaulted in global CSS so first
paint is correct; a ResizeObserver refines it on mount. -->
and the wrapper share the brand green so the overlap reads as
one continuous surface. Hosts the BrandHero same illustration
the SplashIntro overlay leaves behind, so the splash dismiss
fades into a matching in-page artwork with no visual seam. -->
<div
class="flex flex-col bg-brand md:min-h-[calc(100svh-var(--nav-h))] md:justify-center"
>
<Hero
class="w-full"
variant="split"
tone="brand"
:subheadline="t('ds.hero.sub')"
:image="imgPulver250"
image-alt="Kaiser-Natron Pulver 250 g Großpackung"
:cta-label="t('ds.buttons.addToCart')"
:secondary-label="t('ds.buttons.learnMore')"
:secondary-href="`/shop/${heroProductId}`"
@cta="onHeroAdd"
>
<template #headline>
{{ t('ds.hero.headline.a') }}
<em class="italic font-light text-accent-soft">{{ t('ds.hero.headline.em') }}</em>
{{ t('ds.hero.headline.b') }}
</template>
</Hero>
<BrandHero class="w-full" />
</div>
<!-- Wave divider from brand-green cream. Sits OUTSIDE the fold
wrapper so it doesn't steal vertical space from the hero's
centering. The SVG is fully opaque: a cream rect fills the
whole viewBox so the SVG's bottom row is solid cream (matches
the banner below → no seam), and a green path paints the top
portion (matches the bg-brand fold above → no seam). -->
<!-- Wave brand cream. Mirrors the existing pattern: rect = dest
colour (cream), path = source colour (brand), parent painted in
source so the seam disappears against the section above. -->
<svg
aria-hidden="true"
class="block w-full h-12 md:h-16 shrink-0 -mb-px bg-brand"
viewBox="0 0 1440 64"
preserveAspectRatio="none"
>
<rect width="1440" height="64" fill="var(--color-cream)" />
<path
d="M0,0 L0,40 C320,4 520,60 720,32 C920,4 1120,60 1440,24 L1440,0 Z"
fill="var(--color-brand)"
/>
</svg>
<!-- Three-product teaser one SKU per Cook/Clean/Care use-case,
cream surface, "Shop Kaiser Natron" CTA underneath funnels
into the full catalogue. -->
<ProductTeaser
class="-mt-px"
:eyebrow="t('home.teaser.eyebrow')"
:headline="t('home.teaser.headline')"
:sub="t('home.teaser.sub')"
:products="teaserProducts"
:cta-label="t('home.teaser.cta')"
cta-href="/shop"
tone="cream"
@add="onTeaserAdd"
/>
<!-- Wave cream brand sets up the existing Pulver product hero,
which keeps its brand-green ground. rect = brand (dest), path =
cream (source), parent painted cream. -->
<svg
aria-hidden="true"
class="block w-full h-12 md:h-16 shrink-0 -mb-px bg-cream"
viewBox="0 0 1440 64"
preserveAspectRatio="none"
>
<rect width="1440" height="64" fill="var(--color-brand)" />
<path
d="M0,0 L0,40 C320,4 520,60 720,32 C920,4 1120,60 1440,24 L1440,0 Z"
fill="var(--color-cream)"
/>
</svg>
<!-- Existing Pulver 250 g hero moved out of the first-fold
wrapper since BrandHero now owns the first fold. Sits on
brand-green via `tone="brand"`. -->
<Hero
class="-mt-px w-full"
variant="split"
tone="brand"
:subheadline="t('ds.hero.sub')"
:image="imgPulver250"
image-alt="Kaiser-Natron Pulver 250 g Großpackung"
:cta-label="t('ds.buttons.addToCart')"
:secondary-label="t('ds.buttons.learnMore')"
:secondary-href="`/shop/${heroProductId}`"
@cta="onHeroAdd"
>
<template #headline>
{{ t('ds.hero.headline.a') }}
<em class="italic font-light text-accent-soft">{{ t('ds.hero.headline.em') }}</em>
{{ t('ds.hero.headline.b') }}
</template>
</Hero>
<!-- Wave brand cream for the second-fold cream banner. -->
<svg
aria-hidden="true"
class="block w-full h-12 md:h-16 shrink-0 -mb-px bg-brand"
@@ -326,9 +402,7 @@ onBeforeUnmount(() => {
<!-- Second-fold product banner same Hero component, cream surface,
split layout reversed so the product sits on the left. `compact`
tightens the desktop media sizing so this section reads as a
companion band, not a second full hero stage. The -mt-px pairs
with the wave's -mb-px to overlap the two sections by 1 CSS
pixel and hide any device-pixel seam. -->
companion band, not a second full hero stage. -->
<Hero
class="-mt-px"
variant="split"

View File

@@ -241,7 +241,7 @@ onBeforeUnmount(() => {
:image="product.image"
:image-alt="product.title"
:href="product.href"
tone="cream"
tone="brand"
:in-stock="product.inStock"
:cta-variant="i % 2 === 1 ? 'accent' : 'primary'"
@add="onAdd(product)"