feat: add Hero component to design system
- Responsive hero for home + category tops (390→1280px), composed from Button, Badge, and the Icon/Logo primitives already in the DS. - Two layouts — split (copy left / product right on lg, stacked below) and centered — and three surface tones — cream, paper, brand. On the brand-green surface the secondary CTA is rendered with a cream outline pill since Button's ghost/secondary variants read dark-on-dark there. - Decorative disc + soft glow behind the product cutout give the image a focal point without needing a photographed backdrop. - Headline can be passed as a string prop or as a slot (so consumers can mix in the italic `text-brand-soft` emphasis used on the home page). - Ships with a /design/hero showcase page that renders the component in the DevicePreview iframe across mobile / tablet / desktop, plus layout and tone tabs and a usage snippet. Preview route is /design/preview/hero so the iframe can include the live Navbar. - i18n keys added to DE + EN; AT inherits from DE. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
198
src/design-system/components/Hero.vue
Normal file
198
src/design-system/components/Hero.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Button from './Button.vue'
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
eyebrow: { type: String, default: '' },
|
||||
headline: { type: String, default: '' },
|
||||
subheadline: { type: String, default: '' },
|
||||
image: { type: String, default: '' },
|
||||
imageAlt: { type: String, default: '' },
|
||||
badge: { type: String, default: '' },
|
||||
badgeVariant: {
|
||||
type: String,
|
||||
default: 'accent',
|
||||
validator: (v) =>
|
||||
['neutral', 'brand', 'accent', 'subtle', 'success', 'warning', 'danger'].includes(v),
|
||||
},
|
||||
ctaLabel: { type: String, default: '' },
|
||||
ctaHref: { type: String, default: '' },
|
||||
secondaryLabel: { type: String, default: '' },
|
||||
secondaryHref: { type: String, default: '' },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'split',
|
||||
validator: (v) => ['split', 'centered'].includes(v),
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'cream',
|
||||
validator: (t) => ['cream', 'paper', 'brand'].includes(t),
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['cta', 'secondary'])
|
||||
|
||||
const tones = {
|
||||
cream: {
|
||||
surface: 'bg-cream',
|
||||
text: 'text-ink',
|
||||
sub: 'text-muted',
|
||||
disc: 'bg-accent-soft/60',
|
||||
glow: 'bg-accent/15',
|
||||
},
|
||||
paper: {
|
||||
surface: 'bg-paper',
|
||||
text: 'text-ink',
|
||||
sub: 'text-muted',
|
||||
disc: 'bg-cream',
|
||||
glow: 'bg-brand-soft-wash',
|
||||
},
|
||||
brand: {
|
||||
surface: 'bg-brand',
|
||||
text: 'text-cream',
|
||||
sub: 'text-cream/80',
|
||||
disc: 'bg-brand-soft/30',
|
||||
glow: 'bg-accent/10',
|
||||
},
|
||||
}
|
||||
|
||||
const tone = computed(() => tones[props.tone])
|
||||
const isBrandTone = computed(() => props.tone === 'brand')
|
||||
const primaryVariant = computed(() => (isBrandTone.value ? 'accent' : 'primary'))
|
||||
|
||||
const layout = computed(() => {
|
||||
if (props.variant === 'centered') {
|
||||
return {
|
||||
root: 'flex flex-col items-center text-center gap-10 md:gap-12',
|
||||
copy: 'max-w-2xl mx-auto items-center text-center',
|
||||
actions: 'justify-center',
|
||||
media: 'mt-4 md:mt-6',
|
||||
mediaSize: 'w-[260px] sm:w-[320px] md:w-[400px] lg:w-[480px]',
|
||||
}
|
||||
}
|
||||
return {
|
||||
root: 'grid gap-10 md:gap-14 lg:grid-cols-[1.05fr_1fr] lg:items-center',
|
||||
copy: 'max-w-xl mx-auto lg:mx-0 items-center text-center lg:items-start lg:text-left',
|
||||
actions: 'justify-center lg:justify-start',
|
||||
media: '',
|
||||
mediaSize: 'w-[260px] sm:w-[340px] md:w-[420px] lg:w-full lg:max-w-[520px]',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'relative overflow-hidden',
|
||||
'px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16 lg:py-28',
|
||||
tone.surface,
|
||||
tone.text,
|
||||
]"
|
||||
>
|
||||
<div :class="['relative mx-auto w-full max-w-6xl', layout.root]">
|
||||
<!-- Copy -->
|
||||
<div :class="['relative z-[1] flex flex-col gap-6', layout.copy]">
|
||||
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
|
||||
|
||||
<h1
|
||||
class="font-display font-normal leading-[1.04] tracking-tight"
|
||||
style="font-size: clamp(2.5rem, 6vw, 4.5rem);"
|
||||
>
|
||||
<slot name="headline">{{ headline }}</slot>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subheadline || $slots.subheadline"
|
||||
:class="['text-lg leading-relaxed max-w-xl', tone.sub]"
|
||||
>
|
||||
<slot name="subheadline">{{ subheadline }}</slot>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="ctaLabel || secondaryLabel || $slots.actions"
|
||||
:class="['mt-2 flex flex-wrap items-center gap-3', layout.actions]"
|
||||
>
|
||||
<slot name="actions">
|
||||
<a v-if="ctaLabel && ctaHref" :href="ctaHref" class="inline-flex">
|
||||
<Button :variant="primaryVariant" size="lg">
|
||||
<slot name="cta">{{ ctaLabel }}</slot>
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
v-else-if="ctaLabel"
|
||||
:variant="primaryVariant"
|
||||
size="lg"
|
||||
@click="$emit('cta')"
|
||||
>
|
||||
<slot name="cta">{{ ctaLabel }}</slot>
|
||||
</Button>
|
||||
|
||||
<template v-if="secondaryLabel">
|
||||
<!-- Brand tone needs a cream-outlined pill; Button's ghost/secondary
|
||||
render dark-on-dark on the brand green. -->
|
||||
<component
|
||||
:is="secondaryHref ? 'a' : 'button'"
|
||||
v-if="isBrandTone"
|
||||
:type="secondaryHref ? undefined : 'button'"
|
||||
:href="secondaryHref || undefined"
|
||||
class="inline-flex items-center justify-center rounded-pill border border-cream/50 px-[34px] py-[17px] text-[16px] font-semibold tracking-label text-cream transition-colors duration-base hover:border-cream hover:bg-cream-wash-strong"
|
||||
@click="secondaryHref ? null : $emit('secondary')"
|
||||
>{{ secondaryLabel }}</component>
|
||||
<a v-else-if="secondaryHref" :href="secondaryHref" class="inline-flex">
|
||||
<Button variant="secondary" size="lg">{{ secondaryLabel }}</Button>
|
||||
</a>
|
||||
<Button
|
||||
v-else
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="$emit('secondary')"
|
||||
>{{ secondaryLabel }}</Button>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div
|
||||
v-if="image || $slots.media"
|
||||
:class="['relative flex items-center justify-center', layout.media]"
|
||||
>
|
||||
<!-- Decorative disc behind product -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'w-[85%] aspect-square rounded-full blur-2xl',
|
||||
tone.glow,
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'w-[70%] aspect-square rounded-full',
|
||||
tone.disc,
|
||||
]"
|
||||
/>
|
||||
<div :class="['relative', layout.mediaSize]">
|
||||
<Badge
|
||||
v-if="badge"
|
||||
:variant="badgeVariant"
|
||||
class="absolute -top-3 -left-3 z-[1] shadow-sm"
|
||||
>{{ badge }}</Badge>
|
||||
<slot name="media">
|
||||
<img
|
||||
:src="image"
|
||||
:alt="imageAlt || headline"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="relative w-full h-auto object-contain drop-shadow-[0_20px_40px_rgba(28,58,40,0.18)]"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -30,6 +30,7 @@ const de = {
|
||||
'ds.nav.inputs': 'Eingabefelder',
|
||||
'ds.nav.cards': 'Karten',
|
||||
'ds.nav.products': 'Produktkarten',
|
||||
'ds.nav.hero': 'Hero',
|
||||
'ds.nav.navbar': 'Navigation',
|
||||
'ds.nav.language': 'Sprachwahl',
|
||||
|
||||
@@ -176,6 +177,18 @@ const de = {
|
||||
'ds.product.outOfStock': 'Nicht verfügbar',
|
||||
'ds.product.added': 'Hinzugefügt',
|
||||
|
||||
// Hero
|
||||
'ds.hero.title': 'Hero',
|
||||
'ds.hero.description': 'Ganzflächiger Bühnenbereich für die Startseite und Kategorie-Auftakte. Eyebrow, Display-Headline mit optionaler Kursiv-Hervorhebung, Unterzeile, Produkt-Cutout auf dekorativer Scheibe und ein Primary/Secondary-CTA-Paar. Responsiv von 390 bis 1280 px, drei Flächentöne, zwei Layouts.',
|
||||
'ds.hero.variant.label': 'Layout',
|
||||
'ds.hero.variant.split': 'Split',
|
||||
'ds.hero.variant.centered': 'Zentriert',
|
||||
'ds.hero.eyebrow': 'Neu im Shop',
|
||||
'ds.hero.headline.a': 'Kaiser-Natron',
|
||||
'ds.hero.headline.em': 'für alles',
|
||||
'ds.hero.headline.b': 'was glänzen soll.',
|
||||
'ds.hero.sub': 'Reinigt, backt und neutralisiert Gerüche. Die 250-g-Großpackung für den Haushalt, der sich auf das Original verlässt.',
|
||||
|
||||
// Navbar section
|
||||
'ds.navbar.title': 'Navigation',
|
||||
'ds.navbar.description': 'Logo + Nav + Warenkorb, in drei Flächentönen. Auf Mobilgeräten behält die obere Leiste das Logo, Menü + Warenkorb wechseln in ein ergonomisches bodennahes rechtes Floating-Cluster. Das Menü öffnet ein vollflächiges markengrünes Overlay.',
|
||||
@@ -244,6 +257,7 @@ const en = {
|
||||
'ds.nav.inputs': 'Inputs',
|
||||
'ds.nav.cards': 'Cards',
|
||||
'ds.nav.products': 'Product cards',
|
||||
'ds.nav.hero': 'Hero',
|
||||
'ds.nav.navbar': 'Navbar',
|
||||
'ds.nav.language': 'Language',
|
||||
|
||||
@@ -377,6 +391,17 @@ const en = {
|
||||
'ds.product.outOfStock': 'Out of stock',
|
||||
'ds.product.added': 'Added',
|
||||
|
||||
'ds.hero.title': 'Hero',
|
||||
'ds.hero.description': 'Full-bleed stage for the home page and category tops. Eyebrow, display headline with optional italic emphasis, subheadline, product cutout on a decorative disc, and a primary/secondary CTA pair. Responsive from 390 to 1280px, three surface tones, two layouts.',
|
||||
'ds.hero.variant.label': 'Layout',
|
||||
'ds.hero.variant.split': 'Split',
|
||||
'ds.hero.variant.centered': 'Centered',
|
||||
'ds.hero.eyebrow': 'New in shop',
|
||||
'ds.hero.headline.a': 'Kaiser Natron',
|
||||
'ds.hero.headline.em': 'for everything',
|
||||
'ds.hero.headline.b': 'that should shine.',
|
||||
'ds.hero.sub': 'Cleans, bakes, and neutralises odours. The 250 g large pack for the household that trusts the original.',
|
||||
|
||||
'ds.navbar.title': 'Navbar',
|
||||
'ds.navbar.description': 'Logo + nav + cart, in three surface tones. On mobile, the top bar keeps the logo and the menu + cart move to an ergonomic bottom-right floating cluster. The menu opens a full-screen brand-green overlay.',
|
||||
'ds.navbar.tone': 'Navbar tone',
|
||||
|
||||
@@ -32,6 +32,7 @@ const groups = computed(() => [
|
||||
{ name: 'ds-inputs', label: t('ds.nav.inputs') },
|
||||
{ name: 'ds-cards', label: t('ds.nav.cards') },
|
||||
{ name: 'ds-products', label: t('ds.nav.products') },
|
||||
{ name: 'ds-hero', label: t('ds.nav.hero') },
|
||||
{ name: 'ds-navbar', label: t('ds.nav.navbar') },
|
||||
{ name: 'ds-language', label: t('ds.nav.language') },
|
||||
],
|
||||
|
||||
103
src/pages/design/HeroSection.vue
Normal file
103
src/pages/design/HeroSection.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import SectionShell from './SectionShell.vue'
|
||||
import DevicePreview from '@/design-system/components/DevicePreview.vue'
|
||||
import { useI18n } from '@/i18n/index.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const variants = computed(() => [
|
||||
{ id: 'split', label: t('ds.hero.variant.split') },
|
||||
{ id: 'centered', label: t('ds.hero.variant.centered') },
|
||||
])
|
||||
|
||||
const tones = computed(() => [
|
||||
{ id: 'cream', label: t('ds.navbar.tone.cream'), swatch: 'var(--color-cream)' },
|
||||
{ id: 'paper', label: t('ds.navbar.tone.paper'), swatch: '#ffffff' },
|
||||
{ id: 'brand', label: t('ds.navbar.tone.brand'), swatch: 'var(--color-brand)' },
|
||||
])
|
||||
|
||||
const variant = ref('split')
|
||||
const tone = ref('cream')
|
||||
const src = computed(
|
||||
() => `/design/preview/hero?variant=${variant.value}&tone=${tone.value}`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionShell
|
||||
:eyebrow="t('ds.eyebrow.components')"
|
||||
:title="t('ds.hero.title')"
|
||||
:description="t('ds.hero.description')"
|
||||
wide
|
||||
>
|
||||
<section>
|
||||
<DevicePreview :src="src" initial="desktop" :height="760">
|
||||
<template #controls>
|
||||
<div
|
||||
role="tablist"
|
||||
:aria-label="t('ds.hero.variant.label')"
|
||||
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
|
||||
>
|
||||
<button
|
||||
v-for="v in variants"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="variant === v.id"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
|
||||
variant === v.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
|
||||
]"
|
||||
@click="variant = v.id"
|
||||
>{{ v.label }}</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
:aria-label="t('ds.navbar.tone')"
|
||||
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
|
||||
>
|
||||
<button
|
||||
v-for="v in tones"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tone === v.id"
|
||||
:class="[
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
|
||||
tone === v.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
|
||||
]"
|
||||
@click="tone = v.id"
|
||||
>
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full border border-line-strong"
|
||||
:style="{ backgroundColor: v.swatch }"
|
||||
/>
|
||||
{{ v.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DevicePreview>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
|
||||
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
|
||||
<pre class="whitespace-pre-wrap"><Hero
|
||||
variant="split"
|
||||
tone="cream"
|
||||
eyebrow="Neu"
|
||||
headline="Kaiser-Natron Pulver"
|
||||
subheadline="Reinigt. Backt. Neutralisiert."
|
||||
image="/products/cutouts/…-removebg-preview.png"
|
||||
image-alt="Kaiser-Natron Pulver 250 g"
|
||||
badge="Bestseller"
|
||||
cta-label="In den Warenkorb"
|
||||
secondary-label="Mehr erfahren"
|
||||
@cta="addToCart(sku)"
|
||||
@secondary="router.push('/anwendungen')"
|
||||
/></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
44
src/pages/design/previews/HeroPreview.vue
Normal file
44
src/pages/design/previews/HeroPreview.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Navbar from '@/design-system/components/Navbar.vue'
|
||||
import Hero from '@/design-system/components/Hero.vue'
|
||||
import { useI18n } from '@/i18n/index.js'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const variant = computed(() =>
|
||||
['split', 'centered'].includes(route.query.variant) ? route.query.variant : 'split',
|
||||
)
|
||||
const tone = computed(() =>
|
||||
['cream', 'paper', 'brand'].includes(route.query.tone) ? route.query.tone : 'cream',
|
||||
)
|
||||
|
||||
const navVariant = computed(() => (tone.value === 'brand' ? 'brand' : tone.value))
|
||||
const imgPulver250 =
|
||||
'/products/cutouts/kaiser-natron-pulver-250-g-gro%C3%9Fpackung-removebg-preview.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['min-h-screen', tone === 'brand' ? 'bg-brand' : 'bg-surface']">
|
||||
<Navbar :variant="navVariant" :cart-count="0" />
|
||||
<Hero
|
||||
:variant="variant"
|
||||
:tone="tone"
|
||||
:eyebrow="t('ds.hero.eyebrow')"
|
||||
:subheadline="t('ds.hero.sub')"
|
||||
:image="imgPulver250"
|
||||
image-alt="Kaiser-Natron Pulver 250 g Großpackung"
|
||||
:badge="t('ds.badges.featured')"
|
||||
:cta-label="t('ds.buttons.addToCart')"
|
||||
:secondary-label="t('ds.buttons.learnMore')"
|
||||
>
|
||||
<template #headline>
|
||||
{{ t('ds.hero.headline.a') }}
|
||||
<em class="italic font-light text-brand-soft">{{ t('ds.hero.headline.em') }}</em>
|
||||
{{ t('ds.hero.headline.b') }}
|
||||
</template>
|
||||
</Hero>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,12 @@ const routes = [
|
||||
component: () => import('@/pages/design/previews/NavbarPreview.vue'),
|
||||
meta: { layout: 'none', preview: true },
|
||||
},
|
||||
{
|
||||
path: '/design/preview/hero',
|
||||
name: 'ds-preview-hero',
|
||||
component: () => import('@/pages/design/previews/HeroPreview.vue'),
|
||||
meta: { layout: 'none', preview: true },
|
||||
},
|
||||
{
|
||||
path: '/design',
|
||||
component: () => import('@/pages/design/DesignLayout.vue'),
|
||||
@@ -29,6 +35,7 @@ const routes = [
|
||||
{ path: 'inputs', name: 'ds-inputs', component: () => import('@/pages/design/InputsSection.vue') },
|
||||
{ path: 'cards', name: 'ds-cards', component: () => import('@/pages/design/CardsSection.vue') },
|
||||
{ path: 'products', name: 'ds-products', component: () => import('@/pages/design/ProductsSection.vue') },
|
||||
{ path: 'hero', name: 'ds-hero', component: () => import('@/pages/design/HeroSection.vue') },
|
||||
{ path: 'navbar', name: 'ds-navbar', component: () => import('@/pages/design/NavbarSection.vue') },
|
||||
{ path: 'language', name: 'ds-language', component: () => import('@/pages/design/LanguageSwitcherSection.vue') },
|
||||
{ path: 'icons', name: 'ds-icons', component: () => import('@/pages/design/IconsSection.vue') },
|
||||
|
||||
Reference in New Issue
Block a user