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:
25
src/App.vue
25
src/App.vue
@@ -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
25
src/components/splashPaths.js
Normal file
25
src/components/splashPaths.js
Normal file
File diff suppressed because one or more lines are too long
278
src/design-system/components/BrandHero.vue
Normal file
278
src/design-system/components/BrandHero.vue
Normal 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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
82
src/design-system/components/ProductTeaser.vue
Normal file
82
src/design-system/components/ProductTeaser.vue
Normal 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>
|
||||
@@ -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 —',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user