Files
orion/app/templates/shared/macros/storefront/variant-selector.html
Samir Boulahtit 7245f79f7b 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>
2026-01-30 22:58:28 +01:00

419 lines
17 KiB
HTML

{#
Variant Selector Components
===========================
Product variant selection (size, color, etc.) for product detail pages.
Usage:
{% from 'shared/macros/shop/variant-selector.html' import variant_selector, size_selector, color_swatches %}
#}
{#
Variant Selector
================
Generic variant selector that adapts to variant type.
Parameters:
- variants_var: Alpine.js expression for variants array (default: 'product.variants')
- selected_var: Alpine.js variable for selected variant (default: 'selectedVariant')
- type: 'buttons' | 'dropdown' | 'swatches' (default: 'buttons')
- label: Label text (default: 'Select Option')
- show_stock: Show stock status per variant (default: true)
- on_change: Custom change handler (default: none)
Expected variant object:
{
id: 1,
name: 'Large',
value: 'L',
stock: 10,
price_modifier: 0,
color_hex: '#FF0000', // For swatches
image_url: '...' // For swatches with preview
}
Usage:
{{ variant_selector(variants_var='product.sizes', label='Size') }}
#}
{% macro variant_selector(
variants_var='product.variants',
selected_var='selectedVariant',
type='buttons',
label='Select Option',
show_stock=true,
on_change=none
) %}
<div class="space-y-2">
{# Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ label }}
</label>
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}?.value"
></span>
</div>
{% if type == 'buttons' %}
{{ _variant_buttons(variants_var, selected_var, show_stock, on_change) }}
{% elif type == 'dropdown' %}
{{ _variant_dropdown(variants_var, selected_var, show_stock, on_change) }}
{% elif type == 'swatches' %}
{{ _variant_swatches(variants_var, selected_var, show_stock, on_change) }}
{% endif %}
</div>
{% endmacro %}
{#
Internal: Variant Buttons
#}
{% macro _variant_buttons(variants_var, selected_var, show_stock, on_change) %}
<div class="flex flex-wrap gap-2">
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<button
type="button"
@click="{{ selected_var }} = variant{{ '; ' ~ on_change if on_change else '' }}"
:disabled="variant.stock === 0"
class="px-4 py-2 text-sm font-medium rounded-lg border-2 transition-all"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': {{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': ({{ selected_var }}?.id !== variant.id && {{ selected_var }}?.value !== variant.value) && variant.stock > 0,
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed line-through': variant.stock === 0
}"
>
<span x-text="variant.name || variant.value"></span>
{% if show_stock %}
<span
x-show="variant.stock > 0 && variant.stock <= 5"
class="ml-1 text-xs text-orange-600 dark:text-orange-400"
x-text="'(' + variant.stock + ' left)'"
></span>
{% endif %}
</button>
</template>
</div>
{% endmacro %}
{#
Internal: Variant Dropdown
#}
{% macro _variant_dropdown(variants_var, selected_var, show_stock, on_change) %}
<select
x-model="{{ selected_var }}"
@change="{{ on_change if on_change else '' }}"
class="block w-full px-4 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="" disabled>Choose an option</option>
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<option
:value="JSON.stringify(variant)"
:disabled="variant.stock === 0"
x-text="(variant.name || variant.value) + (variant.stock === 0 ? ' (Out of stock)' : {{ '(variant.stock <= 5 ? \' (Only \' + variant.stock + \' left)\' : \'\')' if show_stock else '\'\'' }})"
></option>
</template>
</select>
{% endmacro %}
{#
Internal: Variant Swatches (for colors)
#}
{% macro _variant_swatches(variants_var, selected_var, show_stock, on_change) %}
<div class="flex flex-wrap gap-3">
<template x-for="variant in {{ variants_var }}" :key="variant.id || variant.value">
<button
type="button"
@click="{{ selected_var }} = variant{{ '; ' ~ on_change if on_change else '' }}"
:disabled="variant.stock === 0"
:title="(variant.name || variant.value) + (variant.stock === 0 ? ' - Out of stock' : '')"
class="relative w-10 h-10 rounded-full border-2 transition-all"
:class="{
'ring-2 ring-offset-2 ring-purple-500 dark:ring-offset-gray-800': {{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value,
'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500': ({{ selected_var }}?.id !== variant.id && {{ selected_var }}?.value !== variant.value) && variant.stock > 0,
'opacity-40 cursor-not-allowed': variant.stock === 0
}"
:style="'background-color: ' + (variant.color_hex || variant.color || '#ccc')"
>
{# Out of Stock Slash #}
<span
x-show="variant.stock === 0"
class="absolute inset-0 flex items-center justify-center"
>
<span class="w-full h-0.5 bg-gray-600 dark:bg-gray-400 rotate-45 absolute"></span>
</span>
{# Check Mark for Selected #}
<span
x-show="{{ selected_var }}?.id === variant.id || {{ selected_var }}?.value === variant.value"
class="absolute inset-0 flex items-center justify-center"
>
<span x-html="$icon('check', 'w-5 h-5')" :class="isLightColor(variant.color_hex || variant.color) ? 'text-gray-800' : 'text-white'"></span>
</span>
</button>
</template>
</div>
{% endmacro %}
{#
Size Selector
=============
Specialized selector for clothing/shoe sizes.
Parameters:
- sizes_var: Alpine.js expression for sizes array
- selected_var: Alpine.js variable for selected size
- show_guide: Show size guide link (default: true)
- guide_action: Action for size guide button (default: none)
Usage:
{{ size_selector(sizes_var='product.sizes', guide_action='showSizeGuide = true') }}
#}
{% macro size_selector(
sizes_var='product.sizes',
selected_var='selectedSize',
show_guide=true,
guide_action=none
) %}
<div class="space-y-2">
{# Label with Size Guide #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Size
</label>
<div class="flex items-center gap-3">
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}"
></span>
{% if show_guide %}
<button
type="button"
{% if guide_action %}@click="{{ guide_action }}"{% endif %}
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
>
Size Guide
</button>
{% endif %}
</div>
</div>
{# Size Buttons #}
<div class="flex flex-wrap gap-2">
<template x-for="size in {{ sizes_var }}" :key="size.id || size.value || size">
<button
type="button"
@click="{{ selected_var }} = size"
:disabled="size.stock === 0 || (typeof size === 'object' && size.available === false)"
class="min-w-[3rem] px-3 py-2 text-sm font-medium rounded-lg border-2 transition-all text-center"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': JSON.stringify({{ selected_var }}) === JSON.stringify(size) || {{ selected_var }} === size,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': JSON.stringify({{ selected_var }}) !== JSON.stringify(size) && {{ selected_var }} !== size && (size.stock !== 0 && size.available !== false),
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed': size.stock === 0 || size.available === false
}"
>
<span x-text="size.name || size.value || size"></span>
</button>
</template>
</div>
</div>
{% endmacro %}
{#
Color Swatches
==============
Specialized selector for color options with preview.
Parameters:
- colors_var: Alpine.js expression for colors array
- selected_var: Alpine.js variable for selected color
- size: 'sm' | 'md' | 'lg' (default: 'md')
- on_change: Custom change handler (triggers image change, etc.)
Expected color object:
{
id: 1,
name: 'Red',
value: 'red',
color_hex: '#FF0000',
stock: 10,
image_url: '...' // Optional: product image for this color
}
Usage:
{{ color_swatches(colors_var='product.colors', on_change='updateProductImage(selectedColor)') }}
#}
{% macro color_swatches(
colors_var='product.colors',
selected_var='selectedColor',
size='md',
on_change=none
) %}
{% set sizes = {
'sm': {'swatch': 'w-8 h-8', 'icon': 'w-4 h-4'},
'md': {'swatch': 'w-10 h-10', 'icon': 'w-5 h-5'},
'lg': {'swatch': 'w-12 h-12', 'icon': 'w-6 h-6'}
} %}
{% set s = sizes[size] %}
<div class="space-y-2">
{# Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<span
x-show="{{ selected_var }}"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="{{ selected_var }}?.name || {{ selected_var }}?.value"
></span>
</div>
{# Color Swatches #}
<div class="flex flex-wrap gap-3">
<template x-for="color in {{ colors_var }}" :key="color.id || color.value">
<button
type="button"
@click="{{ selected_var }} = color{{ '; ' ~ on_change if on_change else '' }}"
:disabled="color.stock === 0"
:title="(color.name || color.value) + (color.stock === 0 ? ' - Out of stock' : '')"
class="relative {{ s.swatch }} rounded-full border-2 transition-all shadow-sm"
:class="{
'ring-2 ring-offset-2 ring-purple-500 dark:ring-offset-gray-800 border-gray-300': {{ selected_var }}?.id === color.id || {{ selected_var }}?.value === color.value,
'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500': ({{ selected_var }}?.id !== color.id && {{ selected_var }}?.value !== color.value) && color.stock > 0,
'opacity-40 cursor-not-allowed': color.stock === 0
}"
:style="'background-color: ' + (color.color_hex || color.color || '#ccc')"
>
{# Checkered pattern for white/light colors #}
<span
x-show="isLightColor(color.color_hex || color.color)"
class="absolute inset-0.5 rounded-full border border-gray-200"
></span>
{# Out of Stock Slash #}
<span
x-show="color.stock === 0"
class="absolute inset-0 flex items-center justify-center"
>
<span class="w-full h-0.5 bg-gray-600 rotate-45 absolute"></span>
</span>
{# Check Mark for Selected #}
<span
x-show="{{ selected_var }}?.id === color.id || {{ selected_var }}?.value === color.value"
class="absolute inset-0 flex items-center justify-center"
>
<span x-html="$icon('check', '{{ s.icon }}')" :class="isLightColor(color.color_hex || color.color) ? 'text-gray-800' : 'text-white'"></span>
</span>
</button>
</template>
</div>
</div>
{% endmacro %}
{#
Multi-Option Variant Selector
=============================
Combined selector for products with multiple option types (size + color).
Parameters:
- product_var: Alpine.js expression for product
- on_change: Callback when any variant changes
Expected product structure:
{
options: [
{ name: 'Size', values: [...] },
{ name: 'Color', values: [...] }
],
variants: [
{ id: 1, options: { size: 'M', color: 'Red' }, stock: 10, price: 99.99 }
]
}
Usage:
{{ multi_variant_selector(product_var='product') }}
#}
{% macro multi_variant_selector(
product_var='product',
on_change=none
) %}
<div
x-data="{
selectedOptions: {},
get matchingVariant() {
return {{ product_var }}.variants?.find(v => {
return Object.keys(this.selectedOptions).every(
key => v.options[key] === this.selectedOptions[key]
);
}) || null;
},
selectOption(optionName, value) {
this.selectedOptions[optionName] = value;
{{ on_change if on_change else '' }}
},
isOptionAvailable(optionName, value) {
const testOptions = { ...this.selectedOptions, [optionName]: value };
return {{ product_var }}.variants?.some(v => {
return Object.keys(testOptions).every(
key => v.options[key] === testOptions[key]
) && v.stock > 0;
});
}
}"
class="space-y-4"
>
<template x-for="option in {{ product_var }}.options" :key="option.name">
<div class="space-y-2">
{# Option Label #}
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="option.name"></label>
<span
x-show="selectedOptions[option.name]"
class="text-sm text-gray-600 dark:text-gray-400"
x-text="selectedOptions[option.name]"
></span>
</div>
{# Option Values #}
<div class="flex flex-wrap gap-2">
<template x-for="value in option.values" :key="value">
<button
type="button"
@click="selectOption(option.name, value)"
:disabled="!isOptionAvailable(option.name, value)"
class="px-4 py-2 text-sm font-medium rounded-lg border-2 transition-all"
:class="{
'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': selectedOptions[option.name] === value,
'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 text-gray-700 dark:text-gray-300': selectedOptions[option.name] !== value && isOptionAvailable(option.name, value),
'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed line-through': !isOptionAvailable(option.name, value)
}"
x-text="value"
></button>
</template>
</div>
</div>
</template>
{# Selected Variant Info #}
<div x-show="matchingVariant" class="pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">
<span x-show="matchingVariant?.stock > 10" class="text-green-600 dark:text-green-400">In Stock</span>
<span x-show="matchingVariant?.stock > 0 && matchingVariant?.stock <= 10" class="text-orange-600 dark:text-orange-400" x-text="'Only ' + matchingVariant?.stock + ' left'"></span>
<span x-show="matchingVariant?.stock === 0" class="text-red-600 dark:text-red-400">Out of Stock</span>
</span>
<span x-show="matchingVariant?.sku" class="text-gray-500 dark:text-gray-500" x-text="'SKU: ' + matchingVariant?.sku"></span>
</div>
</div>
</div>
{% endmacro %}