feat(search): product search component + navbar triggers

Adds a client-side fuzzy product search to the design system:

- `src/api/products.js`: fixture catalogue (22 products across the Kaiser
  Natron, Holste, Gazelle, Grüne Tante and Linda ranges) plus a scored,
  diacritic-folded search (ß→ss, ä→ae, NFKD) with weighted fields
  (title 5, brand/keywords 3, size/category 2, id 1) and prefix bonus.
- `Search.vue`: tone-driven (brand/paper/cream) full-screen overlay on
  mobile, centered modal on md+. Auto-focus, arrow-key nav, Enter/Esc,
  suggested-products empty state, keyboard-hint footer on desktop, safe-
  area aware, scroll-locks the document while open.
- Navbar now hosts two triggers: a pill-shaped "Search products"
  lookalike (desktop, in the same LanguageSwitcher-style container) and
  a green/accent shadow-sm floating button bottom-left on mobile. Both
  open the same overlay.
- HomePage feeds the products list into Navbar.
- Design-system showcase (`/design/search`) with live demo + canned
  result preview + usage snippet. Sidebar + mobile bottom-nav entries
  and DE/EN i18n added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-21 13:40:21 +01:00
parent af4e03a155
commit 7b44260fbc
38 changed files with 64 additions and 315 deletions

View File

