feat: add Letzshop vendor directory with sync and admin management

- Add LetzshopVendorCache model to store cached vendor data from Letzshop API
- Create LetzshopVendorSyncService for syncing vendor directory
- Add Celery task for background vendor sync
- Create admin page at /admin/letzshop/vendor-directory with:
  - Stats dashboard (total, claimed, unclaimed vendors)
  - Searchable/filterable vendor list
  - "Sync Now" button to trigger sync
  - Ability to create platform vendors from Letzshop cache
- Add API endpoints for vendor directory management
- Add Pydantic schemas for API responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 20:35:46 +01:00
parent 78b14a4b00
commit ccfbbcb804
13 changed files with 2571 additions and 46 deletions

View File

@@ -0,0 +1,430 @@
{# app/templates/admin/letzshop-vendor-directory.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/pagination.html' import pagination_controls %}
{% block title %}Letzshop Vendor Directory{% endblock %}
{% block alpine_data %}letzshopVendorDirectory(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Letzshop Vendor Directory', subtitle='Browse and import vendors from Letzshop marketplace') %}
<div class="flex items-center gap-3">
<button
@click="triggerSync()"
:disabled="syncing"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
<span x-show="!syncing" x-html="$icon('arrow-path', 'w-4 h-4 mr-2')"></span>
<span x-show="syncing" class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span x-text="syncing ? 'Syncing...' : 'Sync from Letzshop'"></span>
</button>
{{ refresh_button(loading_var='loading', onclick='loadVendors()', variant='secondary') }}
</div>
{% endcall %}
<!-- Success/Error Messages -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 flex-shrink-0')"></span>
<span x-text="successMessage"></span>
<button @click="successMessage = ''" class="ml-auto">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
{{ error_state('Error', show_condition='error && !loading') }}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Vendors</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('building-storefront', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Active</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Claimed</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.claimed_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('user-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Unclaimed</p>
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="stats.unclaimed_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('user-plus', 'w-5 h-5 text-amber-600 dark:text-amber-400')"></span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" x-show="stats.last_synced_at">
Last sync: <span x-text="formatDate(stats.last_synced_at)"></span>
</p>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input
type="text"
x-model="filters.search"
@input.debounce.300ms="loadVendors()"
placeholder="Search by name..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<input
type="text"
x-model="filters.city"
@input.debounce.300ms="loadVendors()"
placeholder="Filter by city..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<input
type="text"
x-model="filters.category"
@input.debounce.300ms="loadVendors()"
placeholder="Filter by category..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- Only Unclaimed -->
<div class="flex items-end">
<label class="inline-flex items-center cursor-pointer">
<input
type="checkbox"
x-model="filters.only_unclaimed"
@change="loadVendors()"
class="sr-only peer"
>
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
<span class="ms-3 text-sm font-medium text-gray-700 dark:text-gray-300">Only Unclaimed</span>
</label>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- Vendors Table -->
<div x-show="!loading" x-cloak class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Empty State -->
<div x-show="vendors.length === 0" class="text-center py-12">
<span x-html="$icon('building-storefront', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No vendors found</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
<span x-show="stats.total_vendors === 0">Click "Sync from Letzshop" to import vendors.</span>
<span x-show="stats.total_vendors > 0">Try adjusting your filters.</span>
</p>
</div>
<!-- Table -->
<div x-show="vendors.length > 0" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contact</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="vendor in vendors" :key="vendor.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center">
<div class="flex-shrink-0 w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-400" x-text="vendor.name?.charAt(0).toUpperCase()"></span>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="vendor.name"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.company_name"></div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.email || '-'"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.phone || ''"></div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.city || '-'"></div>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<template x-for="cat in (vendor.categories || []).slice(0, 2)" :key="cat">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200" x-text="cat"></span>
</template>
<span x-show="(vendor.categories || []).length > 2" class="text-xs text-gray-500">+<span x-text="vendor.categories.length - 2"></span></span>
</div>
</td>
<td class="px-6 py-4">
<span
x-show="vendor.is_claimed"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
>
<span x-html="$icon('check', 'w-3 h-3 mr-1')"></span>
Claimed
</span>
<span
x-show="!vendor.is_claimed"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300"
>
Available
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a
:href="vendor.letzshop_url"
target="_blank"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="View on Letzshop"
>
<span x-html="$icon('arrow-top-right-on-square', 'w-5 h-5')"></span>
</a>
<button
@click="showVendorDetail(vendor)"
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
x-show="!vendor.is_claimed"
@click="openCreateVendorModal(vendor)"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
title="Create Platform Vendor"
>
<span x-html="$icon('plus-circle', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="vendors.length > 0" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
Showing <span x-text="((page - 1) * limit) + 1"></span> to <span x-text="Math.min(page * limit, total)"></span> of <span x-text="total"></span> vendors
</div>
<div class="flex items-center gap-2">
<button
@click="page--; loadVendors()"
:disabled="page <= 1"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Previous
</button>
<span class="px-3 py-1 text-sm">Page <span x-text="page"></span></span>
<button
@click="page++; loadVendors()"
:disabled="!hasMore"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Next
</button>
</div>
</div>
</div>
</div>
<!-- Vendor Detail Modal -->
<div
x-show="showDetailModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@keydown.escape.window="showDetailModal = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showDetailModal = false"></div>
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-2xl p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedVendor?.name"></h3>
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<div x-show="selectedVendor" class="space-y-4">
<!-- Company Name -->
<div x-show="selectedVendor?.company_name">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Company</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.company_name"></p>
</div>
<!-- Contact -->
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.email || '-'"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.phone || '-'"></p>
</div>
</div>
<!-- Address -->
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</p>
<p class="text-gray-900 dark:text-white">
<span x-text="selectedVendor?.city || '-'"></span>
</p>
</div>
<!-- Categories -->
<div x-show="selectedVendor?.categories?.length">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Categories</p>
<div class="flex flex-wrap gap-2">
<template x-for="cat in (selectedVendor?.categories || [])" :key="cat">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300" x-text="cat"></span>
</template>
</div>
</div>
<!-- Website -->
<div x-show="selectedVendor?.website">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Website</p>
<a :href="selectedVendor?.website" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.website"></a>
</div>
<!-- Letzshop URL -->
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Letzshop Page</p>
<a :href="selectedVendor?.letzshop_url" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.letzshop_url"></a>
</div>
<!-- Actions -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button @click="showDetailModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
Close
</button>
<button
x-show="!selectedVendor?.is_claimed"
@click="showDetailModal = false; openCreateVendorModal(selectedVendor)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
>
Create Vendor
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Create Vendor Modal -->
<div
x-show="showCreateModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@keydown.escape.window="showCreateModal = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-md p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Create Vendor from Letzshop</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a platform vendor from <strong x-text="createVendorData?.name"></strong>
</p>
<!-- Company Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Company <span class="text-red-500">*</span>
</label>
<select
x-model="createVendorData.company_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">-- Select a company --</option>
<template x-for="company in companies" :key="company.id">
<option :value="company.id" x-text="company.name"></option>
</template>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The vendor will be created under this company</p>
</div>
<!-- Error -->
<div x-show="createError" class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg text-sm">
<span x-text="createError"></span>
</div>
<!-- Actions -->
<div class="pt-4 flex justify-end gap-3">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
Cancel
</button>
<button
@click="createVendor()"
:disabled="!createVendorData.company_id || creating"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
>
<span x-show="!creating">Create Vendor</span>
<span x-show="creating" class="flex items-center">
<span class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
Creating...
</span>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/letzshop-vendor-directory.js') }}"></script>
{% endblock %}