feat: implement product search, media library, and vendor customers
- Add full-text product search in ProductService.search_products() searching titles, descriptions, SKUs, brands, and GTINs - Implement complete vendor media library with file uploads, thumbnails, folders, and product associations - Implement vendor customers API with listing, details, orders, statistics, and status management - Add shop search results UI with pagination and add-to-cart - Add vendor media library UI with drag-drop upload and grid view - Add database migration for media_files and product_media tables - Update TODO file with current launch status (~95% complete) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,326 @@
|
||||
{# app/templates/shop/search.html #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{% block title %}Search Results{% endblock %}
|
||||
{% 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">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
|
||||
|
||||
{# TODO: Implement search results #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400">Search results coming soon...</p>
|
||||
{# 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 %}
|
||||
|
||||
Reference in New Issue
Block a user