@@ -1,260 +0,0 @@
// Product catalog fixture. Drives the UI until the backend (Python/MySQL)
// fills in the real `src/api/` surface. Same shape — id/sku, title, brand,
// size, price, image, category, keywords, href — so consumers don't have
// to change when the real API lands.
//
// Keywords are lowercase, unaccented tokens the search layer uses to
// broaden matches (a user typing "backen" should still find "Natron Pulver",
// a user typing "kalk" should find "Entkalker", etc.).
const IMG = '/products/'
export const products = [
// --- Kaiser-Natron line -------------------------------------------------
{
sku: 'kn-pulver-50',
title: 'Kaiser-Natron® Pulver',
brand: 'Kaiser Natron',
size: '50 g Beutel',
price: 1.49,
image: `${IMG}kaiser-natron-pulver-50-g-beutel.jpg`,
category: 'Haushalt',
keywords: ['natron', 'pulver', 'backen', 'neutralisieren', 'reinigen'],
href: '/shop/kaiser-natron-pulver-50-g-beutel',
},
{
sku: 'kn-pulver-250',
title: 'Kaiser-Natron® Pulver',
brand: 'Kaiser Natron',
size: '250 g Großpackung',
price: 4.49,
image: '/products/cutouts/kaiser-natron-pulver-250-g-gro%C3%9Fpackung-removebg-preview.png',
category: 'Haushalt',
keywords: ['natron', 'pulver', 'großpackung', 'backen', 'neutralisieren'],
href: '/shop/kaiser-natron-pulver-250-g-grosspackung',
},
{
sku: 'kn-pulver-3490',
title: 'Kaiser-Natron® Pulver',
brand: 'Kaiser Natron',
size: '3.490 g Eimer',
price: 34.9,
image: `${IMG}kaiser-natron-pulver-3.490-g-eimer.jpg`,
category: 'Haushalt',
keywords: ['natron', 'pulver', 'eimer', 'gastro', 'großverbraucher'],
href: '/shop/kaiser-natron-pulver-3490-g-eimer',
},
{
sku: 'kn-tabletten-100',
title: 'Kaiser-Natron® Tabletten',
brand: 'Kaiser Natron',
size: '100 g Dose',
price: 2.99,
image: `${IMG}kaiser-natron-tabletten-100-g-dose.jpg`,
category: 'Gesundheit',
keywords: ['natron', 'tabletten', 'dose', 'magen', 'sodbrennen'],
href: '/shop/kaiser-natron-tabletten-100-g-dose',
},
{
sku: 'kn-allzweck-reiniger-750',
title: 'Kaiser-Natron® Allzweck-Reiniger',
brand: 'Kaiser Natron',
size: '750 ml',
price: 5.49,
image: `${IMG}kaiser-natron-allzweck-reiniger-750-ml.jpg`,
category: 'Reiniger',
keywords: ['reiniger', 'allzweck', 'küche', 'bad', 'haushaltsreiniger'],
href: '/shop/kaiser-natron-allzweck-reiniger-750-ml',
},
{
sku: 'kn-allzweck-spray-500',
title: 'Kaiser-Natron® Allzweck-Spray',
brand: 'Kaiser Natron',
size: '500 ml',
price: 4.99,
image: `${IMG}kaiser-natron-allzweck-spray-500-ml.jpg`,
category: 'Reiniger',
keywords: ['spray', 'allzweck', 'reiniger', 'küche', 'bad'],
href: '/shop/kaiser-natron-allzweck-spray-500-ml',
},
{
sku: 'kn-bad-500',
title: 'Kaiser-Natron® Bad',
brand: 'Kaiser Natron',
size: '500 g',
price: 3.99,
image: `${IMG}kaiser-natron-bad-500-g.jpg`,
category: 'Pflege',
keywords: ['bad', 'badezusatz', 'wellness', 'entspannung', 'haut'],
href: '/shop/kaiser-natron-bad-500-g',
},
{
sku: 'kn-fussbad-500',
title: 'Kaiser-Natron® Fußbad',
brand: 'Kaiser Natron',
size: '500 g',
price: 3.99,
image: `${IMG}kaiser-natron-fussbad-500-g.jpg`,
category: 'Pflege',
keywords: ['fußbad', 'füße', 'pflege', 'wellness', 'entspannung'],
href: '/shop/kaiser-natron-fussbad-500-g',
},
{
sku: 'kn-daunenwasch-250',
title: 'Kaiser-Natron® Daunenwasch',
brand: 'Kaiser Natron',
size: '250 ml',
price: 6.49,
image: `${IMG}kaiser-natron-daunenwasch-250-ml.jpg`,
category: 'Wäsche',
keywords: ['daunen', 'waschen', 'wäsche', 'federbett', 'jacke'],
href: '/shop/kaiser-natron-daunenwasch-250-ml',
},
{
sku: 'kn-sport-profi-250',
title: 'Kaiser-Natron® Sport-Profi',
brand: 'Kaiser Natron',
size: '250 ml',
price: 5.99,
image: `${IMG}kaiser-natron-sport-profi-250-ml.jpg`,
category: 'Wäsche',
keywords: ['sport', 'funktion', 'geruch', 'wäsche', 'schweiß'],
href: '/shop/kaiser-natron-sport-profi-250-ml',
},
{
sku: 'kn-spuelmittel-500',
title: 'Kaiser-Natron® Spülmittel',
brand: 'Kaiser Natron',
size: '500 ml',
price: 3.49,
image: `${IMG}kaiser-natron-spuelmittel-500-ml.jpg`,
category: 'Reiniger',
keywords: ['spülmittel', 'geschirr', 'küche', 'reinigen'],
href: '/shop/kaiser-natron-spuelmittel-500-ml',
},
// --- Holste line --------------------------------------------------------
{
sku: 'holste-handwaschpaste-500',
title: 'Holste Handwaschpaste',
brand: 'Holste',
size: '500 ml',
price: 6.99,
image: `${IMG}holste-handwaschpaste-500-ml.jpg`,
category: 'Reiniger',
keywords: ['handwaschpaste', 'werkstatt', 'öl', 'fett', 'grobreiniger'],
href: '/shop/holste-handwaschpaste-500-ml',
},
{
sku: 'holste-kalk-urinstein-750',
title: 'Holste Kalk- und Urinsteinlöser',
brand: 'Holste',
size: '750 ml',
price: 7.49,
image: `${IMG}holste-kalk--und-urinsteinloeser-750-ml.jpg`,
category: 'Reiniger',
keywords: ['kalk', 'urinstein', 'wc', 'toilette', 'entkalker'],
href: '/shop/holste-kalk-und-urinsteinloeser-750-ml',
},
{
sku: 'holste-reisstaerke-250',
title: 'Holste Reisstärke',
brand: 'Holste',
size: '250 g Faltschachtel',
price: 3.99,
image: `${IMG}holste-reisstaerke-250-g-faltschachtel.jpg`,
category: 'Wäsche',
keywords: ['stärke', 'reisstärke', 'bügeln', 'wäsche', 'hemd'],
href: '/shop/holste-reisstaerke-250-g-faltschachtel',
},
{
sku: 'holste-schmierseife-1l',
title: 'Holste Schmierseife flüssig',
brand: 'Holste',
size: '1 l Flasche',
price: 4.99,
image: `${IMG}holste-schmierseife-fluessig-1-l-flasche.jpg`,
category: 'Reiniger',
keywords: ['schmierseife', 'seife', 'boden', 'holz', 'flüssig'],
href: '/shop/holste-schmierseife-fluessig-1-l-flasche',
},
{
sku: 'holste-wasch-soda-500',
title: 'Holste Wasch-Soda',
brand: 'Holste',
size: '500 g Beutel',
price: 2.49,
image: `${IMG}holste-wasch-soda-500-g-beutel.jpg`,
category: 'Wäsche',
keywords: ['soda', 'wasch-soda', 'wäsche', 'reinigen', 'fett'],
href: '/shop/holste-wasch-soda-500-g-beutel',
},
{
sku: 'holste-zitronensaeure-500',
title: 'Holste Zitronensäure-Entkalker',
brand: 'Holste',
size: '500 ml flüssig',
price: 4.49,
image: `${IMG}holste-zitronensaeure-entkalker-fluessig-500-ml.jpg`,
category: 'Reiniger',
keywords: ['zitronensäure', 'entkalker', 'kalk', 'wasserkocher', 'bio'],
href: '/shop/holste-zitronensaeure-entkalker-fluessig-500-ml',
},
// --- Andere Marken ------------------------------------------------------
{
sku: 'gazelle-waeschestaerke-1000',
title: 'Gazelle Wäschestärke',
brand: 'Gazelle',
size: '1000 ml Flasche',
price: 5.99,
image: `${IMG}gazelle-waeschestaerke-1000-ml-flasche.jpg`,
category: 'Wäsche',
keywords: ['stärke', 'wäschestärke', 'bügeln', 'hemd', 'gazelle'],
href: '/shop/gazelle-waeschestaerke-1000-ml-flasche',
},
{
sku: 'gruene-tante-quarz-500',
title: 'Grüne Tante mit Quarzmehl',
brand: 'Grüne Tante',
size: '500 ml Dose',
price: 8.49,
image: `${IMG}gruene-tante-mit-quarzmehl-500-ml-dose.jpg`,
category: 'Reiniger',
keywords: ['grüne tante', 'quarzmehl', 'handreiniger', 'werkstatt', 'grob'],
href: '/shop/gruene-tante-mit-quarzmehl-500-ml-dose',
},
{
sku: 'linda-fleckenweg-200',
title: 'Linda Fleckenweg',
brand: 'Linda',
size: '200 ml Tube',
price: 3.49,
image: `${IMG}linda-fleckenweg-200-ml-tube.jpg`,
category: 'Wäsche',
keywords: ['fleck', 'fleckenentferner', 'wäsche', 'linda', 'tube'],
href: '/shop/linda-fleckenweg-200-ml-tube',
},
{
sku: 'linda-handreiniger-200',
title: 'Linda Handreiniger der Kraftvolle',
brand: 'Linda',
size: '200 g Tube',
price: 4.49,
image: `${IMG}linda-handreiniger-der-kraftvolle-200-g-tube.jpg`,
category: 'Reiniger',
keywords: ['handreiniger', 'hände', 'werkstatt', 'öl', 'fett', 'linda'],
href: '/shop/linda-handreiniger-der-kraftvolle-200-g-tube',
},
{
sku: 'linda-neutral-375',
title: 'Linda Neutral',
brand: 'Linda',
size: '375 ml Dose',
price: 5.49,
image: `${IMG}linda-neutral-375-ml-dose.jpg`,
category: 'Pflege',
keywords: ['neutral', 'pflege', 'hände', 'linda'],
href: '/shop/linda-neutral-375-ml-dose',
},
]

