feat: redesign Letzshop products tab with product listing view
Products Tab Changes:
- Converted to product listing page similar to /admin/marketplace-products
- Added Import/Export buttons in header
- Added product stats cards (total, active, inactive, last sync)
- Added search and filter functionality
- Added product table with pagination
- Import modal for single URL or all languages
Settings Tab Changes:
- Moved batch size setting from products tab
- Moved include inactive checkbox from products tab
- Added export behavior info box
Export Changes:
- New POST endpoint exports all languages (FR, DE, EN)
- CSV files written to exports/letzshop/{vendor_code}/ for scheduler pickup
- Letzshop scheduler can fetch files from this location
API Changes:
- Added vendor_id filter to /admin/vendor-products/stats endpoint
- Added POST /admin/vendors/{id}/export/letzshop for folder export
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,11 +206,12 @@ def get_vendor_products(
|
||||
|
||||
@router.get("/stats", response_model=VendorProductStats)
|
||||
def get_vendor_product_stats(
|
||||
vendor_id: int | None = Query(None, description="Filter stats by vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get vendor product statistics for admin dashboard."""
|
||||
stats = vendor_product_service.get_product_stats(db)
|
||||
stats = vendor_product_service.get_product_stats(db, vendor_id=vendor_id)
|
||||
return VendorProductStats(**stats)
|
||||
|
||||
|
||||
|
||||
@@ -363,3 +363,83 @@ def export_vendor_products_letzshop(
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LetzshopExportRequest(BaseModel):
|
||||
"""Request body for Letzshop export to pickup folder."""
|
||||
|
||||
include_inactive: bool = False
|
||||
|
||||
|
||||
@router.post("/{vendor_identifier}/export/letzshop")
|
||||
def export_vendor_products_letzshop_to_folder(
|
||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||
request: LetzshopExportRequest = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Export vendor products to Letzshop pickup folder (Admin only).
|
||||
|
||||
Generates CSV files for all languages (FR, DE, EN) and places them in a folder
|
||||
that Letzshop scheduler can fetch from. This is the preferred method for
|
||||
automated product sync.
|
||||
|
||||
**Behavior:**
|
||||
- Creates CSV files for each language (fr, de, en)
|
||||
- Places files in: exports/letzshop/{vendor_code}/
|
||||
- Filename format: {vendor_code}_products_{language}.csv
|
||||
|
||||
Returns:
|
||||
JSON with export status and file paths
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path as FilePath
|
||||
|
||||
from app.services.letzshop_export_service import letzshop_export_service
|
||||
|
||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||
|
||||
include_inactive = request.include_inactive if request else False
|
||||
|
||||
# Create export directory
|
||||
export_dir = FilePath(f"exports/letzshop/{vendor.vendor_code.lower()}")
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exported_files = []
|
||||
languages = ["fr", "de", "en"]
|
||||
|
||||
for lang in languages:
|
||||
try:
|
||||
csv_content = letzshop_export_service.export_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
language=lang,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
filename = f"{vendor.vendor_code.lower()}_products_{lang}.csv"
|
||||
filepath = export_dir / filename
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(csv_content)
|
||||
|
||||
exported_files.append({
|
||||
"language": lang,
|
||||
"filename": filename,
|
||||
"path": str(filepath),
|
||||
"size_bytes": os.path.getsize(filepath),
|
||||
})
|
||||
except Exception as e:
|
||||
exported_files.append({
|
||||
"language": lang,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Exported {len([f for f in exported_files if 'error' not in f])} language(s) to {export_dir}",
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"export_directory": str(export_dir),
|
||||
"files": exported_files,
|
||||
}
|
||||
|
||||
@@ -75,12 +75,24 @@ class VendorProductService:
|
||||
|
||||
return result, total
|
||||
|
||||
def get_product_stats(self, db: Session) -> dict:
|
||||
"""Get vendor product statistics for admin dashboard."""
|
||||
total = db.query(func.count(Product.id)).scalar() or 0
|
||||
def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict:
|
||||
"""Get vendor product statistics for admin dashboard.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor ID to filter stats
|
||||
|
||||
Returns:
|
||||
Dict with product counts (total, active, inactive, etc.)
|
||||
"""
|
||||
# Base query filter
|
||||
base_filter = Product.vendor_id == vendor_id if vendor_id else True
|
||||
|
||||
total = db.query(func.count(Product.id)).filter(base_filter).scalar() or 0
|
||||
|
||||
active = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(base_filter)
|
||||
.filter(Product.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
@@ -89,6 +101,7 @@ class VendorProductService:
|
||||
|
||||
featured = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(base_filter)
|
||||
.filter(Product.is_featured == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
@@ -97,6 +110,7 @@ class VendorProductService:
|
||||
# Digital/physical counts
|
||||
digital = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(base_filter)
|
||||
.join(Product.marketplace_product)
|
||||
.filter(Product.marketplace_product.has(is_digital=True))
|
||||
.scalar()
|
||||
@@ -104,17 +118,19 @@ class VendorProductService:
|
||||
)
|
||||
physical = total - digital
|
||||
|
||||
# Count by vendor
|
||||
vendor_counts = (
|
||||
db.query(
|
||||
Vendor.name,
|
||||
func.count(Product.id),
|
||||
# Count by vendor (only when not filtered by vendor_id)
|
||||
by_vendor = {}
|
||||
if not vendor_id:
|
||||
vendor_counts = (
|
||||
db.query(
|
||||
Vendor.name,
|
||||
func.count(Product.id),
|
||||
)
|
||||
.join(Vendor, Product.vendor_id == Vendor.id)
|
||||
.group_by(Vendor.name)
|
||||
.all()
|
||||
)
|
||||
.join(Vendor, Product.vendor_id == Vendor.id)
|
||||
.group_by(Vendor.name)
|
||||
.all()
|
||||
)
|
||||
by_vendor = {name or "unknown": count for name, count in vendor_counts}
|
||||
by_vendor = {name or "unknown": count for name, count in vendor_counts}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -1,236 +1,382 @@
|
||||
{# app/templates/admin/partials/letzshop-products-tab.html #}
|
||||
{# Products tab for admin Letzshop management - Import & Export #}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
{# Products tab for admin Letzshop management - Product listing with Import/Export #}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Import Section -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Products from Letzshop
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Import products from a Letzshop CSV feed into the marketplace catalog.
|
||||
<!-- Header with Import/Export Buttons -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Letzshop Products</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Vendor products synced with Letzshop marketplace</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
:disabled="importing"
|
||||
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="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Importing...' : 'Import'"></span>
|
||||
</button>
|
||||
<!-- Export Button -->
|
||||
<button
|
||||
@click="exportAllLanguages()"
|
||||
:disabled="exporting"
|
||||
class="flex items-center 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 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!exporting" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="exporting ? 'Exporting...' : 'Export'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 mb-6 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="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="productStats.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="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="productStats.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-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="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="productStats.inactive || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Sync -->
|
||||
<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('refresh', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Sync</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.last_sync || 'Never'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Bar -->
|
||||
<div 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="productFilters.search"
|
||||
@input.debounce.300ms="loadProducts()"
|
||||
placeholder="Search by title, GTIN, or 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="productFilters.is_active"
|
||||
@change="productsPage = 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>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadProducts()"
|
||||
:disabled="loadingProducts"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Refresh products"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loadingProducts" class="flex items-center justify-center py-12">
|
||||
<span x-html="$icon('spinner', 'w-8 h-8 text-purple-600')"></span>
|
||||
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading products...</span>
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<div x-show="!loadingProducts">
|
||||
{% 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">Identifiers</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="5" 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 products found</p>
|
||||
<p class="text-xs mt-1" x-text="productFilters.search ? 'Try adjusting your search' : 'Import products to get started'"></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-10 h-10 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-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Product Details -->
|
||||
<div class="min-w-0">
|
||||
<a :href="'/admin/vendor-products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Identifiers -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<template x-if="product.gtin">
|
||||
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="product.vendor_sku">
|
||||
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.vendor_sku" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="!product.gtin && !product.vendor_sku">
|
||||
<p class="text-xs text-gray-400">No identifiers</p>
|
||||
</template>
|
||||
</div>
|
||||
</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 || 'EUR')"></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">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
: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>
|
||||
</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 Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="totalProducts > productsLimit" class="mt-4">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 dark:bg-gray-800 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Showing</span>
|
||||
<span class="font-medium mx-1" x-text="((productsPage - 1) * productsLimit) + 1"></span>
|
||||
<span>to</span>
|
||||
<span class="font-medium mx-1" x-text="Math.min(productsPage * productsLimit, totalProducts)"></span>
|
||||
<span>of</span>
|
||||
<span class="font-medium mx-1" x-text="totalProducts"></span>
|
||||
<span>products</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="productsPage--; loadProducts()"
|
||||
:disabled="productsPage <= 1"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="productsPage++; loadProducts()"
|
||||
:disabled="productsPage * productsLimit >= totalProducts"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div
|
||||
x-show="showImportModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showImportModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Import Products from Letzshop</h3>
|
||||
<button @click="showImportModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Import products from Letzshop CSV feeds. All languages will be imported.
|
||||
</p>
|
||||
|
||||
<!-- Quick Fill Buttons -->
|
||||
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Import
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="startImportAllLanguages()"
|
||||
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"
|
||||
>
|
||||
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
|
||||
Import All Languages
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Imports products from all configured CSV URLs (FR, EN, DE)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 mt-4">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Or import from custom URL:</p>
|
||||
<form @submit.prevent="startImportFromUrl()">
|
||||
<!-- CSV URL -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
CSV URL
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://letzshop.lu/feeds/products.csv"
|
||||
class="block w-full px-3 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-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
class="block w-full px-3 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-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons -->
|
||||
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFillImport('fr')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<span class="fi fi-fr mr-1.5"></span>
|
||||
French
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFillImport('en')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<span class="fi fi-gb mr-1.5"></span>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFillImport('de')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<span class="fi fi-de mr-1.5"></span>
|
||||
German
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
Language for this URL
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="importForm.language = 'fr'"
|
||||
:class="importForm.language === 'fr'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-fr"></span>
|
||||
FR
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="importForm.language = 'fr'"
|
||||
:class="importForm.language === 'fr' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-fr"></span> FR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="importForm.language = 'de'"
|
||||
:class="importForm.language === 'de'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-de"></span>
|
||||
DE
|
||||
<button type="button" @click="importForm.language = 'de'"
|
||||
:class="importForm.language === 'de' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-de"></span> DE
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="importForm.language = 'en'"
|
||||
:class="importForm.language === 'en'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-gb"></span>
|
||||
EN
|
||||
<button type="button" @click="importForm.language = 'en'"
|
||||
:class="importForm.language === 'en' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-gb"></span> EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Products processed per batch (100-5000)
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showImportModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url"
|
||||
class="w-full flex items-center justify-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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Export Products to Letzshop
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Generate a Letzshop-compatible CSV file from this vendor's product catalog.
|
||||
</p>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Export Language
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select the language for product titles and descriptions
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="exportLanguage = 'fr'"
|
||||
:class="exportLanguage === 'fr'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-fr"></span>
|
||||
Francais
|
||||
</button>
|
||||
<button
|
||||
@click="exportLanguage = 'de'"
|
||||
:class="exportLanguage === 'de'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-de"></span>
|
||||
Deutsch
|
||||
</button>
|
||||
<button
|
||||
@click="exportLanguage = 'en'"
|
||||
:class="exportLanguage === 'en'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-gb"></span>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include Inactive -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="exportIncludeInactive"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
Export products that are currently marked as inactive
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
@click="downloadExport()"
|
||||
:disabled="exporting"
|
||||
class="w-full flex items-center justify-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 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!exporting" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="exporting ? 'Generating...' : 'Download CSV'"></span>
|
||||
</button>
|
||||
|
||||
<!-- CSV Info -->
|
||||
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV Format</h4>
|
||||
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Tab-separated values (TSV)
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
UTF-8 encoding
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Google Shopping compatible
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
41 fields including price, stock, images
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{# app/templates/admin/partials/letzshop-settings-tab.html #}
|
||||
{# Settings tab for admin Letzshop management - API credentials and CSV URLs #}
|
||||
{# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- API Configuration Card -->
|
||||
@@ -236,6 +237,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export Settings Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import / Export Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure settings for product import and export operations.
|
||||
</p>
|
||||
|
||||
<!-- Import Settings -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2 text-purple-500')"></span>
|
||||
Import Settings
|
||||
</h4>
|
||||
<div class="pl-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Products processed per batch (100-5000). Higher = faster but more memory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Settings -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<span x-html="$icon('upload', 'w-4 h-4 mr-2 text-green-500')"></span>
|
||||
Export Settings
|
||||
</h4>
|
||||
<div class="pl-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="exportIncludeInactive"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
Export products that are currently marked as inactive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Info Box -->
|
||||
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Export Behavior</h4>
|
||||
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Exports all languages (FR, DE, EN) automatically
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
CSV files are placed in a folder for Letzshop pickup
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Letzshop scheduler fetches files periodically
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carrier Settings Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
|
||||
<div class="p-6">
|
||||
|
||||
Reference in New Issue
Block a user