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>
328 lines
13 KiB
HTML
328 lines
13 KiB
HTML
{# app/templates/storefront/search.html #}
|
|
{# noqa: FE-001 - Shop uses custom pagination with vendor-themed styling (CSS variables) #}
|
|
{% extends "storefront/base.html" %}
|
|
|
|
{% block title %}Search Results{% if query %} for "{{ query }}"{% endif %}{% endblock %}
|
|
|
|
{# Alpine.js component #}
|
|
{% block alpine_data %}shopSearch(){% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
{# Breadcrumbs #}
|
|
<div class="breadcrumb mb-6">
|
|
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
|
<span>/</span>
|
|
<span class="text-gray-900 dark:text-gray-200 font-medium">Search</span>
|
|
</div>
|
|
|
|
{# Page Header #}
|
|
<div class="mb-8">
|
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-2">
|
|
<span x-show="query">Search Results for "<span x-text="query"></span>"</span>
|
|
<span x-show="!query">Search Products</span>
|
|
</h1>
|
|
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
|
|
Found <span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span>
|
|
</p>
|
|
</div>
|
|
|
|
{# Search Bar #}
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
|
|
<form @submit.prevent="performSearch" class="flex gap-4">
|
|
<div class="flex-1 relative">
|
|
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
|
|
<span x-html="$icon('search', 'w-5 h-5')"></span>
|
|
</span>
|
|
<input
|
|
type="text"
|
|
x-model="searchInput"
|
|
placeholder="Search products by name, description, SKU..."
|
|
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
|
|
autofocus
|
|
>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center gap-2"
|
|
style="background-color: var(--color-primary)"
|
|
:disabled="searching"
|
|
>
|
|
<span x-show="!searching" x-html="$icon('search', 'w-5 h-5')"></span>
|
|
<span x-show="searching" class="spinner-sm"></span>
|
|
<span class="hidden sm:inline">Search</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{# Results Area #}
|
|
<div>
|
|
{# Loading State #}
|
|
<div x-show="loading" class="flex justify-center items-center py-12">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
|
|
{# No Query Yet #}
|
|
<div x-show="!loading && !query" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
|
<div class="text-6xl mb-4">
|
|
<span x-html="$icon('search', 'w-16 h-16 mx-auto text-gray-400')"></span>
|
|
</div>
|
|
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
|
Start Your Search
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400">
|
|
Enter a search term above to find products
|
|
</p>
|
|
</div>
|
|
|
|
{# Search Results Grid #}
|
|
<div x-show="!loading && query && products.length > 0" class="product-grid">
|
|
<template x-for="product in products" :key="product.id">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
|
<a :href="`{{ base_url }}shop/products/${product.id}`">
|
|
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
|
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
|
:alt="product.marketplace_product?.title"
|
|
class="w-full h-48 object-cover">
|
|
</a>
|
|
<div class="p-4">
|
|
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
|
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
|
|
</a>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
|
|
|
|
{# Brand badge if available #}
|
|
<div x-show="product.brand" class="mb-2">
|
|
<span class="inline-block px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded" x-text="product.brand"></span>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
|
|
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
|
|
</div>
|
|
<button @click.prevent="addToCart(product)"
|
|
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
|
|
style="background-color: var(--color-primary)"
|
|
:title="'Add to Cart'">
|
|
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
|
<span class="hidden sm:inline">Add</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{# No Results Message #}
|
|
<div x-show="!loading && query && products.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
|
<div class="text-6xl mb-4">
|
|
<span x-html="$icon('search-x', 'w-16 h-16 mx-auto text-gray-400')"></span>
|
|
</div>
|
|
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
|
No Results Found
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
|
No products match "<span x-text="query" class="font-medium"></span>"
|
|
</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-500">
|
|
Try different keywords or check the spelling
|
|
</p>
|
|
</div>
|
|
|
|
{# Pagination #}
|
|
<div x-show="!loading && query && totalPages > 1" class="mt-8 flex justify-center">
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="goToPage(currentPage - 1)"
|
|
:disabled="currentPage === 1"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
|
|
<template x-for="page in visiblePages" :key="page">
|
|
<button
|
|
@click="goToPage(page)"
|
|
:class="page === currentPage ? 'bg-primary text-white' : 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
|
class="px-4 py-2 rounded-lg"
|
|
:style="page === currentPage ? 'background-color: var(--color-primary)' : ''"
|
|
x-text="page"
|
|
></button>
|
|
</template>
|
|
|
|
<button
|
|
@click="goToPage(currentPage + 1)"
|
|
:disabled="currentPage === totalPages"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('shopSearch', () => ({
|
|
...shopLayoutData(),
|
|
|
|
// Search state
|
|
searchInput: '',
|
|
query: '',
|
|
products: [],
|
|
total: 0,
|
|
loading: false,
|
|
searching: false,
|
|
|
|
// Pagination
|
|
currentPage: 1,
|
|
perPage: 12,
|
|
|
|
get totalPages() {
|
|
return Math.ceil(this.total / this.perPage);
|
|
},
|
|
|
|
get visiblePages() {
|
|
const pages = [];
|
|
const total = this.totalPages;
|
|
const current = this.currentPage;
|
|
|
|
let start = Math.max(1, current - 2);
|
|
let end = Math.min(total, current + 2);
|
|
|
|
// Adjust to always show 5 pages if possible
|
|
if (end - start < 4) {
|
|
if (start === 1) {
|
|
end = Math.min(total, 5);
|
|
} else {
|
|
start = Math.max(1, total - 4);
|
|
}
|
|
}
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
pages.push(i);
|
|
}
|
|
return pages;
|
|
},
|
|
|
|
async init() {
|
|
console.log('[SHOP] Search page initializing...');
|
|
|
|
// Check for query parameter in URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const urlQuery = urlParams.get('q');
|
|
|
|
if (urlQuery) {
|
|
this.searchInput = urlQuery;
|
|
this.query = urlQuery;
|
|
await this.loadResults();
|
|
}
|
|
},
|
|
|
|
async performSearch() {
|
|
if (!this.searchInput.trim()) {
|
|
return;
|
|
}
|
|
|
|
this.query = this.searchInput.trim();
|
|
this.currentPage = 1;
|
|
|
|
// Update URL without reload
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('q', this.query);
|
|
window.history.pushState({}, '', url);
|
|
|
|
await this.loadResults();
|
|
},
|
|
|
|
async loadResults() {
|
|
if (!this.query) return;
|
|
|
|
this.loading = true;
|
|
this.searching = true;
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
q: this.query,
|
|
skip: (this.currentPage - 1) * this.perPage,
|
|
limit: this.perPage
|
|
});
|
|
|
|
console.log(`[SHOP] Searching: /api/v1/shop/products/search?${params}`);
|
|
|
|
const response = await fetch(`/api/v1/shop/products/search?${params}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
console.log(`[SHOP] Search found ${data.total} results`);
|
|
|
|
this.products = data.products;
|
|
this.total = data.total;
|
|
} catch (error) {
|
|
console.error('[SHOP] Search failed:', error);
|
|
this.showToast('Search failed. Please try again.', 'error');
|
|
this.products = [];
|
|
this.total = 0;
|
|
} finally {
|
|
this.loading = false;
|
|
this.searching = false;
|
|
}
|
|
},
|
|
|
|
async goToPage(page) {
|
|
if (page < 1 || page > this.totalPages) return;
|
|
this.currentPage = page;
|
|
await this.loadResults();
|
|
|
|
// Scroll to top of results
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
},
|
|
|
|
async addToCart(product) {
|
|
console.log('[SHOP] Adding to cart:', product);
|
|
|
|
try {
|
|
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
|
const payload = {
|
|
product_id: product.id,
|
|
quantity: 1
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('[SHOP] Add to cart success:', result);
|
|
this.cartCount += 1;
|
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
|
} else {
|
|
const error = await response.json();
|
|
console.error('[SHOP] Add to cart error:', error);
|
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('[SHOP] Add to cart exception:', error);
|
|
this.showToast('Failed to add to cart', 'error');
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
</script>
|
|
{% endblock %}
|