View File

@@ -1,4 +1,4 @@
// Barrel for the API boundary. Swap these imports for real backend calls
// when the Python/MySQL side lands — callers keep the same import path.
export { products } from './fixtures/products.js'
export { products, searchProducts, formatPrice } from './products.js'

View File

@@ -57,16 +57,19 @@ const tones = {
bar: 'bg-brand text-cream border-cream-line',
link: 'text-cream hover:text-accent',
logo: 'text-cream',
searchTrigger: 'border border-cream-line bg-cream-wash text-cream/80 hover:text-accent',
},
cream: {
bar: 'bg-cream text-brand border-line',
link: 'text-brand hover:text-brand-hover',
logo: 'text-brand',
searchTrigger: 'border border-line-strong bg-paper text-muted hover:text-brand',
},
paper: {
bar: 'bg-paper text-ink border-line',
link: 'text-ink hover:text-brand',
logo: 'text-brand',
searchTrigger: 'border border-line bg-paper text-muted hover:text-brand',
},
}
@@ -137,11 +140,15 @@ onBeforeUnmount(() => {
<div class="hidden md:flex items-center gap-4">
<button
type="button"
:class="[tone.link, 'inline-flex items-center justify-center w-11 h-11 rounded-full transition-colors duration-base ease-out']"
:aria-label="t('ds.search.open')"
:class="[
'inline-flex items-center gap-2 pl-3 pr-4 py-2 rounded-pill text-[13px] font-medium tracking-label transition-colors duration-base',
tone.searchTrigger,
]"
:aria-label="t('search.open')"
@click="searchOpen = true"
>
<Icon name="search" :size="20" />
<Icon name="search" :size="16" />
<span>{{ t('search.placeholder') }}</span>
</button>
<LanguageSwitcher :tone="variant" />
<button
@@ -168,8 +175,8 @@ onBeforeUnmount(() => {
>
<button
type="button"
class="w-14 h-14 rounded-full bg-cream text-brand shadow-lg flex items-center justify-center transition-transform duration-base ease-out hover:-translate-y-0.5 active:translate-y-0"
:aria-label="t('ds.search.open')"
class="w-14 h-14 rounded-full bg-brand text-accent shadow-sm flex items-center justify-center transition-transform duration-base ease-out hover:-translate-y-0.5 active:translate-y-0"
:aria-label="t('search.open')"
@click="searchOpen = true"
>
<Icon name="search" :size="22" :stroke-width="2" />
@@ -274,7 +281,8 @@ onBeforeUnmount(() => {
<Search
v-model="searchOpen"
:products="products"
@select="(p) => $emit('select', p)"
:tone="variant"
@select="(p) => $emit('search', p)"
/>
</header>
</template>

View File

@@ -112,7 +112,7 @@ function scoreProduct(p, tokens) {
{ val: normalize(p.size), weight: 2 },
{ val: normalize(p.category), weight: 2 },
{ val: (p.keywords || []).map(normalize).join(' '), weight: 3 },
{ val: normalize(p.sku), weight: 1 },
{ val: normalize(p.id), weight: 1 },
]
let score = 0
for (const tok of tokens) {
@@ -230,7 +230,7 @@ function onRowClick(i, item, e) {
class="fixed inset-0 z-[60] font-sans"
role="dialog"
aria-modal="true"
:aria-label="t('ds.search.label')"
:aria-label="t('search.label')"
@keydown="onKeydown"
>
<!-- Backdrop (md+). Tap to dismiss. -->
@@ -278,8 +278,8 @@ function onRowClick(i, item, e) {
autocapitalize="none"
spellcheck="false"
enterkeyhint="search"
:placeholder="placeholder || t('ds.search.placeholder')"
:aria-label="t('ds.search.label')"
:placeholder="placeholder || t('search.placeholder')"
:aria-label="t('search.label')"
:class="[
'flex-1 min-w-0 bg-transparent border-0 outline-none text-[17px] md:text-[15px]',
toneClasses.input,
@@ -304,7 +304,7 @@ function onRowClick(i, item, e) {
class="flex-1 overflow-y-auto py-2"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 0.5rem);"
role="listbox"
:aria-label="t('ds.search.results')"
:aria-label="t('search.results')"
>
<p
v-if="!query.trim() && results.length"
@@ -313,19 +313,19 @@ function onRowClick(i, item, e) {
toneClasses.eyebrowCream ? 'text-cream/70' : '',
]"
>
{{ t('ds.search.suggested') }}
{{ t('search.suggested') }}
</p>
<p
v-if="!results.length"
:class="['px-5 md:px-4 py-10 text-center text-sm', toneClasses.noResults]"
>
{{ t('ds.search.noResults') }}
{{ t('search.noResults') }}
</p>
<a
v-for="(p, i) in results"
:key="p.sku"
:key="p.id"
:href="p.href || '#'"
role="option"
:aria-selected="i === activeIndex"
@@ -375,15 +375,15 @@ function onRowClick(i, item, e) {
<span class="inline-flex items-center gap-1.5">
<kbd :class="['px-1.5 py-0.5 rounded-sm border font-mono text-[11px]', toneClasses.kbd]"></kbd>
<kbd :class="['px-1.5 py-0.5 rounded-sm border font-mono text-[11px]', toneClasses.kbd]"></kbd>
{{ t('ds.search.hint.navigate') }}
{{ t('search.hint.navigate') }}
</span>
<span class="inline-flex items-center gap-1.5">
<kbd :class="['px-1.5 py-0.5 rounded-sm border font-mono text-[11px]', toneClasses.kbd]"></kbd>
{{ t('ds.search.hint.select') }}
{{ t('search.hint.select') }}
</span>
<span class="inline-flex items-center gap-1.5">
<kbd :class="['px-1.5 py-0.5 rounded-sm border font-mono text-[11px]', toneClasses.kbd]">esc</kbd>
{{ t('ds.search.hint.close') }}
{{ t('search.hint.close') }}
</span>
</div>
</div>

View File

@@ -21,6 +21,7 @@ const de = {
'search.noResults': 'Keine Produkte gefunden',
'search.noResultsHint': 'Probiere eine andere Schreibweise oder einen allgemeineren Begriff.',
'search.emptyHint': 'Tippe, um Produkte zu finden z. B. „Natron Pulver" oder „Entkalker".',
'search.suggested': 'Vorschläge',
'search.hint.navigate': 'Navigieren',
'search.hint.select': 'Auswählen',
'search.hint.close': 'Schließen',
@@ -285,6 +286,7 @@ const en = {
'search.noResults': 'No products found',
'search.noResultsHint': 'Try a different spelling or a broader term.',
'search.emptyHint': 'Type to find products — e.g. "Natron powder" or "descaler".',
'search.suggested': 'Suggested',
'search.hint.navigate': 'Navigate',
'search.hint.select': 'Select',
'search.hint.close': 'Close',