refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
{# app/templates/admin/vendor-product-create.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
{% from 'shared/macros/modals.html' import media_picker_modal %}
|
||||
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
|
||||
|
||||
{% block title %}Create Vendor Product{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorProductCreate(){% endblock %}
|
||||
|
||||
{% block quill_css %}
|
||||
{{ quill_css() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block quill_script %}
|
||||
{{ quill_js() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call detail_page_header("'Create Vendor Product'", '/admin/vendor-products') %}
|
||||
<span>Add a new product to a vendor's catalog</span>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Create Form -->
|
||||
<form @submit.prevent="createProduct()">
|
||||
<!-- Vendor Selection -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Vendor
|
||||
</h3>
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Vendor <span class="text-red-500">*</span></label>
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The vendor whose catalog this product will be added to</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Information (Translations) -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Information <span class="text-red-500">*</span>
|
||||
</h3>
|
||||
|
||||
<!-- Language Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<nav class="flex space-x-4">
|
||||
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
|
||||
<button
|
||||
type="button"
|
||||
@click="activeLanguage = lang"
|
||||
:class="activeLanguage === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400'"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm uppercase"
|
||||
x-text="lang"
|
||||
></button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - English -->
|
||||
<div x-show="activeLanguage === 'en'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (EN) <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.en.title"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='create-desc-editor-en',
|
||||
model='form.translations.en.description',
|
||||
label='Description (EN)',
|
||||
placeholder='Enter product description in English...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - French -->
|
||||
<div x-show="activeLanguage === 'fr'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (FR)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.fr.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='create-desc-editor-fr',
|
||||
model='form.translations.fr.description',
|
||||
label='Description (FR)',
|
||||
placeholder='Enter product description in French...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - German -->
|
||||
<div x-show="activeLanguage === 'de'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (DE)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.de.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='create-desc-editor-de',
|
||||
model='form.translations.de.description',
|
||||
label='Description (DE)',
|
||||
placeholder='Enter product description in German...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - Luxembourgish -->
|
||||
<div x-show="activeLanguage === 'lu'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (LU)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.lu.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='create-desc-editor-lu',
|
||||
model='form.translations.lu.description',
|
||||
label='Description (LU)',
|
||||
placeholder='Enter product description in Luxembourgish...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Identifiers -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Identifiers
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Vendor SKU</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.vendor_sku"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
|
||||
placeholder="XXXX_XXXX_XXXX"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="generateSku()"
|
||||
class="px-3 py-2 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
|
||||
title="Auto-generate SKU"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.brand"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Brand name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.gtin"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
|
||||
placeholder="4007817144145"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN Type</label>
|
||||
<select
|
||||
x-model="form.gtin_type"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
<option value="ean13">EAN-13</option>
|
||||
<option value="ean8">EAN-8</option>
|
||||
<option value="upc">UPC</option>
|
||||
<option value="isbn">ISBN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pricing
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{# noqa: FE-008 - Using raw number input for price with EUR prefix #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price (incl. VAT)</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
x-model.number="form.price"
|
||||
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Sale Price</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
x-model.number="form.sale_price"
|
||||
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
|
||||
<select
|
||||
x-model="form.currency"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">VAT Rate</label>
|
||||
<select
|
||||
x-model.number="form.tax_rate_percent"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="17">17% (Standard)</option>
|
||||
<option value="14">14% (Intermediate)</option>
|
||||
<option value="8">8% (Reduced)</option>
|
||||
<option value="3">3% (Super-reduced)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
|
||||
<select
|
||||
x-model="form.availability"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
<option value="preorder">Preorder</option>
|
||||
<option value="backorder">Backorder</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Images -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Images
|
||||
</h3>
|
||||
|
||||
<!-- Main Image -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Main Image</label>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Preview -->
|
||||
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex-shrink-0">
|
||||
<template x-if="form.primary_image_url">
|
||||
<div class="relative w-full h-full group">
|
||||
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="clearMainImage()"
|
||||
class="p-2 bg-red-500 rounded-full text-white hover:bg-red-600"
|
||||
title="Remove image"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!form.primary_image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="openMediaPickerMain()"
|
||||
:disabled="!form.vendor_id"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span x-html="$icon('photograph', 'w-4 h-4 mr-2')"></span>
|
||||
Browse Media Library
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Or enter URL directly:</p>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.primary_image_url"
|
||||
class="w-64 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="https://... or /uploads/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Images -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Additional Images</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Existing Additional Images -->
|
||||
<template x-for="(imgUrl, index) in form.additional_images" :key="index">
|
||||
<div class="relative w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 group">
|
||||
<img :src="imgUrl" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAdditionalImage(index)"
|
||||
class="p-1.5 bg-red-500 rounded-full text-white hover:bg-red-600"
|
||||
title="Remove image"
|
||||
>
|
||||
<span x-html="$icon('close', 'w-3 h-3')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs text-center py-0.5" x-text="index + 1"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Image Button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="openMediaPickerAdditional()"
|
||||
:disabled="!form.vendor_id"
|
||||
class="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex flex-col items-center justify-center text-gray-400 hover:text-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-6 h-6')"></span>
|
||||
<span class="text-xs mt-1">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Click "Add" to select images from the media library or upload new ones</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Type & Status -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Type & Status
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_digital"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_active"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_featured"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<a
|
||||
href="/admin/vendor-products"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving || !form.vendor_id || !form.translations.en.title"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="saving ? 'Creating...' : 'Create Product'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Media Picker Modal for Main Image -->
|
||||
{{ media_picker_modal(
|
||||
id='mediaPickerMain',
|
||||
show_var='showMediaPicker',
|
||||
vendor_id_var='form.vendor_id',
|
||||
on_select='setMainImage',
|
||||
multi_select=false,
|
||||
title='Select Main Image'
|
||||
) }}
|
||||
|
||||
<!-- Media Picker Modal for Additional Images -->
|
||||
{{ media_picker_modal(
|
||||
id='mediaPickerAdditional',
|
||||
show_var='showMediaPickerAdditional',
|
||||
vendor_id_var='form.vendor_id',
|
||||
on_select='addAdditionalImages',
|
||||
multi_select=true,
|
||||
title='Select Additional Images'
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/product-create.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,358 @@
|
||||
{# app/templates/admin/vendor-product-detail.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Vendor Product Details{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorProductDetail(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call detail_page_header("product?.title || 'Product Details'", '/admin/vendor-products', subtitle_show='product') %}
|
||||
<span x-text="product?.vendor_name || 'Unknown Vendor'"></span>
|
||||
<span class="text-gray-400 mx-2">|</span>
|
||||
<span x-text="product?.vendor_code || ''"></span>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading product details...') }}
|
||||
|
||||
{{ error_state('Error loading product') }}
|
||||
|
||||
<!-- Product Details -->
|
||||
<div x-show="!loading && product">
|
||||
<!-- Info Banner - adapts based on whether product has marketplace source -->
|
||||
<div class="px-4 py-3 mb-6 rounded-lg shadow-md"
|
||||
:class="product?.marketplace_product_id ? 'bg-purple-50 dark:bg-purple-900/20' : 'bg-blue-50 dark:bg-blue-900/20'">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('information-circle', product?.marketplace_product_id ? 'w-5 h-5 text-purple-500 mr-3 mt-0.5 flex-shrink-0' : 'w-5 h-5 text-blue-500 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<!-- Marketplace-sourced product -->
|
||||
<template x-if="product?.marketplace_product_id">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p>
|
||||
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||
This is a vendor-specific copy of a marketplace product. All fields are independently managed.
|
||||
View the source product for comparison.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Directly created product -->
|
||||
<template x-if="!product?.marketplace_product_id">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-700 dark:text-blue-300">Directly Created Product</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
This product was created directly for this vendor without a marketplace source.
|
||||
All product information is managed independently.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- View Source Product - only for marketplace-sourced products -->
|
||||
<a
|
||||
x-show="product?.marketplace_product_id"
|
||||
:href="'/admin/marketplace-products/' + product?.marketplace_product_id"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('database', 'w-4 h-4 mr-2')"></span>
|
||||
View Source Product
|
||||
</a>
|
||||
<a
|
||||
:href="'/admin/vendor-products/' + productId + '/edit'"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="product?.marketplace_product_id ? 'Edit Overrides' : 'Edit Product'"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="toggleActive()"
|
||||
:class="product?.is_active
|
||||
? 'text-red-700 dark:text-red-300 border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
: 'text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 transition-colors duration-150 bg-white dark:bg-gray-700 border rounded-lg">
|
||||
<span x-html="$icon(product?.is_active ? 'x-circle' : 'check-circle', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="product?.is_active ? 'Deactivate' : 'Activate'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="confirmRemove()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-red-600 dark:text-red-400 transition-colors duration-150 bg-white dark:bg-gray-700 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Remove from Catalog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Header with Image -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
<!-- Product Image -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
|
||||
<template x-if="product?.image_url">
|
||||
<img :src="product?.image_url" :alt="product?.title" class="w-full h-full object-contain" />
|
||||
</template>
|
||||
<template x-if="!product?.image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-16 h-16 text-gray-300')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Additional Images -->
|
||||
<div x-show="product?.additional_images?.length > 0" class="mt-4">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Additional Images</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<template x-for="(img, index) in (product?.additional_images || [])" :key="index">
|
||||
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded overflow-hidden">
|
||||
<img :src="img" :alt="'Image ' + (index + 1)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
<!-- Vendor Info Card -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Vendor Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="product?.vendor_name || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor Code</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_code || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Status</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="product?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400' : 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="product?.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
<span x-show="product?.is_featured" class="px-2 py-1 text-xs font-semibold rounded-full text-yellow-700 bg-yellow-100 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Card -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pricing
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Price</p>
|
||||
<p class="text-lg font-bold text-gray-700 dark:text-gray-200" x-text="formatPrice(product?.effective_price, product?.effective_currency)">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Availability</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.availability || 'Not specified'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Information Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Brand</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.brand || 'No brand'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product Type</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="product?.is_digital ? 'text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400' : 'text-orange-700 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400'"
|
||||
x-text="product?.is_digital ? 'Digital' : 'Physical'">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Condition</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.condition || 'Not specified'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Identifiers Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Identifiers
|
||||
</h3>
|
||||
<div class="grid gap-4" :class="product?.marketplace_product_id ? 'md:grid-cols-4' : 'md:grid-cols-3'">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product ID</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.id || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">GTIN/EAN</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.gtin || product?.source_gtin || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p>
|
||||
</div>
|
||||
<!-- Source SKU - only for marketplace-sourced products -->
|
||||
<div x-show="product?.marketplace_product_id">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source SKU</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.source_sku || '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source Information Card - only for marketplace-sourced products -->
|
||||
<template x-if="product?.marketplace_product_id">
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Source Information
|
||||
</h3>
|
||||
<a
|
||||
:href="'/admin/marketplace-products/' + product?.marketplace_product_id"
|
||||
class="flex items-center text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
<span>View Source</span>
|
||||
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-1')"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_marketplace || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_vendor || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace Product ID</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Product Origin Card - for directly created products -->
|
||||
<template x-if="!product?.marketplace_product_id">
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Origin
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<span x-html="$icon('plus-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Direct Creation</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">This product was created directly in the vendor's catalog</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Description Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.title || product?.description">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Content
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Title</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.title || '-'">-</p>
|
||||
</div>
|
||||
<div x-show="product?.description">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Description</p>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none" x-html="product?.description || '-'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Information -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.google_product_category || product?.category_path">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Categories
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div x-show="product?.google_product_category">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Google Product Category</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.google_product_category">-</p>
|
||||
</div>
|
||||
<div x-show="product?.category_path">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Category Path</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.category_path">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Record Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Added to Catalog</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.created_at)">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Updated</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.updated_at)">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Remove Modal -->
|
||||
{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to remove this product from the vendor's catalog?
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="product?.title || 'Untitled'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
This will not delete the source product from the marketplace repository.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showRemoveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="executeRemove()"
|
||||
:disabled="removing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-text="removing ? 'Removing...' : 'Remove'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/product-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,503 @@
|
||||
{# app/templates/admin/vendor-product-edit.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
{% from 'shared/macros/modals.html' import media_picker_modal %}
|
||||
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
|
||||
|
||||
{% block title %}Edit Vendor Product{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorProductEdit(){% endblock %}
|
||||
|
||||
{% block quill_css %}
|
||||
{{ quill_css() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block quill_script %}
|
||||
{{ quill_js() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call detail_page_header("'Edit: ' + (product?.vendor_translations?.en?.title || 'Product')", '/admin/vendor-products', subtitle_show='product') %}
|
||||
<span x-text="product?.vendor_name || 'Unknown Vendor'"></span>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading product...') }}
|
||||
|
||||
{{ error_state('Error loading product') }}
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div x-show="!loading && product" x-cloak>
|
||||
<form @submit.prevent="saveProduct()">
|
||||
<!-- Translations (Tabbed) -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Information <span class="text-red-500">*</span>
|
||||
</h3>
|
||||
|
||||
<!-- Language Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<nav class="flex space-x-4">
|
||||
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
|
||||
<button
|
||||
type="button"
|
||||
@click="activeLanguage = lang"
|
||||
:class="activeLanguage === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400'"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm uppercase"
|
||||
x-text="lang"
|
||||
></button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - English -->
|
||||
<div x-show="activeLanguage === 'en'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (EN) <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.en.title"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='desc-editor-en',
|
||||
model='form.translations.en.description',
|
||||
label='Description (EN)',
|
||||
required=true,
|
||||
placeholder='Enter product description in English...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - French -->
|
||||
<div x-show="activeLanguage === 'fr'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (FR)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.fr.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='desc-editor-fr',
|
||||
model='form.translations.fr.description',
|
||||
label='Description (FR)',
|
||||
placeholder='Enter product description in French...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - German -->
|
||||
<div x-show="activeLanguage === 'de'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (DE)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.de.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='desc-editor-de',
|
||||
model='form.translations.de.description',
|
||||
label='Description (DE)',
|
||||
placeholder='Enter product description in German...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<!-- Translation Fields - Luxembourgish -->
|
||||
<div x-show="activeLanguage === 'lu'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Title (LU)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.translations.lu.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
{{ quill_editor(
|
||||
id='desc-editor-lu',
|
||||
model='form.translations.lu.description',
|
||||
label='Description (LU)',
|
||||
placeholder='Enter product description in Luxembourgish...',
|
||||
min_height='150px',
|
||||
toolbar='standard'
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Identifiers -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Identifiers
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Vendor SKU <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.vendor_sku"
|
||||
required
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
|
||||
placeholder="XXXX_XXXX_XXXX"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="generateSku()"
|
||||
class="px-3 py-2 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
|
||||
title="Auto-generate SKU"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Brand <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.brand"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Brand name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
GTIN/EAN <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.gtin"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
|
||||
placeholder="4007817144145"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
GTIN Type <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="form.gtin_type"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="ean13">EAN-13</option>
|
||||
<option value="ean8">EAN-8</option>
|
||||
<option value="upc">UPC</option>
|
||||
<option value="isbn">ISBN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pricing
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{# noqa: FE-008 - Using raw number input for price with EUR prefix #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Price (incl. VAT) <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
x-model.number="form.price"
|
||||
required
|
||||
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Sale Price (optional)
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
x-model.number="form.sale_price"
|
||||
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Currency <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="form.currency"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
VAT Rate <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model.number="form.tax_rate_percent"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="17">17% (Standard)</option>
|
||||
<option value="14">14% (Intermediate)</option>
|
||||
<option value="8">8% (Reduced)</option>
|
||||
<option value="3">3% (Super-reduced)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Availability
|
||||
</label>
|
||||
<select
|
||||
x-model="form.availability"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
<option value="preorder">Preorder</option>
|
||||
<option value="backorder">Backorder</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Images
|
||||
</h3>
|
||||
|
||||
<!-- Main Image -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Main Image <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Preview -->
|
||||
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 flex-shrink-0">
|
||||
<template x-if="form.primary_image_url">
|
||||
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
|
||||
</template>
|
||||
<template x-if="!form.primary_image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="openMediaPickerMain(); loadMediaLibrary()"
|
||||
class="px-3 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 flex items-center gap-2"
|
||||
>
|
||||
<span x-html="$icon('photograph', 'w-4 h-4')"></span>
|
||||
Browse Media
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
x-show="form.primary_image_url"
|
||||
@click="clearMainImage()"
|
||||
class="px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<!-- URL fallback -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Or enter URL directly:</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.primary_image_url"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="https://... or /uploads/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Images -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Additional Images
|
||||
</label>
|
||||
<div class="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-3 mb-3">
|
||||
<!-- Existing additional images -->
|
||||
<template x-for="(url, index) in form.additional_images" :key="index">
|
||||
<div class="relative group">
|
||||
<div class="w-full aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
|
||||
<img :src="url" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAdditionalImage(index)"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Add button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="openMediaPickerAdditional(); loadMediaLibrary()"
|
||||
class="w-full aspect-square rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex items-center justify-center text-gray-400 hover:text-purple-500 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-8 h-8')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click the + button to add more images from the media library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Picker Modals -->
|
||||
{{ media_picker_modal(
|
||||
id='media-picker-main',
|
||||
show_var='showMediaPicker',
|
||||
vendor_id_var='product?.vendor_id',
|
||||
title='Select Main Image'
|
||||
) }}
|
||||
|
||||
{{ media_picker_modal(
|
||||
id='media-picker-additional',
|
||||
show_var='showMediaPickerAdditional',
|
||||
vendor_id_var='product?.vendor_id',
|
||||
multi_select=true,
|
||||
title='Select Additional Images'
|
||||
) }}
|
||||
|
||||
<!-- Product Type & Status -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Type & Status
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_digital"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_active"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_featured"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Supplier Info -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Supplier Info (Optional)
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Supplier</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.supplier"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Supplier name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Cost (what you pay)</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
x-model.number="form.cost"
|
||||
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<a
|
||||
:href="'/admin/vendor-products/' + productId"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving || !isFormValid()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/product-edit.js') }}"></script>
|
||||
{% endblock %}
|
||||
400
app/modules/catalog/templates/catalog/admin/vendor-products.html
Normal file
400
app/modules/catalog/templates/catalog/admin/vendor-products.html
Normal file
@@ -0,0 +1,400 @@
|
||||
{# app/templates/admin/vendor-products.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Vendor Products{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorProducts(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Vendor Products', subtitle='Browse vendor-specific product catalogs with override capability') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
<a
|
||||
href="/admin/vendor-products/create"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Create Product
|
||||
</a>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading products...') }}
|
||||
|
||||
{{ error_state('Error loading products') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-5">
|
||||
<!-- Card: Total Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Products
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Active Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Featured Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('star', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Featured
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.featured || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Digital Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('code', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Digital
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Physical Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('truck', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Physical
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.physical || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 max-w-xl">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search by title or vendor SKU..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Featured Filter -->
|
||||
<select
|
||||
x-model="filters.is_featured"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Products</option>
|
||||
<option value="true">Featured Only</option>
|
||||
<option value="false">Not Featured</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Table with Pagination -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Source</th>
|
||||
<th class="px-4 py-3">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="products.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('cube', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No vendor products found</p>
|
||||
<p class="text-xs mt-1" x-text="filters.search || filters.vendor_id || filters.is_active ? 'Try adjusting your filters' : 'Copy products from the Marketplace Products page'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Product Rows -->
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<!-- Product Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<!-- Product Image -->
|
||||
<div class="w-12 h-12 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-6 h-6 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Product Details -->
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-sm truncate max-w-xs" x-text="product.title || 'Untitled'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
||||
<template x-if="product.vendor_sku">
|
||||
<p class="text-xs text-gray-400 font-mono">SKU: <span x-text="product.vendor_sku"></span></p>
|
||||
</template>
|
||||
<template x-if="product.is_digital">
|
||||
<span class="inline-flex items-center px-2 py-0.5 mt-1 text-xs font-medium text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
<span x-html="$icon('code', 'w-3 h-3 mr-1')"></span>
|
||||
Digital
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Vendor Info -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p class="font-medium" x-text="product.vendor_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="product.vendor_code || ''"></p>
|
||||
</td>
|
||||
|
||||
<!-- Source (Marketplace) -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p x-text="product.source_marketplace || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[120px]" x-text="'from ' + (product.source_vendor || 'Unknown')"></p>
|
||||
</td>
|
||||
|
||||
<!-- Price -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="product.effective_price">
|
||||
<p class="font-medium" x-text="formatPrice(product.effective_price, product.effective_currency)"></p>
|
||||
</template>
|
||||
<template x-if="!product.effective_price">
|
||||
<p class="text-gray-400">-</p>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs w-fit"
|
||||
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="product.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
<template x-if="product.is_featured">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs w-fit text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100">
|
||||
Featured
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a
|
||||
:href="'/admin/vendor-products/' + product.id"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
<a
|
||||
:href="'/admin/vendor-products/' + product.id + '/edit'"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-blue-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Edit"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="confirmRemove(product)"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-red-600 rounded-lg dark:text-red-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Delete"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
</div>
|
||||
|
||||
<!-- Confirm Remove Modal -->
|
||||
{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to remove this product from the vendor's catalog?
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="productToRemove?.title || 'Untitled'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
This will not delete the source product from the marketplace repository.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showRemoveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="executeRemove()"
|
||||
:disabled="removing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-text="removing ? 'Removing...' : 'Remove'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('catalog_static', path='admin/js/products.js') }}"></script>
|
||||
{% endblock %}
|
||||
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal file
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal file
@@ -0,0 +1,281 @@
|
||||
{# app/modules/catalog/templates/catalog/storefront/category.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}{% endblock %}
|
||||
|
||||
{# Alpine.js component #}
|
||||
{% block alpine_data %}shopCategory(){% 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>
|
||||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</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" x-text="categoryName">
|
||||
{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
|
||||
<span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span> in this category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Sort Bar #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span x-text="products.length" class="font-semibold"></span> of <span x-text="total" class="font-semibold"></span> products
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
|
||||
<select
|
||||
x-model="sortBy"
|
||||
@change="loadProducts()"
|
||||
class="px-4 py-2 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"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price-low">Price: Low to High</option>
|
||||
<option value="price-high">Price: High to Low</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div>
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div x-show="!loading && 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>
|
||||
<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 to Cart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# No Products Message #}
|
||||
<div x-show="!loading && products.length === 0" class="col-span-full 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">📦</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
No Products in This Category
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Check back later or browse other categories.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse All Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
<div x-show="!loading && 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>
|
||||
// Pass category slug from template to JavaScript
|
||||
window.CATEGORY_SLUG = '{{ category_slug | default("") }}';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shopCategory', () => ({
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
categorySlug: window.CATEGORY_SLUG,
|
||||
categoryName: '',
|
||||
products: [],
|
||||
total: 0,
|
||||
loading: true,
|
||||
sortBy: 'newest',
|
||||
|
||||
// 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);
|
||||
|
||||
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] Category page initializing...');
|
||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
||||
|
||||
// Convert slug to display name
|
||||
this.categoryName = this.categorySlug
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
await this.loadProducts();
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.currentPage - 1) * this.perPage,
|
||||
limit: this.perPage,
|
||||
category: this.categorySlug
|
||||
});
|
||||
|
||||
if (this.sortBy) {
|
||||
params.append('sort', this.sortBy);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading category products from /api/v1/shop/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
|
||||
this.products = data.products;
|
||||
this.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load category products:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async goToPage(page) {
|
||||
if (page < 1 || page > this.totalPages) return;
|
||||
this.currentPage = page;
|
||||
await this.loadProducts();
|
||||
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} 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 %}
|
||||
426
app/modules/catalog/templates/catalog/storefront/product.html
Normal file
426
app/modules/catalog/templates/catalog/storefront/product.html
Normal file
@@ -0,0 +1,426 @@
|
||||
{# app/modules/catalog/templates/catalog/storefront/product.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
|
||||
|
||||
{# Alpine.js component #}
|
||||
{% block alpine_data %}productDetail(){% 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>
|
||||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
|
||||
</div>
|
||||
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Product Detail #}
|
||||
<div x-show="!loading && product" class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
|
||||
{# Product Images #}
|
||||
<div class="product-images">
|
||||
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
|
||||
<img
|
||||
:src="selectedImage || '/static/shop/img/placeholder.svg'"
|
||||
@error="selectedImage = '/static/shop/img/placeholder.svg'"
|
||||
:alt="product?.marketplace_product?.title"
|
||||
class="w-full h-auto object-contain"
|
||||
style="max-height: 600px;"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# Thumbnail Gallery #}
|
||||
<div x-show="product?.marketplace_product?.images?.length > 1" class="grid grid-cols-4 gap-2">
|
||||
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`Product image ${index + 1}`"
|
||||
class="w-full aspect-square object-cover rounded-lg cursor-pointer border-2 transition-all"
|
||||
:class="selectedImage === image ? 'border-primary' : 'border-transparent hover:border-gray-300'"
|
||||
@click="selectedImage = image"
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Product Info #}
|
||||
<div class="product-info-detail">
|
||||
<h1 x-text="product?.marketplace_product?.title" class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Product
|
||||
</h1>
|
||||
|
||||
{# Brand & Category #}
|
||||
<div class="flex flex-wrap gap-4 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<span x-show="product?.marketplace_product?.brand" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
|
||||
</span>
|
||||
<span x-show="product?.marketplace_product?.google_product_category" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Price #}
|
||||
<div class="mb-6">
|
||||
<div x-show="product?.sale_price && product?.sale_price < product?.price">
|
||||
<span class="text-xl text-gray-500 line-through mr-3">€<span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
|
||||
<span class="text-4xl font-bold text-red-600">€<span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
|
||||
<span class="ml-2 inline-block bg-red-600 text-white px-3 py-1 rounded-full text-sm font-semibold">SALE</span>
|
||||
</div>
|
||||
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
|
||||
<span class="text-4xl font-bold text-gray-800 dark:text-gray-200">€<span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Availability #}
|
||||
<div class="mb-6">
|
||||
<span
|
||||
x-show="product?.available_inventory > 0"
|
||||
class="inline-block px-4 py-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-lg font-semibold"
|
||||
>
|
||||
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
|
||||
</span>
|
||||
<span
|
||||
x-show="!product?.available_inventory || product?.available_inventory <= 0"
|
||||
class="inline-block px-4 py-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-lg font-semibold"
|
||||
>
|
||||
✗ Out of Stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<div class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-xl font-semibold mb-3">Description</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 leading-relaxed" x-text="product?.marketplace_product?.description || 'No description available'"></p>
|
||||
</div>
|
||||
|
||||
{# Additional Details #}
|
||||
<div x-show="hasAdditionalDetails" class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-xl font-semibold mb-3">Product Details</h3>
|
||||
<ul class="space-y-2">
|
||||
<li x-show="product?.marketplace_product?.gtin" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
|
||||
</li>
|
||||
<li x-show="product?.condition" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Condition:</strong> <span x-text="product?.condition"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.color" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.size" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.material" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Add to Cart Section #}
|
||||
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
|
||||
{# Quantity Selector #}
|
||||
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
|
||||
<div class="mb-4">
|
||||
<label class="block font-semibold text-lg mb-2">Quantity:</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="decreaseQuantity()"
|
||||
:disabled="quantity <= (product?.min_quantity || 1)"
|
||||
class="w-10 h-10 flex items-center justify-center border-2 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"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="quantity"
|
||||
:min="product?.min_quantity || 1"
|
||||
:max="product?.max_quantity || product?.available_inventory"
|
||||
class="w-20 text-center px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg font-semibold dark:bg-gray-700 dark:text-white"
|
||||
@change="validateQuantity()"
|
||||
>
|
||||
<button
|
||||
@click="increaseQuantity()"
|
||||
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
|
||||
class="w-10 h-10 flex items-center justify-center border-2 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"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Add to Cart Button #}
|
||||
<button
|
||||
@click="addToCart()"
|
||||
:disabled="!canAddToCart || addingToCart"
|
||||
class="w-full px-6 py-4 bg-primary text-white rounded-lg font-semibold text-lg hover:bg-primary-dark transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!addingToCart">
|
||||
🛒 Add to Cart
|
||||
</span>
|
||||
<span x-show="addingToCart">
|
||||
<span class="inline-block animate-spin">⏳</span> Adding...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{# Total Price #}
|
||||
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-center">
|
||||
<strong class="text-xl">Total:</strong> <span class="text-2xl font-bold">€<span x-text="totalPrice.toFixed(2)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Related Products #}
|
||||
<div x-show="relatedProducts.length > 0" class="mt-12 pt-12 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-3xl font-bold mb-6">You May Also Like</h2>
|
||||
<div class="product-grid">
|
||||
<template x-for="related in relatedProducts" :key="related.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
|
||||
<a :href="`{{ base_url }}shop/products/${related.id}`">
|
||||
<img
|
||||
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
:alt="related.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover"
|
||||
>
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${related.id}`">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
€<span x-text="parseFloat(related.price).toFixed(2)"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Pass product ID from template to JavaScript
|
||||
window.PRODUCT_ID = {{ product_id }};
|
||||
window.VENDOR_ID = {{ vendor.id }};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('productDetail', () => {
|
||||
const baseData = shopLayoutData();
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
|
||||
// Data
|
||||
product: null,
|
||||
relatedProducts: [],
|
||||
loading: false,
|
||||
addingToCart: false,
|
||||
quantity: 1,
|
||||
selectedImage: null,
|
||||
vendorId: window.VENDOR_ID,
|
||||
productId: window.PRODUCT_ID,
|
||||
|
||||
// Computed properties
|
||||
get canAddToCart() {
|
||||
return this.product?.is_active &&
|
||||
this.product?.available_inventory > 0 &&
|
||||
this.quantity > 0 &&
|
||||
this.quantity <= this.product?.available_inventory;
|
||||
},
|
||||
|
||||
get totalPrice() {
|
||||
const price = this.product?.sale_price || this.product?.price || 0;
|
||||
return price * this.quantity;
|
||||
},
|
||||
|
||||
get hasAdditionalDetails() {
|
||||
return this.product?.marketplace_product?.gtin ||
|
||||
this.product?.condition ||
|
||||
this.product?.marketplace_product?.color ||
|
||||
this.product?.marketplace_product?.size ||
|
||||
this.product?.marketplace_product?.material;
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
console.log('[SHOP] Product detail page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
baseData.init.call(this);
|
||||
}
|
||||
|
||||
console.log('[SHOP] Product ID:', this.productId);
|
||||
console.log('[SHOP] Vendor ID:', this.vendorId);
|
||||
console.log('[SHOP] Session ID:', this.sessionId);
|
||||
|
||||
await this.loadProduct();
|
||||
},
|
||||
|
||||
// Load product details
|
||||
async loadProduct() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
||||
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
this.product = await response.json();
|
||||
console.log('[SHOP] Product loaded:', this.product);
|
||||
|
||||
// Set default image
|
||||
if (this.product?.marketplace_product?.image_link) {
|
||||
this.selectedImage = this.product.marketplace_product.image_link;
|
||||
}
|
||||
|
||||
// Set initial quantity
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
|
||||
// Load related products
|
||||
await this.loadRelatedProducts();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load product:', error);
|
||||
this.showToast('Failed to load product', 'error');
|
||||
// Redirect back to products after error
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/products';
|
||||
}, 2000);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load related products
|
||||
async loadRelatedProducts() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/products?limit=4`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Filter out current product
|
||||
this.relatedProducts = data.products
|
||||
.filter(p => p.id !== parseInt(this.productId))
|
||||
.slice(0, 4);
|
||||
|
||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load related products:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Quantity controls
|
||||
increaseQuantity() {
|
||||
const max = this.product?.max_quantity || this.product?.available_inventory;
|
||||
if (this.quantity < max) {
|
||||
this.quantity++;
|
||||
}
|
||||
},
|
||||
|
||||
decreaseQuantity() {
|
||||
const min = this.product?.min_quantity || 1;
|
||||
if (this.quantity > min) {
|
||||
this.quantity--;
|
||||
}
|
||||
},
|
||||
|
||||
validateQuantity() {
|
||||
const min = this.product?.min_quantity || 1;
|
||||
const max = this.product?.max_quantity || this.product?.available_inventory;
|
||||
|
||||
if (this.quantity < min) {
|
||||
this.quantity = min;
|
||||
} else if (this.quantity > max) {
|
||||
this.quantity = max;
|
||||
}
|
||||
},
|
||||
|
||||
// Add to cart
|
||||
async addToCart() {
|
||||
if (!this.canAddToCart) {
|
||||
console.warn('[SHOP] Cannot add to cart:', {
|
||||
canAddToCart: this.canAddToCart,
|
||||
isActive: this.product?.is_active,
|
||||
inventory: this.product?.available_inventory,
|
||||
quantity: this.quantity
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingToCart = true;
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: parseInt(this.productId),
|
||||
quantity: this.quantity
|
||||
};
|
||||
|
||||
console.log('[SHOP] Adding to cart:', {
|
||||
url,
|
||||
sessionId: this.sessionId,
|
||||
productId: this.productId,
|
||||
quantity: this.quantity,
|
||||
payload
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log('[SHOP] Add to cart response:', {
|
||||
status: response.status,
|
||||
ok: response.ok
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
|
||||
this.cartCount += this.quantity;
|
||||
this.showToast(
|
||||
`${this.quantity} item(s) added to cart!`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Reset quantity to minimum
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error response:', error);
|
||||
throw new Error(error.detail || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
} finally {
|
||||
this.addingToCart = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
246
app/modules/catalog/templates/catalog/storefront/products.html
Normal file
246
app/modules/catalog/templates/catalog/storefront/products.html
Normal file
@@ -0,0 +1,246 @@
|
||||
{# app/modules/catalog/templates/catalog/storefront/products.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}Products{% endblock %}
|
||||
|
||||
{# Alpine.js component #}
|
||||
{% block alpine_data %}shopProducts(){% 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">Products</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">
|
||||
All Products
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Discover our complete collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Filters & Search Bar #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
|
||||
{# Search #}
|
||||
<div class="md:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
class="w-full px-4 py-2 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"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# Category Filter #}
|
||||
<div>
|
||||
<select class="w-full px-4 py-2 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">
|
||||
<option value="">All Categories</option>
|
||||
<option value="home">Home & Living</option>
|
||||
<option value="fashion">Fashion</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="arts">Arts & Crafts</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Sort #}
|
||||
<div>
|
||||
<select class="w-full px-4 py-2 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">
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price-low">Price: Low to High</option>
|
||||
<option value="price-high">Price: High to Low</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div>
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Products Grid #}
|
||||
<div x-show="!loading && 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>
|
||||
<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 to Cart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# No Products Message #}
|
||||
<div x-show="!loading && products.length === 0" class="col-span-full 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">📦</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
No Products Yet
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Products will appear here once they are added to the catalog.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">
|
||||
<strong>For Developers:</strong> Add products through the vendor dashboard or admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Pagination (hidden for now) #}
|
||||
<div x-show="false" class="mt-8 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
Previous
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-primary text-white rounded-lg">
|
||||
1
|
||||
</button>
|
||||
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
2
|
||||
</button>
|
||||
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
3
|
||||
</button>
|
||||
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shopProducts', () => ({
|
||||
...shopLayoutData(),
|
||||
products: [],
|
||||
loading: true,
|
||||
filters: {
|
||||
search: '',
|
||||
category: '',
|
||||
sort: 'newest'
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
perPage: 12,
|
||||
total: 0
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Products page initializing...');
|
||||
await this.loadProducts();
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.pagination.page - 1) * this.pagination.perPage,
|
||||
limit: this.pagination.perPage
|
||||
});
|
||||
|
||||
// Add search filter if present
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading products from /api/v1/shop/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
|
||||
this.products = data.products;
|
||||
this.pagination.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load products:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterProducts() {
|
||||
this.loading = true;
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
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} 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 %}
|
||||
327
app/modules/catalog/templates/catalog/storefront/search.html
Normal file
327
app/modules/catalog/templates/catalog/storefront/search.html
Normal file
@@ -0,0 +1,327 @@
|
||||
{# app/modules/catalog/templates/catalog/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 %}
|
||||
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal file
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal file
@@ -0,0 +1,252 @@
|
||||
{# app/modules/catalog/templates/catalog/storefront/wishlist.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}My Wishlist{% endblock %}
|
||||
|
||||
{# Alpine.js component #}
|
||||
{% block alpine_data %}shopWishlist(){% 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>
|
||||
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">Account</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</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">
|
||||
My Wishlist
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && items.length > 0">
|
||||
<span x-text="items.length" class="font-semibold"></span> saved item<span x-show="items.length !== 1">s</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Wishlist Content #}
|
||||
<div>
|
||||
{# Loading State #}
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
{# Not Logged In Message #}
|
||||
<div x-show="!loading && !isLoggedIn" 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('user', '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">
|
||||
Please Log In
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Log in to your account to view and manage your wishlist.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Wishlist Items Grid #}
|
||||
<div x-show="!loading && isLoggedIn && items.length > 0" class="product-grid">
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden relative">
|
||||
{# Remove from Wishlist Button #}
|
||||
<button
|
||||
@click="removeFromWishlist(item)"
|
||||
class="absolute top-2 right-2 z-10 p-2 bg-white dark:bg-gray-700 rounded-full shadow-md hover:bg-red-50 dark:hover:bg-red-900 transition-colors group"
|
||||
title="Remove from wishlist"
|
||||
>
|
||||
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
|
||||
</button>
|
||||
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`">
|
||||
<img :src="item.product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
:alt="item.product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
|
||||
|
||||
{# Availability #}
|
||||
<div class="mb-3">
|
||||
<span
|
||||
x-show="item.product.available_inventory > 0"
|
||||
class="text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
In Stock
|
||||
</span>
|
||||
<span
|
||||
x-show="!item.product.available_inventory || item.product.available_inventory <= 0"
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
Out of Stock
|
||||
</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(item.product.price)"></span>
|
||||
<span x-show="item.product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(item.product.sale_price)"></span>
|
||||
</div>
|
||||
<button @click.prevent="addToCart(item.product)"
|
||||
:disabled="!item.product.available_inventory || item.product.available_inventory <= 0"
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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 to Cart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Empty Wishlist Message #}
|
||||
<div x-show="!loading && isLoggedIn && items.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('heart', '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">
|
||||
Your Wishlist is Empty
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Save items you like by clicking the heart icon on product pages.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shopWishlist', () => ({
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
items: [],
|
||||
loading: true,
|
||||
isLoggedIn: false,
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Wishlist page initializing...');
|
||||
|
||||
// Check if user is logged in
|
||||
this.isLoggedIn = await this.checkLoginStatus();
|
||||
|
||||
if (this.isLoggedIn) {
|
||||
await this.loadWishlist();
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async checkLoginStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/customers/me');
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadWishlist() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Loading wishlist...');
|
||||
|
||||
const response = await fetch('/api/v1/shop/wishlist');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.isLoggedIn = false;
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
||||
|
||||
this.items = data.items || [];
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load wishlist:', error);
|
||||
this.showToast('Failed to load wishlist', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeFromWishlist(item) {
|
||||
try {
|
||||
console.log('[SHOP] Removing from wishlist:', item);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/wishlist/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.items = this.items.filter(i => i.id !== item.id);
|
||||
this.showToast('Removed from wishlist', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to remove from wishlist');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
||||
this.showToast('Failed to remove from wishlist', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
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 %}
|
||||
174
app/modules/catalog/templates/catalog/vendor/product-create.html
vendored
Normal file
174
app/modules/catalog/templates/catalog/vendor/product-create.html
vendored
Normal file
@@ -0,0 +1,174 @@
|
||||
{# app/templates/vendor/product-create.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
|
||||
{% block title %}Create Product{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorProductCreate(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call detail_page_header("'Create Product'", backUrl) %}
|
||||
<span>Add a new product to your catalog</span>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Create Form -->
|
||||
<form @submit.prevent="createProduct()">
|
||||
<!-- Basic Information -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.title"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.brand"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Brand name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">SKU</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.vendor_sku"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="SKU"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.gtin"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="GTIN/EAN"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pricing
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{# noqa: FE-008 - Using raw number input for price field #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
x-model="form.price"
|
||||
required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
|
||||
<select
|
||||
x-model="form.currency"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
|
||||
<select
|
||||
x-model="form.availability"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
<option value="preorder">Preorder</option>
|
||||
<option value="backorder">Backorder</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Status
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_active"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_featured"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_digital"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Description
|
||||
</h3>
|
||||
<textarea
|
||||
x-model="form.description"
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="Product description"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<a
|
||||
:href="backUrl"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving || !form.title"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
|
||||
<span x-text="saving ? 'Creating...' : 'Create Product'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('catalog_static', path='vendor/js/product-create.js') }}"></script>
|
||||
{% endblock %}
|
||||
368
app/modules/catalog/templates/catalog/vendor/products.html
vendored
Normal file
368
app/modules/catalog/templates/catalog/vendor/products.html
vendored
Normal file
@@ -0,0 +1,368 @@
|
||||
{# app/templates/vendor/products.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Products{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorProducts(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Products', subtitle='Manage your product catalog') %}
|
||||
<div class="flex items-center gap-4">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadProducts()', variant='secondary') }}
|
||||
<button
|
||||
@click="createProduct()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading products...') }}
|
||||
|
||||
{{ error_state('Error loading products') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Products</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inactive Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Inactive</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Featured Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('star', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Featured</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.featured">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search products..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm text-gray-700 placeholder-gray-400 bg-gray-50 border border-gray-200 rounded-lg dark:placeholder-gray-500 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="applyFilter()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Featured Filter -->
|
||||
<select
|
||||
x-model="filters.featured"
|
||||
@change="applyFilter()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">All Products</option>
|
||||
<option value="true">Featured Only</option>
|
||||
<option value="false">Not Featured</option>
|
||||
</select>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button
|
||||
x-show="filters.search || filters.status || filters.featured"
|
||||
@click="clearFilters()"
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="!loading && selectedProducts.length > 0"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
class="mb-4 p-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||
<span x-text="selectedProducts.length"></span> product(s) selected
|
||||
</span>
|
||||
<button @click="clearSelection()" class="text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="bulkActivate()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 rounded-lg hover:bg-green-200 dark:bg-green-900 dark:text-green-300 dark:hover:bg-green-800 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
@click="bulkDeactivate()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('x-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Deactivate
|
||||
</button>
|
||||
<button
|
||||
@click="bulkSetFeatured()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-yellow-700 bg-yellow-100 rounded-lg hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('star', 'w-4 h-4 inline mr-1')"></span>
|
||||
Feature
|
||||
</button>
|
||||
<button
|
||||
@click="bulkRemoveFeatured()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Unfeature
|
||||
</button>
|
||||
<button
|
||||
@click="confirmBulkDelete()"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-700 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300 dark:hover:bg-red-800 disabled:opacity-50"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@click="toggleSelectAll()"
|
||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3">SKU</th>
|
||||
<th class="px-4 py-3">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Featured</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(product.id)}">
|
||||
<!-- Checkbox -->
|
||||
<td class="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(product.id)"
|
||||
@click="toggleSelect(product.id)"
|
||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
<!-- Product Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<img
|
||||
x-show="product.image_url"
|
||||
:src="product.image_url"
|
||||
:alt="product.name"
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
<div
|
||||
x-show="!product.image_url"
|
||||
class="flex items-center justify-center w-full h-full text-gray-400"
|
||||
>
|
||||
<span x-html="$icon('photo', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="product.name"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.category || 'No category'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- SKU -->
|
||||
<td class="px-4 py-3 text-sm font-mono" x-text="product.sku || '-'"></td>
|
||||
<!-- Price -->
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(product.price)"></td>
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<button
|
||||
@click="toggleActive(product)"
|
||||
:class="product.is_active
|
||||
? 'px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 font-semibold leading-tight text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="product.is_active ? 'Active' : 'Inactive'"
|
||||
></button>
|
||||
</td>
|
||||
<!-- Featured -->
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
@click="toggleFeatured(product)"
|
||||
:class="product.is_featured ? 'text-yellow-500' : 'text-gray-300 hover:text-yellow-500'"
|
||||
>
|
||||
<span x-html="$icon('star', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="viewProduct(product)"
|
||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||
title="View"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="editProduct(product)"
|
||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
title="Edit"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(product)"
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<tr x-show="products.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('cube', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No products found</p>
|
||||
<p class="text-sm">Add your first product to get started</p>
|
||||
<button
|
||||
@click="createProduct()"
|
||||
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{% call modal_simple('deleteProductModal', 'Delete Product', show_var='showDeleteModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<template x-if="selectedProduct">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to delete <span class="font-semibold" x-text="selectedProduct.name"></span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</template>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showDeleteModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="deleteProduct()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Bulk Delete Confirmation Modal -->
|
||||
{% call modal_simple('bulkDeleteProductModal', 'Delete Selected Products', show_var='showBulkDeleteModal', size='sm') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to delete <span class="font-semibold" x-text="selectedProducts.length"></span> selected product(s)?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showBulkDeleteModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="bulkDelete()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>Delete All</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('catalog_static', path='vendor/js/products.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user