feat: add Priority 4 Navigation & Discovery e-commerce macros

Add navigation and discovery components for shop templates:

- category-nav.html: Category navigation with mega menu, mobile drawer,
  featured categories, and breadcrumb integration
- breadcrumbs.html: Breadcrumb navigation with schema.org markup,
  multiple variants (default, simple, with-home)
- search-bar.html: Search with autocomplete, recent/popular suggestions,
  mobile-optimized search drawer
- filter-sidebar.html: Product filtering with price range, rating filter,
  color/size selectors, sort dropdown, mobile filter drawer

All macros support:
- Alpine.js reactive bindings
- Dark mode via Tailwind dark: prefix
- Mobile-responsive layouts
- Accessibility features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 18:16:59 +01:00
parent 65e06c90ef
commit 10b69d2f29
4 changed files with 2066 additions and 0 deletions

View File

@@ -0,0 +1,262 @@
{#
Breadcrumbs Components
======================
Navigation breadcrumb trail for shop pages.
Usage:
{% from 'shared/macros/shop/breadcrumbs.html' import shop_breadcrumbs, auto_breadcrumbs %}
#}
{#
Shop Breadcrumbs
================
Breadcrumb navigation trail.
Parameters:
- items: List of breadcrumb items (static)
- items_var: Alpine.js expression for items (dynamic)
- separator: Separator icon (default: 'chevron-right')
- show_home: Show home link (default: true)
- home_url: URL for home (default: '/')
- home_label: Label for home (default: 'Home')
Item structure:
{
label: 'Category Name',
url: '/category/...', // Optional, last item typically has no URL
icon: 'folder' // Optional icon
}
Usage (static):
{{ shop_breadcrumbs(items=[
{'label': 'Electronics', 'url': '/category/electronics'},
{'label': 'Headphones', 'url': '/category/headphones'},
{'label': 'Wireless'}
]) }}
Usage (dynamic):
{{ shop_breadcrumbs(items_var='breadcrumbs') }}
#}
{% macro shop_breadcrumbs(
items=none,
items_var=none,
separator='chevron-right',
show_home=true,
home_url='/',
home_label='Home'
) %}
<nav aria-label="Breadcrumb" class="flex items-center text-sm">
<ol class="flex items-center flex-wrap gap-1">
{% if show_home %}
<li class="flex items-center">
<a
href="{{ home_url }}"
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
>
<span x-html="$icon('home', 'w-4 h-4')"></span>
<span class="sr-only sm:not-sr-only">{{ home_label }}</span>
</a>
</li>
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
</li>
{% endif %}
{% if items %}
{# Static items #}
{% for item in items %}
<li class="flex items-center">
{% if item.url and not loop.last %}
<a
href="{{ item.url }}"
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
>
{% if item.icon %}
<span x-html="$icon('{{ item.icon }}', 'w-4 h-4')"></span>
{% endif %}
<span>{{ item.label }}</span>
</a>
{% else %}
<span class="flex items-center gap-1 text-gray-900 dark:text-white font-medium" aria-current="page">
{% if item.icon %}
<span x-html="$icon('{{ item.icon }}', 'w-4 h-4')"></span>
{% endif %}
<span>{{ item.label }}</span>
</span>
{% endif %}
</li>
{% if not loop.last %}
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
</li>
{% endif %}
{% endfor %}
{% elif items_var %}
{# Dynamic items from Alpine.js #}
<template x-for="(item, index) in {{ items_var }}" :key="index">
<li class="flex items-center">
<template x-if="item.url && index < {{ items_var }}.length - 1">
<a
:href="item.url"
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
>
<span x-show="item.icon" x-html="$icon(item.icon, 'w-4 h-4')"></span>
<span x-text="item.label"></span>
</a>
</template>
<template x-if="!item.url || index === {{ items_var }}.length - 1">
<span class="flex items-center gap-1 text-gray-900 dark:text-white font-medium" aria-current="page">
<span x-show="item.icon" x-html="$icon(item.icon, 'w-4 h-4')"></span>
<span x-text="item.label"></span>
</span>
</template>
</li>
</template>
<template x-for="(item, index) in {{ items_var }}.slice(0, -1)" :key="'sep-' + index">
<li class="flex items-center text-gray-400 dark:text-gray-500 order-last" aria-hidden="true" :style="'order: ' + (index * 2 + 1)">
<span x-html="$icon('{{ separator }}', 'w-4 h-4')"></span>
</li>
</template>
{% endif %}
</ol>
</nav>
{% endmacro %}
{#
Auto Breadcrumbs
================
Automatically generates breadcrumbs from category hierarchy.
Parameters:
- product_var: Alpine.js expression for product (optional)
- category_var: Alpine.js expression for current category
- show_home: Show home link (default: true)
Usage:
{{ auto_breadcrumbs(category_var='currentCategory') }}
{{ auto_breadcrumbs(product_var='product') }}
#}
{% macro auto_breadcrumbs(
product_var=none,
category_var='currentCategory',
show_home=true
) %}
<nav
aria-label="Breadcrumb"
class="flex items-center text-sm"
x-data="{
get breadcrumbs() {
const items = [];
{% if product_var %}
// Build from product's category
let cat = {{ product_var }}?.category;
{% else %}
let cat = {{ category_var }};
{% endif %}
// Build category path (from parent to child)
const categoryPath = [];
while (cat) {
categoryPath.unshift(cat);
cat = cat.parent;
}
categoryPath.forEach(c => {
items.push({
label: c.name,
url: c.url || '/category/' + c.slug
});
});
{% if product_var %}
// Add product as last item
items.push({
label: {{ product_var }}?.name,
url: null
});
{% endif %}
return items;
}
}"
>
<ol class="flex items-center flex-wrap gap-1">
{% if show_home %}
<li class="flex items-center">
<a href="/" class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
<span x-html="$icon('home', 'w-4 h-4')"></span>
<span class="sr-only sm:not-sr-only">Home</span>
</a>
</li>
<li class="flex items-center text-gray-400 dark:text-gray-500" aria-hidden="true">
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</li>
{% endif %}
<template x-for="(item, index) in breadcrumbs" :key="index">
<li class="flex items-center">
<template x-if="item.url && index < breadcrumbs.length - 1">
<a :href="item.url" class="text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors" x-text="item.label"></a>
</template>
<template x-if="!item.url || index === breadcrumbs.length - 1">
<span class="text-gray-900 dark:text-white font-medium" aria-current="page" x-text="item.label"></span>
</template>
<span x-show="index < breadcrumbs.length - 1" class="mx-2 text-gray-400 dark:text-gray-500" x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</li>
</template>
</ol>
</nav>
{% endmacro %}
{#
Compact Breadcrumbs
===================
Mobile-friendly breadcrumbs showing only parent and current.
Parameters:
- parent: Parent item (static)
- parent_var: Alpine.js expression for parent
- current: Current page label
- current_var: Alpine.js expression for current
Usage:
{{ compact_breadcrumbs(parent={'label': 'Electronics', 'url': '/electronics'}, current='Headphones') }}
#}
{% macro compact_breadcrumbs(
parent=none,
parent_var=none,
current=none,
current_var=none
) %}
<nav aria-label="Breadcrumb" class="flex items-center text-sm">
{% if parent %}
<a
href="{{ parent.url }}"
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
>
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
<span>{{ parent.label }}</span>
</a>
{% elif parent_var %}
<a
:href="{{ parent_var }}?.url || '/'"
class="flex items-center gap-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
>
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
<span x-text="{{ parent_var }}?.label || 'Back'"></span>
</a>
{% endif %}
<span class="mx-2 text-gray-400 dark:text-gray-500">/</span>
{% if current %}
<span class="text-gray-900 dark:text-white font-medium" aria-current="page">{{ current }}</span>
{% elif current_var %}
<span class="text-gray-900 dark:text-white font-medium" aria-current="page" x-text="{{ current_var }}"></span>
{% endif %}
</nav>
{% endmacro %}

View File

@@ -0,0 +1,391 @@
{#
Category Navigation Components
==============================
Category browsing sidebar and menu for shop navigation.
Usage:
{% from 'shared/macros/shop/category-nav.html' import category_nav, category_tree, category_menu %}
#}
{#
Category Navigation
===================
Sidebar category navigation with nested categories.
Parameters:
- categories_var: Alpine.js expression for categories array (default: 'categories')
- current_var: Alpine.js expression for current category (default: 'currentCategory')
- show_count: Show product counts (default: true)
- collapsible: Make nested categories collapsible (default: true)
- max_depth: Maximum nesting depth to show (default: 3)
Expected category object:
{
id: 1,
name: 'Electronics',
slug: 'electronics',
url: '/category/electronics',
product_count: 150,
children: [...]
}
Usage:
{{ category_nav(categories_var='categories', show_count=true) }}
#}
{% macro category_nav(
categories_var='categories',
current_var='currentCategory',
show_count=true,
collapsible=true,
max_depth=3
) %}
<nav
x-data="{
expandedCategories: new Set(),
toggleCategory(id) {
if (this.expandedCategories.has(id)) {
this.expandedCategories.delete(id);
} else {
this.expandedCategories.add(id);
}
},
isExpanded(id) {
return this.expandedCategories.has(id);
},
isActive(category) {
return {{ current_var }}?.id === category.id || {{ current_var }}?.slug === category.slug;
},
isParentOfActive(category) {
if (!category.children?.length) return false;
return category.children.some(child =>
this.isActive(child) || this.isParentOfActive(child)
);
}
}"
x-init="
// Auto-expand parent of active category
const expandParents = (cats) => {
cats.forEach(cat => {
if (isParentOfActive(cat)) {
expandedCategories.add(cat.id);
}
if (cat.children?.length) expandParents(cat.children);
});
};
expandParents({{ categories_var }} || []);
"
class="space-y-1"
aria-label="Category navigation"
>
<template x-for="category in {{ categories_var }}" :key="category.id">
{{ _category_item(current_var, show_count, collapsible, 0, max_depth) }}
</template>
</nav>
{% endmacro %}
{#
Internal: Category Item (recursive)
#}
{% macro _category_item(current_var, show_count, collapsible, depth, max_depth) %}
<div class="relative">
<div class="flex items-center">
{# Category Link #}
<a
:href="category.url || '/category/' + category.slug"
class="flex-1 flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors"
:class="{
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium': isActive(category),
'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700': !isActive(category)
}"
:style="'padding-left: ' + ({{ depth }} * 12 + 12) + 'px'"
>
<span x-text="category.name"></span>
{% if show_count %}
<span
x-show="category.product_count !== undefined"
class="text-xs text-gray-500 dark:text-gray-400"
x-text="'(' + category.product_count + ')'"
></span>
{% endif %}
</a>
{# Expand/Collapse Button #}
{% if collapsible %}
<button
x-show="category.children?.length && {{ depth }} < {{ max_depth - 1 }}"
type="button"
@click.prevent="toggleCategory(category.id)"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:aria-expanded="isExpanded(category.id)"
>
<span
x-html="$icon('chevron-right', 'w-4 h-4 transition-transform')"
:class="isExpanded(category.id) && 'rotate-90'"
></span>
</button>
{% endif %}
</div>
{# Nested Categories #}
{% if depth < max_depth - 1 %}
<div
x-show="{{ 'isExpanded(category.id)' if collapsible else 'true' }} && category.children?.length"
x-collapse
class="mt-1"
>
<template x-for="child in category.children" :key="child.id">
<div x-data="{ category: child }">
{{ _category_item(current_var, show_count, collapsible, depth + 1, max_depth) }}
</div>
</template>
</div>
{% endif %}
</div>
{% endmacro %}
{#
Category Tree (Flat List)
=========================
Simple flat list of categories without nesting.
Parameters:
- categories_var: Alpine.js expression for categories
- current_var: Alpine.js expression for current category
- show_count: Show product counts (default: true)
- layout: 'vertical' | 'horizontal' (default: 'vertical')
Usage:
{{ category_tree(categories_var='topCategories', layout='horizontal') }}
#}
{% macro category_tree(
categories_var='categories',
current_var='currentCategory',
show_count=true,
layout='vertical'
) %}
<nav
class="{{ 'flex flex-wrap gap-2' if layout == 'horizontal' else 'space-y-1' }}"
aria-label="Categories"
>
<template x-for="category in {{ categories_var }}" :key="category.id">
<a
:href="category.url || '/category/' + category.slug"
class="{{ 'px-4 py-2 rounded-full text-sm font-medium' if layout == 'horizontal' else 'block px-3 py-2 text-sm rounded-lg' }} transition-colors"
:class="{
'{{ 'bg-purple-600 text-white' if layout == 'horizontal' else 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium' }}': {{ current_var }}?.id === category.id || {{ current_var }}?.slug === category.slug,
'{{ 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' if layout == 'horizontal' else 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' }}': {{ current_var }}?.id !== category.id && {{ current_var }}?.slug !== category.slug
}"
>
<span x-text="category.name"></span>
{% if show_count %}
<span
x-show="category.product_count !== undefined"
class="ml-1 text-xs {{ 'opacity-75' if layout == 'horizontal' else 'text-gray-500 dark:text-gray-400' }}"
x-text="'(' + category.product_count + ')'"
></span>
{% endif %}
</a>
</template>
</nav>
{% endmacro %}
{#
Category Menu (Dropdown/Mega Menu)
==================================
Horizontal category menu for header navigation.
Parameters:
- categories_var: Alpine.js expression for categories
- show_all_link: Show "All Categories" link (default: true)
- all_link_url: URL for all categories (default: '/categories')
Usage:
{{ category_menu(categories_var='mainCategories') }}
#}
{% macro category_menu(
categories_var='categories',
show_all_link=true,
all_link_url='/categories'
) %}
<nav class="relative" x-data="{ openMenu: null }">
<ul class="flex items-center gap-1">
{% if show_all_link %}
<li>
<a
href="{{ all_link_url }}"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
All Categories
</a>
</li>
{% endif %}
<template x-for="category in {{ categories_var }}" :key="category.id">
<li class="relative" @mouseenter="openMenu = category.id" @mouseleave="openMenu = null">
<a
:href="category.url || '/category/' + category.slug"
class="flex items-center gap-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span x-text="category.name"></span>
<span x-show="category.children?.length" x-html="$icon('chevron-down', 'w-4 h-4')"></span>
</a>
{# Dropdown for subcategories #}
<div
x-show="openMenu === category.id && category.children?.length"
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 top-full mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50"
style="display: none;"
>
<template x-for="child in category.children" :key="child.id">
<a
:href="child.url || '/category/' + child.slug"
class="flex items-center justify-between px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-purple-600 dark:hover:text-purple-400"
>
<span x-text="child.name"></span>
<span x-show="child.product_count" class="text-xs text-gray-500" x-text="child.product_count"></span>
</a>
</template>
{# View All Link #}
<div class="border-t border-gray-200 dark:border-gray-700 mt-2 pt-2">
<a
:href="category.url || '/category/' + category.slug"
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700"
>
View all <span x-text="category.name" class="ml-1"></span>
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-auto')"></span>
</a>
</div>
</div>
</li>
</template>
</ul>
</nav>
{% endmacro %}
{#
Mobile Category Drawer
======================
Full-screen category navigation for mobile devices.
Parameters:
- categories_var: Alpine.js expression for categories
- show_var: Alpine.js variable for drawer visibility (default: 'showCategoryDrawer')
Usage:
{{ category_drawer(categories_var='categories', show_var='showCategoryDrawer') }}
#}
{% macro category_drawer(
categories_var='categories',
show_var='showCategoryDrawer'
) %}
<div
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 lg:hidden"
style="display: none;"
>
{# Backdrop #}
<div
@click="{{ show_var }} = false"
class="absolute inset-0 bg-black/50"
></div>
{# Drawer Panel #}
<div
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="absolute left-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-800 shadow-xl"
x-data="{ selectedCategory: null, categoryStack: [] }"
>
{# Header #}
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<button
x-show="categoryStack.length > 0"
type="button"
@click="selectedCategory = categoryStack.pop()"
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>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedCategory?.name || 'Categories'"></h2>
<button
type="button"
@click="{{ show_var }} = false"
class="p-2 -mr-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
{# Category List #}
<div class="overflow-y-auto h-[calc(100%-60px)]">
<template x-if="!selectedCategory">
<ul class="py-2">
<template x-for="category in {{ categories_var }}" :key="category.id">
<li>
<a
:href="category.children?.length ? '#' : (category.url || '/category/' + category.slug)"
@click.prevent="category.children?.length ? (categoryStack.push(null), selectedCategory = category) : (window.location.href = category.url || '/category/' + category.slug)"
class="flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span x-text="category.name"></span>
<span x-show="category.children?.length" x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</a>
</li>
</template>
</ul>
</template>
<template x-if="selectedCategory">
<ul class="py-2">
{# View All in Category #}
<li>
<a
:href="selectedCategory.url || '/category/' + selectedCategory.slug"
class="flex items-center px-4 py-3 text-purple-600 dark:text-purple-400 font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>View all <span x-text="selectedCategory.name"></span></span>
</a>
</li>
<li class="border-t border-gray-200 dark:border-gray-700 my-2"></li>
<template x-for="child in selectedCategory.children" :key="child.id">
<li>
<a
:href="child.children?.length ? '#' : (child.url || '/category/' + child.slug)"
@click.prevent="child.children?.length ? (categoryStack.push(selectedCategory), selectedCategory = child) : (window.location.href = child.url || '/category/' + child.slug)"
class="flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span x-text="child.name"></span>
<span x-show="child.children?.length" x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</a>
</li>
</template>
</ul>
</template>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,786 @@
{#
Filter Sidebar Components
=========================
Product filtering panel for category and search pages.
Usage:
{% from 'shared/macros/shop/filter-sidebar.html' import filter_sidebar, filter_group, price_filter, rating_filter %}
#}
{#
Filter Sidebar
==============
Complete filter sidebar with multiple filter types.
Parameters:
- filters_var: Alpine.js expression for filter configuration (default: 'filters')
- active_filters_var: Alpine.js expression for active filters (default: 'activeFilters')
- on_change: JavaScript callback when filters change (default: 'filterProducts()')
- show_clear: Show clear all button (default: true)
- collapsible: Make filter groups collapsible (default: true)
Expected filters structure:
{
categories: [{ id, name, count }],
brands: [{ id, name, count }],
priceRange: { min: 0, max: 1000 },
attributes: {
color: [{ value, label, count, hex }],
size: [{ value, label, count }]
},
ratings: [{ value: 5, count: 10 }, ...]
}
Usage:
{{ filter_sidebar(filters_var='filters', on_change='applyFilters()') }}
#}
{% macro filter_sidebar(
filters_var='filters',
active_filters_var='activeFilters',
on_change='filterProducts()',
show_clear=true,
collapsible=true
) %}
<aside
x-data="{
expandedGroups: new Set(['categories', 'price', 'brands']),
toggleGroup(name) {
if (this.expandedGroups.has(name)) {
this.expandedGroups.delete(name);
} else {
this.expandedGroups.add(name);
}
},
isExpanded(name) {
return this.expandedGroups.has(name);
},
hasActiveFilters() {
const af = {{ active_filters_var }};
return af.categories?.length > 0 ||
af.brands?.length > 0 ||
af.priceMin !== undefined ||
af.priceMax !== undefined ||
af.rating !== undefined ||
Object.keys(af.attributes || {}).some(k => af.attributes[k]?.length > 0);
},
clearAll() {
{{ active_filters_var }} = {
categories: [],
brands: [],
priceMin: undefined,
priceMax: undefined,
rating: undefined,
attributes: {},
inStock: false
};
{{ on_change }};
}
}"
class="space-y-6"
>
{# Header with Clear All #}
{% if show_clear %}
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<button
type="button"
x-show="hasActiveFilters()"
@click="clearAll()"
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
>
Clear all
</button>
</div>
{% endif %}
{# Active Filters Tags #}
<div x-show="hasActiveFilters()" class="flex flex-wrap gap-2">
<template x-for="(cat, index) in {{ active_filters_var }}.categories || []" :key="'cat-' + cat">
<span class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs rounded-full">
<span x-text="{{ filters_var }}.categories?.find(c => c.id === cat)?.name || cat"></span>
<button type="button" @click="{{ active_filters_var }}.categories = {{ active_filters_var }}.categories.filter(c => c !== cat); {{ on_change }}" class="hover:text-purple-900 dark:hover:text-purple-100">
<span x-html="$icon('x', 'w-3 h-3')"></span>
</button>
</span>
</template>
<template x-for="(brand, index) in {{ active_filters_var }}.brands || []" :key="'brand-' + brand">
<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs rounded-full">
<span x-text="{{ filters_var }}.brands?.find(b => b.id === brand)?.name || brand"></span>
<button type="button" @click="{{ active_filters_var }}.brands = {{ active_filters_var }}.brands.filter(b => b !== brand); {{ on_change }}" class="hover:text-blue-900 dark:hover:text-blue-100">
<span x-html="$icon('x', 'w-3 h-3')"></span>
</button>
</span>
</template>
<template x-if="{{ active_filters_var }}.priceMin !== undefined || {{ active_filters_var }}.priceMax !== undefined">
<span class="inline-flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded-full">
<span x-text="'$' + ({{ active_filters_var }}.priceMin || 0) + ' - $' + ({{ active_filters_var }}.priceMax || {{ filters_var }}.priceRange?.max || 1000)"></span>
<button type="button" @click="{{ active_filters_var }}.priceMin = undefined; {{ active_filters_var }}.priceMax = undefined; {{ on_change }}" class="hover:text-green-900 dark:hover:text-green-100">
<span x-html="$icon('x', 'w-3 h-3')"></span>
</button>
</span>
</template>
</div>
{# Categories Filter #}
<template x-if="{{ filters_var }}.categories?.length > 0">
{{ _filter_group('Categories', 'categories', collapsible) }}
</template>
{# Price Range Filter #}
<template x-if="{{ filters_var }}.priceRange">
{{ _price_range_group(filters_var, active_filters_var, on_change, collapsible) }}
</template>
{# Brands Filter #}
<template x-if="{{ filters_var }}.brands?.length > 0">
{{ _filter_group('Brands', 'brands', collapsible) }}
</template>
{# Rating Filter #}
<template x-if="{{ filters_var }}.ratings?.length > 0">
{{ _rating_group(filters_var, active_filters_var, on_change, collapsible) }}
</template>
{# Dynamic Attribute Filters #}
<template x-for="(options, attrName) in {{ filters_var }}.attributes || {}" :key="attrName">
{{ _attribute_group(filters_var, active_filters_var, on_change, collapsible) }}
</template>
{# Availability Filter #}
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
x-model="{{ active_filters_var }}.inStock"
@change="{{ on_change }}"
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<span class="text-sm text-gray-700 dark:text-gray-300">In Stock Only</span>
</label>
</div>
</aside>
{% endmacro %}
{#
Internal: Filter Group for Categories/Brands
#}
{% macro _filter_group(title, type, collapsible) %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
{% if collapsible %}
<button
type="button"
@click="toggleGroup('{{ type }}')"
class="flex items-center justify-between w-full text-left"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ title }}</h3>
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('{{ type }}') && 'rotate-180'"></span>
</button>
{% else %}
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">{{ title }}</h3>
{% endif %}
<div
{% if collapsible %}
x-show="isExpanded('{{ type }}')"
x-collapse
{% endif %}
class="mt-3 space-y-2"
>
<template x-for="item in filters.{{ type }}" :key="item.id">
<label class="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
:value="item.id"
:checked="activeFilters.{{ type }}?.includes(item.id)"
@change="
if ($event.target.checked) {
activeFilters.{{ type }} = [...(activeFilters.{{ type }} || []), item.id];
} else {
activeFilters.{{ type }} = activeFilters.{{ type }}.filter(i => i !== item.id);
}
filterProducts();
"
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white" x-text="item.name"></span>
<span x-show="item.count !== undefined" class="text-xs text-gray-400" x-text="'(' + item.count + ')'"></span>
</label>
</template>
</div>
</div>
{% endmacro %}
{#
Internal: Price Range Group
#}
{% macro _price_range_group(filters_var, active_filters_var, on_change, collapsible) %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
{% if collapsible %}
<button
type="button"
@click="toggleGroup('price')"
class="flex items-center justify-between w-full text-left"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Price Range</h3>
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('price') && 'rotate-180'"></span>
</button>
{% else %}
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Price Range</h3>
{% endif %}
<div
{% if collapsible %}
x-show="isExpanded('price')"
x-collapse
{% endif %}
class="mt-3 space-y-4"
x-data="{
minPrice: {{ active_filters_var }}.priceMin ?? {{ filters_var }}.priceRange?.min ?? 0,
maxPrice: {{ active_filters_var }}.priceMax ?? {{ filters_var }}.priceRange?.max ?? 1000,
updatePrice() {
{{ active_filters_var }}.priceMin = this.minPrice;
{{ active_filters_var }}.priceMax = this.maxPrice;
{{ on_change }};
}
}"
>
{# Dual Range Slider #}
<div class="relative pt-1">
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full">
<div
class="absolute h-2 bg-purple-500 rounded-full"
:style="'left: ' + ((minPrice - ({{ filters_var }}.priceRange?.min || 0)) / (({{ filters_var }}.priceRange?.max || 1000) - ({{ filters_var }}.priceRange?.min || 0)) * 100) + '%; right: ' + (100 - (maxPrice - ({{ filters_var }}.priceRange?.min || 0)) / (({{ filters_var }}.priceRange?.max || 1000) - ({{ filters_var }}.priceRange?.min || 0)) * 100) + '%'"
></div>
</div>
<input
type="range"
:min="{{ filters_var }}.priceRange?.min || 0"
:max="{{ filters_var }}.priceRange?.max || 1000"
x-model.number="minPrice"
@change="if (minPrice > maxPrice) minPrice = maxPrice; updatePrice()"
class="absolute w-full h-2 opacity-0 cursor-pointer -top-1"
>
<input
type="range"
:min="{{ filters_var }}.priceRange?.min || 0"
:max="{{ filters_var }}.priceRange?.max || 1000"
x-model.number="maxPrice"
@change="if (maxPrice < minPrice) maxPrice = minPrice; updatePrice()"
class="absolute w-full h-2 opacity-0 cursor-pointer -top-1"
>
</div>
{# Price Inputs #}
<div class="flex items-center gap-3">
<div class="flex-1">
<label class="sr-only">Min price</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
type="number"
x-model.number="minPrice"
:min="{{ filters_var }}.priceRange?.min || 0"
:max="maxPrice"
@change="updatePrice()"
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Min"
>
</div>
</div>
<span class="text-gray-400">-</span>
<div class="flex-1">
<label class="sr-only">Max price</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
type="number"
x-model.number="maxPrice"
:min="minPrice"
:max="{{ filters_var }}.priceRange?.max || 1000"
@change="updatePrice()"
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Max"
>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}
{#
Internal: Rating Group
#}
{% macro _rating_group(filters_var, active_filters_var, on_change, collapsible) %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
{% if collapsible %}
<button
type="button"
@click="toggleGroup('rating')"
class="flex items-center justify-between w-full text-left"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Rating</h3>
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded('rating') && 'rotate-180'"></span>
</button>
{% else %}
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Rating</h3>
{% endif %}
<div
{% if collapsible %}
x-show="isExpanded('rating')"
x-collapse
{% endif %}
class="mt-3 space-y-2"
>
<template x-for="rating in {{ filters_var }}.ratings" :key="rating.value">
<label class="flex items-center gap-3 cursor-pointer group">
<input
type="radio"
name="rating-filter"
:value="rating.value"
:checked="{{ active_filters_var }}.rating === rating.value"
@change="{{ active_filters_var }}.rating = rating.value; {{ on_change }}"
class="w-4 h-4 border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<span class="flex items-center gap-1">
<template x-for="i in 5" :key="i">
<span
x-html="$icon('star', 'w-4 h-4')"
:class="i <= rating.value ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'"
></span>
</template>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">& up</span>
</span>
<span x-show="rating.count !== undefined" class="text-xs text-gray-400 ml-auto" x-text="'(' + rating.count + ')'"></span>
</label>
</template>
<button
type="button"
x-show="{{ active_filters_var }}.rating !== undefined"
@click="{{ active_filters_var }}.rating = undefined; {{ on_change }}"
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
>
Clear rating filter
</button>
</div>
</div>
{% endmacro %}
{#
Internal: Dynamic Attribute Group (color, size, etc.)
#}
{% macro _attribute_group(filters_var, active_filters_var, on_change, collapsible) %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
{% if collapsible %}
<button
type="button"
@click="toggleGroup(attrName)"
class="flex items-center justify-between w-full text-left"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white capitalize" x-text="attrName"></h3>
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform')" :class="isExpanded(attrName) && 'rotate-180'"></span>
</button>
{% else %}
<h3 class="text-sm font-medium text-gray-900 dark:text-white capitalize mb-3" x-text="attrName"></h3>
{% endif %}
<div
{% if collapsible %}
x-show="isExpanded(attrName)"
x-collapse
{% endif %}
class="mt-3"
>
{# Color Swatches #}
<template x-if="attrName === 'color'">
<div class="flex flex-wrap gap-2">
<template x-for="option in options" :key="option.value">
<button
type="button"
@click="
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
const idx = {{ active_filters_var }}.attributes[attrName].indexOf(option.value);
if (idx === -1) {
{{ active_filters_var }}.attributes[attrName].push(option.value);
} else {
{{ active_filters_var }}.attributes[attrName].splice(idx, 1);
}
{{ on_change }};
"
class="w-8 h-8 rounded-full border-2 transition-all"
:class="({{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)) ? 'border-purple-500 ring-2 ring-purple-500/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
:style="'background-color: ' + (option.hex || option.value)"
:title="option.label"
>
<span class="sr-only" x-text="option.label"></span>
<span
x-show="{{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)"
x-html="$icon('check', 'w-4 h-4 mx-auto')"
:class="['#FFFFFF', '#FFF', 'white', '#FFFFF0'].includes(option.hex || option.value) ? 'text-gray-800' : 'text-white'"
></span>
</button>
</template>
</div>
</template>
{# Size/Other Buttons #}
<template x-if="attrName === 'size'">
<div class="flex flex-wrap gap-2">
<template x-for="option in options" :key="option.value">
<button
type="button"
@click="
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
const idx = {{ active_filters_var }}.attributes[attrName].indexOf(option.value);
if (idx === -1) {
{{ active_filters_var }}.attributes[attrName].push(option.value);
} else {
{{ active_filters_var }}.attributes[attrName].splice(idx, 1);
}
{{ on_change }};
"
class="px-3 py-1.5 text-sm border rounded-lg transition-colors"
:class="({{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)) ? 'bg-purple-600 text-white border-purple-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-500'"
:disabled="option.count === 0"
:title="option.count === 0 ? 'Not available' : ''"
>
<span x-text="option.label"></span>
</button>
</template>
</div>
</template>
{# Checkbox List for other attributes #}
<template x-if="attrName !== 'color' && attrName !== 'size'">
<div class="space-y-2">
<template x-for="option in options" :key="option.value">
<label class="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
:checked="{{ active_filters_var }}.attributes?.[attrName]?.includes(option.value)"
@change="
if (!{{ active_filters_var }}.attributes) {{ active_filters_var }}.attributes = {};
if (!{{ active_filters_var }}.attributes[attrName]) {{ active_filters_var }}.attributes[attrName] = [];
if ($event.target.checked) {
{{ active_filters_var }}.attributes[attrName].push(option.value);
} else {
{{ active_filters_var }}.attributes[attrName] = {{ active_filters_var }}.attributes[attrName].filter(v => v !== option.value);
}
{{ on_change }};
"
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300" x-text="option.label"></span>
<span x-show="option.count !== undefined" class="text-xs text-gray-400" x-text="'(' + option.count + ')'"></span>
</label>
</template>
</div>
</template>
</div>
</div>
{% endmacro %}
{#
Price Filter (Standalone)
=========================
Standalone price range filter component.
Parameters:
- min: Minimum price (default: 0)
- max: Maximum price (default: 1000)
- value_min_var: Alpine.js var for min value (default: 'priceMin')
- value_max_var: Alpine.js var for max value (default: 'priceMax')
- on_change: Callback on change (default: 'updateFilters()')
- currency: Currency symbol (default: '$')
Usage:
{{ price_filter(min=0, max=500, on_change='filterByPrice()') }}
#}
{% macro price_filter(
min=0,
max=1000,
value_min_var='priceMin',
value_max_var='priceMax',
on_change='updateFilters()',
currency='$'
) %}
<div
x-data="{
minVal: {{ value_min_var }} ?? {{ min }},
maxVal: {{ value_max_var }} ?? {{ max }},
apply() {
{{ value_min_var }} = this.minVal;
{{ value_max_var }} = this.maxVal;
{{ on_change }};
}
}"
class="space-y-4"
>
<div class="flex items-center gap-3">
<div class="flex-1">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">{{ currency }}</span>
<input
type="number"
x-model.number="minVal"
min="{{ min }}"
:max="maxVal"
@change="apply()"
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Min"
>
</div>
</div>
<span class="text-gray-400">to</span>
<div class="flex-1">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">{{ currency }}</span>
<input
type="number"
x-model.number="maxVal"
:min="minVal"
max="{{ max }}"
@change="apply()"
class="w-full pl-7 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Max"
>
</div>
</div>
</div>
</div>
{% endmacro %}
{#
Rating Filter (Standalone)
==========================
Standalone star rating filter.
Parameters:
- value_var: Alpine.js var for selected rating (default: 'minRating')
- on_change: Callback on change (default: 'updateFilters()')
- show_count: Show product counts (default: false)
- counts: List of counts per rating level
Usage:
{{ rating_filter(value_var='minRating', on_change='filterByRating()') }}
#}
{% macro rating_filter(
value_var='minRating',
on_change='updateFilters()',
show_count=false,
counts=none
) %}
<div class="space-y-2">
{% for i in range(5, 0, -1) %}
<label class="flex items-center gap-3 cursor-pointer group">
<input
type="radio"
name="rating"
value="{{ i }}"
:checked="{{ value_var }} === {{ i }}"
@change="{{ value_var }} = {{ i }}; {{ on_change }}"
class="w-4 h-4 border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<span class="flex items-center gap-0.5">
{% for j in range(1, 6) %}
<span x-html="$icon('star', 'w-4 h-4 {{ 'text-yellow-400' if j <= i else 'text-gray-300 dark:text-gray-600' }}')" class="{{ 'text-yellow-400' if j <= i else 'text-gray-300 dark:text-gray-600' }}"></span>
{% endfor %}
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">& up</span>
</span>
{% if show_count and counts %}
<span class="text-xs text-gray-400 ml-auto">({{ counts[i-1] if counts|length >= i else '0' }})</span>
{% endif %}
</label>
{% endfor %}
</div>
{% endmacro %}
{#
Mobile Filter Drawer
====================
Full-screen filter panel for mobile devices.
Parameters:
- show_var: Alpine.js variable for visibility (default: 'showFilters')
- filters_var: Alpine.js expression for filters (default: 'filters')
- active_filters_var: Alpine.js expression for active filters (default: 'activeFilters')
- on_apply: Callback on apply (default: 'applyFilters()')
- result_count_var: Alpine.js var showing result count (default: 'productCount')
Usage:
{{ mobile_filter_drawer(show_var='showMobileFilters') }}
#}
{% macro mobile_filter_drawer(
show_var='showFilters',
filters_var='filters',
active_filters_var='activeFilters',
on_apply='applyFilters()',
result_count_var='productCount'
) %}
<div
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 lg:hidden"
style="display: none;"
>
{# Backdrop #}
<div
@click="{{ show_var }} = false"
class="absolute inset-0 bg-black/50"
></div>
{# Drawer Panel #}
<div
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-800 shadow-xl flex flex-col"
>
{# Header #}
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<button
type="button"
@click="{{ show_var }} = false"
class="p-2 -mr-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
{# Filter Content #}
<div class="flex-1 overflow-y-auto px-4 py-4">
{{ filter_sidebar(filters_var=filters_var, active_filters_var=active_filters_var, on_change=on_apply, show_clear=false, collapsible=true) }}
</div>
{# Footer with Apply Button #}
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<button
type="button"
@click="{{ show_var }} = false"
class="w-full py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
>
Show Results <span x-show="{{ result_count_var }}" x-text="'(' + {{ result_count_var }} + ')'"></span>
</button>
</div>
</div>
</div>
{% endmacro %}
{#
Filter Trigger Button
====================
Button to open mobile filter drawer.
Parameters:
- show_var: Alpine.js variable to toggle (default: 'showFilters')
- active_count_var: Alpine.js var for active filter count (default: none)
Usage:
{{ filter_trigger(show_var='showMobileFilters') }}
#}
{% macro filter_trigger(
show_var='showFilters',
active_count_var=none
) %}
<button
type="button"
@click="{{ show_var }} = true"
class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors lg:hidden"
>
<span x-html="$icon('adjustments', 'w-5 h-5')"></span>
<span>Filters</span>
{% if active_count_var %}
<span
x-show="{{ active_count_var }} > 0"
x-text="{{ active_count_var }}"
class="w-5 h-5 flex items-center justify-center bg-purple-600 text-white text-xs rounded-full"
></span>
{% endif %}
</button>
{% endmacro %}
{#
Sort Dropdown
=============
Product sorting dropdown.
Parameters:
- value_var: Alpine.js var for sort value (default: 'sortBy')
- options: Sort options list (default: common options)
- on_change: Callback on change (default: 'sortProducts()')
Usage:
{{ sort_dropdown(value_var='currentSort') }}
#}
{% macro sort_dropdown(
value_var='sortBy',
options=none,
on_change='sortProducts()'
) %}
{% set default_options = [
{'value': 'relevance', 'label': 'Relevance'},
{'value': 'price_asc', 'label': 'Price: Low to High'},
{'value': 'price_desc', 'label': 'Price: High to Low'},
{'value': 'newest', 'label': 'Newest First'},
{'value': 'rating', 'label': 'Highest Rated'},
{'value': 'popular', 'label': 'Most Popular'}
] %}
{% set sort_options = options if options else default_options %}
<div x-data="{ open: false }" class="relative" @click.away="open = false">
<button
type="button"
@click="open = !open"
class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span>Sort by:</span>
<span class="text-gray-900 dark:text-white" x-text="[{% for opt in sort_options %}{ value: '{{ opt.value }}', label: '{{ opt.label }}' }{{ ', ' if not loop.last }}{% endfor %}].find(o => o.value === {{ value_var }})?.label || 'Relevance'"></span>
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400')"></span>
</button>
<div
x-show="open"
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 right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
style="display: none;"
>
{% for opt in sort_options %}
<button
type="button"
@click="{{ value_var }} = '{{ opt.value }}'; {{ on_change }}; open = false"
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
:class="{{ value_var }} === '{{ opt.value }}' ? 'text-purple-600 dark:text-purple-400 font-medium' : 'text-gray-700 dark:text-gray-300'"
>
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
{% endmacro %}

View 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 %}