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:
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user