Files
orion/app/templates/shared/macros/storefront/search-bar.html
Samir Boulahtit 8ee8c398ce
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
perf: add defer to scripts and lazy loading to images
Add defer attribute to 145 <script> tags across 103 template files
(PERF-067) and loading="lazy" to 22 <img> tags across 13 template
files (PERF-058). Both improve page load performance.

Validator totals: 0 errors, 2 warnings, 1360 info (down from 1527).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:55:52 +01:00

628 lines
28 KiB
HTML

{#
Search Bar Components
=====================
Product search with autocomplete and suggestions for shop pages.
Usage:
{% from 'shared/macros/storefront/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 loading="lazy" :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 loading="lazy" :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 loading="lazy" :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 %}