refactor: rename shop to storefront for consistency
Rename all "shop" directories and references to "storefront" to match the API and route naming convention already in use. Renamed directories: - app/templates/shop/ → app/templates/storefront/ - static/shop/ → static/storefront/ - app/templates/shared/macros/shop/ → .../macros/storefront/ - docs/frontend/shop/ → docs/frontend/storefront/ Renamed files: - shop.css → storefront.css - shop-layout.js → storefront-layout.js Updated references in: - app/routes/storefront_pages.py (21 template references) - app/modules/cms/routes/pages/vendor.py - app/templates/storefront/base.html (static paths) - All storefront templates (extends/includes) - docs/architecture/frontend-structure.md This aligns the template/static naming with: - Route file: storefront_pages.py - API directory: app/api/v1/storefront/ - Module routes: */routes/api/storefront.py - URL paths: /storefront/* Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
627
app/templates/shared/macros/storefront/search-bar.html
Normal file
627
app/templates/shared/macros/storefront/search-bar.html
Normal file
@@ -0,0 +1,627 @@
|
||||
{#
|
||||
Search Bar Components
|
||||
=====================
|
||||
Product search with autocomplete and suggestions for shop pages.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/shop/search-bar.html' import search_bar, search_autocomplete, mobile_search %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Search Bar
|
||||
==========
|
||||
Basic search input with icon and optional button.
|
||||
|
||||
Parameters:
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- method: Form method (default: 'get')
|
||||
- name: Input name (default: 'q')
|
||||
- value: Initial value (default: '')
|
||||
- show_button: Show search button (default: false)
|
||||
- button_label: Button text (default: 'Search')
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- variant: 'default' | 'filled' | 'minimal' (default: 'default')
|
||||
|
||||
Usage:
|
||||
{{ search_bar(placeholder='Search for products...') }}
|
||||
{{ search_bar(show_button=true, size='lg') }}
|
||||
#}
|
||||
{% macro search_bar(
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
method='get',
|
||||
name='q',
|
||||
value='',
|
||||
show_button=false,
|
||||
button_label='Search',
|
||||
size='md',
|
||||
variant='default'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'input': 'py-1.5 pl-8 pr-3 text-sm', 'icon': 'w-4 h-4 left-2.5', 'button': 'px-3 py-1.5 text-sm'},
|
||||
'md': {'input': 'py-2.5 pl-10 pr-4 text-sm', 'icon': 'w-5 h-5 left-3', 'button': 'px-4 py-2.5 text-sm'},
|
||||
'lg': {'input': 'py-3 pl-12 pr-4 text-base', 'icon': 'w-6 h-6 left-3.5', 'button': 'px-5 py-3 text-base'}
|
||||
} %}
|
||||
{% set variants = {
|
||||
'default': 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
||||
'filled': 'bg-gray-100 dark:bg-gray-700 border border-transparent focus:bg-white dark:focus:bg-gray-800 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
||||
'minimal': 'bg-transparent border-b border-gray-300 dark:border-gray-600 rounded-none focus:border-purple-500 dark:focus:border-purple-400'
|
||||
} %}
|
||||
<form action="{{ action }}" method="{{ method }}" class="relative flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
||||
<span x-html="$icon('search', 'w-full h-full')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="{{ name }}"
|
||||
value="{{ value }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
class="w-full {{ sizes[size].input }} {{ variants[variant] }} {{ 'rounded-lg' if variant != 'minimal' else '' }} text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors"
|
||||
>
|
||||
</div>
|
||||
{% if show_button %}
|
||||
<button
|
||||
type="submit"
|
||||
class="{{ sizes[size].button }} bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{{ button_label }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Search Autocomplete
|
||||
===================
|
||||
Search input with dropdown suggestions and autocomplete.
|
||||
|
||||
Parameters:
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
||||
- min_chars: Minimum characters to trigger search (default: 2)
|
||||
- debounce: Debounce delay in ms (default: 300)
|
||||
- show_recent: Show recent searches (default: true)
|
||||
- show_popular: Show popular searches (default: true)
|
||||
- max_suggestions: Maximum suggestions to show (default: 5)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
|
||||
Usage:
|
||||
{{ search_autocomplete(search_endpoint='/api/products/search') }}
|
||||
#}
|
||||
{% macro search_autocomplete(
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
search_endpoint='/api/search/suggest',
|
||||
min_chars=2,
|
||||
debounce=300,
|
||||
show_recent=true,
|
||||
show_popular=true,
|
||||
max_suggestions=5,
|
||||
size='md'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {'input': 'py-1.5 pl-8 pr-8 text-sm', 'icon': 'w-4 h-4', 'dropdown': 'mt-1'},
|
||||
'md': {'input': 'py-2.5 pl-10 pr-10 text-sm', 'icon': 'w-5 h-5', 'dropdown': 'mt-2'},
|
||||
'lg': {'input': 'py-3 pl-12 pr-12 text-base', 'icon': 'w-6 h-6', 'dropdown': 'mt-2'}
|
||||
} %}
|
||||
<div
|
||||
x-data="{
|
||||
query: '',
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
suggestions: [],
|
||||
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
||||
popularSearches: ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books'],
|
||||
selectedIndex: -1,
|
||||
|
||||
async search() {
|
||||
if (this.query.length < {{ min_chars }}) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
||||
const data = await response.json();
|
||||
this.suggestions = (data.suggestions || data.results || data).slice(0, {{ max_suggestions }});
|
||||
} catch (e) {
|
||||
this.suggestions = [];
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
selectSuggestion(suggestion) {
|
||||
this.query = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
||||
this.saveRecent(this.query);
|
||||
this.$refs.form.submit();
|
||||
},
|
||||
|
||||
saveRecent(term) {
|
||||
let recent = this.recentSearches.filter(s => s !== term);
|
||||
recent.unshift(term);
|
||||
recent = recent.slice(0, 5);
|
||||
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
||||
this.recentSearches = recent;
|
||||
},
|
||||
|
||||
clearRecent() {
|
||||
localStorage.removeItem('recentSearches');
|
||||
this.recentSearches = [];
|
||||
},
|
||||
|
||||
handleKeydown(e) {
|
||||
const items = this.getItems();
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||
} else if (e.key === 'Enter' && this.selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
this.selectSuggestion(items[this.selectedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
|
||||
getItems() {
|
||||
if (this.query.length >= {{ min_chars }}) return this.suggestions;
|
||||
return [...this.recentSearches, ...this.popularSearches.filter(p => !this.recentSearches.includes(p))];
|
||||
}
|
||||
}"
|
||||
x-init="$watch('query', () => { selectedIndex = -1; })"
|
||||
class="relative"
|
||||
@click.away="isOpen = false"
|
||||
>
|
||||
<form x-ref="form" action="{{ action }}" method="get" @submit="saveRecent(query)">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
||||
<span x-html="$icon('search', 'w-full h-full')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
x-model="query"
|
||||
@input.debounce.{{ debounce }}ms="search()"
|
||||
@focus="isOpen = true"
|
||||
@keydown="handleKeydown($event)"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
class="w-full {{ sizes[size].input }} bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none transition-colors"
|
||||
>
|
||||
<span
|
||||
x-show="isLoading"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-full h-full animate-spin')"></span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="query.length > 0 && !isLoading"
|
||||
@click="query = ''; suggestions = []; $refs.form.q.focus()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-full h-full')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Dropdown #}
|
||||
<div
|
||||
x-show="isOpen && (query.length >= {{ min_chars }} ? suggestions.length > 0 : (recentSearches.length > 0 || {{ 'true' if show_popular else 'false' }}))"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute left-0 right-0 {{ sizes[size].dropdown }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Search Suggestions #}
|
||||
<template x-if="query.length >= {{ min_chars }}">
|
||||
<ul class="py-2">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('search', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<template x-if="typeof suggestion === 'string'">
|
||||
<span class="text-gray-900 dark:text-white" x-text="suggestion"></span>
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'object'">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white truncate" x-text="suggestion.name"></p>
|
||||
<p x-show="suggestion.category" class="text-xs text-gray-500 dark:text-gray-400" x-text="'in ' + suggestion.category"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
||||
<img :src="suggestion.image" :alt="suggestion.name" class="w-10 h-10 object-cover rounded">
|
||||
</template>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
{# Recent & Popular Searches #}
|
||||
<template x-if="query.length < {{ min_chars }}">
|
||||
<div class="py-2">
|
||||
{% if show_recent %}
|
||||
<template x-if="recentSearches.length > 0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recent Searches</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearRecent()"
|
||||
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>Clear</button>
|
||||
</div>
|
||||
<template x-for="(term, index) in recentSearches" :key="'recent-' + index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('clock', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
{% endif %}
|
||||
|
||||
{% if show_popular %}
|
||||
<div :class="recentSearches.length > 0 && 'border-t border-gray-200 dark:border-gray-700 mt-2 pt-2'">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Popular Searches</span>
|
||||
</div>
|
||||
<template x-for="(term, index) in popularSearches" :key="'popular-' + index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
@mouseenter="selectedIndex = recentSearches.length + index"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedIndex === recentSearches.length + index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
||||
>
|
||||
<span x-html="$icon('trending-up', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
||||
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Mobile Search
|
||||
=============
|
||||
Full-screen search overlay for mobile devices.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable for visibility (default: 'showMobileSearch')
|
||||
- placeholder: Placeholder text (default: 'Search products...')
|
||||
- action: Form action URL (default: '/search')
|
||||
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
||||
|
||||
Usage:
|
||||
{{ mobile_search(show_var='showSearch') }}
|
||||
#}
|
||||
{% macro mobile_search(
|
||||
show_var='showMobileSearch',
|
||||
placeholder='Search products...',
|
||||
action='/search',
|
||||
search_endpoint='/api/search/suggest'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 bg-white dark:bg-gray-900 lg:hidden"
|
||||
style="display: none;"
|
||||
x-data="{
|
||||
query: '',
|
||||
suggestions: [],
|
||||
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
||||
isLoading: false,
|
||||
|
||||
async search() {
|
||||
if (this.query.length < 2) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
||||
const data = await response.json();
|
||||
this.suggestions = (data.suggestions || data.results || data).slice(0, 8);
|
||||
} catch (e) {
|
||||
this.suggestions = [];
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
selectSuggestion(suggestion) {
|
||||
const term = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
||||
this.saveRecent(term);
|
||||
window.location.href = '{{ action }}?q=' + encodeURIComponent(term);
|
||||
},
|
||||
|
||||
saveRecent(term) {
|
||||
let recent = this.recentSearches.filter(s => s !== term);
|
||||
recent.unshift(term);
|
||||
recent = recent.slice(0, 5);
|
||||
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
||||
this.recentSearches = recent;
|
||||
},
|
||||
|
||||
clearRecent() {
|
||||
localStorage.removeItem('recentSearches');
|
||||
this.recentSearches = [];
|
||||
}
|
||||
}"
|
||||
>
|
||||
{# Header #}
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<form action="{{ action }}" method="get" class="flex-1" @submit="saveRecent(query)">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
class="w-full py-2 px-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
x-show="query.length > 0"
|
||||
@click="query = ''; suggestions = []"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{# Loading State #}
|
||||
<div x-show="isLoading" class="flex items-center justify-center py-8">
|
||||
<span x-html="$icon('refresh', 'w-6 h-6 text-gray-400 animate-spin')"></span>
|
||||
</div>
|
||||
|
||||
{# Search Results #}
|
||||
<template x-if="!isLoading && query.length >= 2 && suggestions.length > 0">
|
||||
<ul class="py-2">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
||||
<img :src="suggestion.image" :alt="suggestion.name" class="w-12 h-12 object-cover rounded">
|
||||
</template>
|
||||
<template x-if="typeof suggestion === 'string' || !suggestion.image">
|
||||
<span class="w-12 h-12 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white font-medium truncate" x-text="typeof suggestion === 'string' ? suggestion : suggestion.name"></p>
|
||||
<template x-if="typeof suggestion === 'object'">
|
||||
<p x-show="suggestion.category || suggestion.price" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="suggestion.category"></span>
|
||||
<span x-show="suggestion.category && suggestion.price"> · </span>
|
||||
<span x-show="suggestion.price" x-text="'$' + suggestion.price"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
{# No Results #}
|
||||
<template x-if="!isLoading && query.length >= 2 && suggestions.length === 0">
|
||||
<div class="text-center py-8">
|
||||
<span x-html="$icon('search', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">No results found for "<span x-text="query"></span>"</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Recent Searches #}
|
||||
<template x-if="query.length < 2 && recentSearches.length > 0">
|
||||
<div class="py-4">
|
||||
<div class="flex items-center justify-between px-4 mb-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Recent Searches</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearRecent()"
|
||||
class="text-sm text-purple-600 dark:text-purple-400"
|
||||
>Clear all</button>
|
||||
</div>
|
||||
<template x-for="(term, index) in recentSearches" :key="index">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectSuggestion(term)"
|
||||
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span x-html="$icon('clock', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<span class="flex-1 text-gray-900 dark:text-white" x-text="term"></span>
|
||||
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Search Trigger Button
|
||||
====================
|
||||
Button to open mobile search or focus desktop search.
|
||||
|
||||
Parameters:
|
||||
- show_var: Alpine.js variable to toggle (default: 'showMobileSearch')
|
||||
- sr_label: Screen reader label (default: 'Open search')
|
||||
|
||||
Usage:
|
||||
{{ search_trigger(show_var='showSearch') }}
|
||||
#}
|
||||
{% macro search_trigger(
|
||||
show_var='showMobileSearch',
|
||||
sr_label='Open search'
|
||||
) %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ show_var }} = true"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:hidden"
|
||||
>
|
||||
<span x-html="$icon('search', 'w-5 h-5')"></span>
|
||||
<span class="sr-only">{{ sr_label }}</span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Instant Search Results
|
||||
=====================
|
||||
Inline search results component (for header search).
|
||||
|
||||
Parameters:
|
||||
- results_var: Alpine.js expression for search results (default: 'searchResults')
|
||||
- loading_var: Alpine.js expression for loading state (default: 'isSearching')
|
||||
- query_var: Alpine.js expression for search query (default: 'searchQuery')
|
||||
- show_var: Alpine.js expression for visibility (default: 'showResults')
|
||||
|
||||
Usage:
|
||||
{{ instant_search_results(results_var='searchResults') }}
|
||||
#}
|
||||
{% macro instant_search_results(
|
||||
results_var='searchResults',
|
||||
loading_var='isSearching',
|
||||
query_var='searchQuery',
|
||||
show_var='showResults'
|
||||
) %}
|
||||
<div
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||
style="display: none;"
|
||||
>
|
||||
{# Loading #}
|
||||
<div x-show="{{ loading_var }}" class="flex items-center justify-center py-6">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 text-gray-400 animate-spin')"></span>
|
||||
<span class="ml-2 text-sm text-gray-500">Searching...</span>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<template x-if="!{{ loading_var }} && {{ results_var }}.length > 0">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{# Products #}
|
||||
<div class="py-2">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Products</span>
|
||||
</div>
|
||||
<template x-for="product in {{ results_var }}.filter(r => r.type === 'product').slice(0, 4)" :key="product.id">
|
||||
<a
|
||||
:href="product.url"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<img :src="product.image" :alt="product.name" class="w-10 h-10 object-cover rounded">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-900 dark:text-white truncate" x-text="product.name"></p>
|
||||
<p class="text-sm text-purple-600 dark:text-purple-400 font-medium" x-text="'$' + product.price"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Categories #}
|
||||
<template x-if="{{ results_var }}.filter(r => r.type === 'category').length > 0">
|
||||
<div class="py-2">
|
||||
<div class="px-4 py-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</span>
|
||||
</div>
|
||||
<template x-for="category in {{ results_var }}.filter(r => r.type === 'category').slice(0, 3)" :key="category.id">
|
||||
<a
|
||||
:href="category.url"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
||||
<span x-html="$icon('folder', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white" x-text="category.name"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# View All Results #}
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50">
|
||||
<a
|
||||
:href="'/search?q=' + encodeURIComponent({{ query_var }})"
|
||||
class="flex items-center justify-center gap-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
View all results
|
||||
<span x-html="$icon('arrow-right', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# No Results #}
|
||||
<template x-if="!{{ loading_var }} && {{ results_var }}.length === 0 && {{ query_var }}.length >= 2">
|
||||
<div class="py-8 text-center">
|
||||
<span x-html="$icon('search', 'w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No results found</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user