feat: complete vendor frontend parity with admin

Phase 1 - Sidebar Refactor:
- Refactor sidebar to use collapsible sections with Alpine.js
- Add localStorage persistence for section states
- Reorganize navigation into logical groups

Phase 2 - Core JS Files:
- Add products.js: product CRUD, search, filtering, toggle active/featured
- Add orders.js: order list, status management, filtering
- Add inventory.js: stock tracking, adjust/set quantity modals
- Add customers.js: customer list, order history, messaging
- Add team.js: member invite, role management, remove members
- Add profile.js: profile editing with form validation
- Add settings.js: tabbed settings (general, marketplace, notifications)

Templates updated from placeholders to full functional UIs.
Vendor frontend now at ~90% parity with admin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 15:34:38 +01:00
parent d803e1c911
commit 463e1b67d5
18 changed files with 4100 additions and 374 deletions

View File

@@ -1,237 +1,144 @@
{# app/templates/vendor/partials/sidebar.html #}
{#
Vendor sidebar - loads vendor data client-side via JavaScript
Follows same pattern as admin sidebar
#}
{# Collapsible sidebar sections with localStorage persistence - matching admin pattern #}
<!-- Desktop sidebar -->
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
<div class="py-4 text-gray-500 dark:text-gray-400">
<!-- Vendor Branding -->
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
:href="`/vendor/${vendorCode}/dashboard`">
<span class="text-2xl mr-2">🏪</span>
<span x-text="vendor?.name || 'Vendor Portal'"></span>
</a>
{# ============================================================================
REUSABLE MACROS FOR SIDEBAR ITEMS
============================================================================ #}
<!-- Main Navigation -->
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/dashboard`">
<span x-html="$icon('home', 'w-5 h-5')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
{# Macro for collapsible section header #}
{% macro section_header(title, section_key, icon=none) %}
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection('{{ section_key }}')"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center">
{% if icon %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
{% endif %}
{{ title }}
</span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections.{{ section_key }} }"
></span>
</button>
{% endmacro %}
<!-- Products & Inventory Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('cube', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Products
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'products'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'products' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/products`">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
<span class="ml-4">All Products</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'marketplace'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'marketplace' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/marketplace`">
<span x-html="$icon('download', 'w-5 h-5')"></span>
<span class="ml-4">Marketplace Import</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'inventory'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'inventory' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/inventory`">
<span x-html="$icon('clipboard-list', 'w-5 h-5')"></span>
<span class="ml-4">Inventory</span>
</a>
</li>
</ul>
{# Macro for collapsible section content wrapper #}
{% macro section_content(section_key) %}
<ul
x-show="openSections.{{ section_key }}"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
{{ caller() }}
</ul>
{% endmacro %}
<!-- Sales Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('chart-bar', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Sales
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'orders'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'orders' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/orders`">
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="ml-4">Orders</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'letzshop'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'letzshop' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/letzshop`">
<span x-html="$icon('external-link', 'w-5 h-5')"></span>
<span class="ml-4">Letzshop Orders</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'customers'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'customers' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/customers`">
<span x-html="$icon('users', 'w-5 h-5')"></span>
<span class="ml-4">Customers</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'messages'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'messages' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/messages`">
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
<span class="ml-4">Messages</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'invoices'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'invoices' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/invoices`">
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
<span class="ml-4">Invoices</span>
</a>
</li>
</ul>
{# Macro for menu item - uses vendorCode for dynamic URLs #}
{% macro menu_item(page_id, path, icon, label) %}
<li class="relative px-6 py-3">
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/{{ path }}`">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
<span class="ml-4">{{ label }}</span>
</a>
</li>
{% endmacro %}
<!-- Shop Customization Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('paint-brush', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Shop
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'content-pages'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'content-pages' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/content-pages`">
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
<span class="ml-4">Content Pages</span>
</a>
</li>
</ul>
{# ============================================================================
SIDEBAR CONTENT (shared between desktop and mobile)
============================================================================ #}
<!-- Settings Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('cog', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Settings
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'team'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'team' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/team`">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
<span class="ml-4">Team</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'profile'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'profile' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/profile`">
<span x-html="$icon('user', 'w-5 h-5')"></span>
<span class="ml-4">Profile</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'settings'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'settings' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/settings`">
<span x-html="$icon('adjustments', 'w-5 h-5')"></span>
<span class="ml-4">Settings</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'billing'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'billing' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/billing`">
<span x-html="$icon('credit-card', 'w-5 h-5')"></span>
<span class="ml-4">Billing</span>
</a>
</li>
</ul>
{% macro sidebar_content() %}
<div class="py-4 text-gray-500 dark:text-gray-400">
<!-- Vendor Branding -->
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
:href="`/vendor/${vendorCode}/dashboard`">
<span class="text-2xl mr-2">🏪</span>
<span x-text="vendor?.name || 'Vendor Portal'"></span>
</a>
<!-- Quick Actions -->
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@click="$dispatch('open-add-product-modal')">
Add Product
<span class="ml-2" aria-hidden="true">+</span>
</button>
</div>
<!-- Dashboard (always visible) -->
<ul class="mt-6">
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
</ul>
<!-- Products & Inventory Section -->
{{ section_header('Products & Inventory', 'products', 'cube') }}
{% call section_content('products') %}
{{ menu_item('products', 'products', 'shopping-bag', 'All Products') }}
{{ menu_item('inventory', 'inventory', 'clipboard-list', 'Inventory') }}
{{ menu_item('marketplace', 'marketplace', 'download', 'Marketplace Import') }}
{% endcall %}
<!-- Sales & Orders Section -->
{{ section_header('Sales & Orders', 'sales', 'shopping-cart') }}
{% call section_content('sales') %}
{{ menu_item('orders', 'orders', 'document-text', 'Orders') }}
{{ menu_item('letzshop', 'letzshop', 'external-link', 'Letzshop Orders') }}
{{ menu_item('invoices', 'invoices', 'currency-euro', 'Invoices') }}
{% endcall %}
<!-- Customers & Communication Section -->
{{ section_header('Customers', 'customers', 'users') }}
{% call section_content('customers') %}
{{ menu_item('customers', 'customers', 'user-group', 'All Customers') }}
{{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }}
{% endcall %}
<!-- Shop & Content Section -->
{{ section_header('Shop & Content', 'shop', 'color-swatch') }}
{% call section_content('shop') %}
{{ menu_item('content-pages', 'content-pages', 'document-text', 'Content Pages') }}
{# Future: Theme customization, if enabled for vendor tier
{{ menu_item('theme', 'theme', 'paint-brush', 'Theme') }}
#}
{% endcall %}
<!-- Account & Settings Section -->
{{ section_header('Account & Settings', 'account', 'cog') }}
{% call section_content('account') %}
{{ menu_item('team', 'team', 'user-group', 'Team') }}
{{ menu_item('profile', 'profile', 'user', 'Profile') }}
{{ menu_item('billing', 'billing', 'credit-card', 'Billing') }}
{{ menu_item('settings', 'settings', 'adjustments', 'Settings') }}
{% endcall %}
<!-- Quick Actions -->
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@click="$dispatch('open-add-product-modal')">
<span x-html="$icon('plus', 'w-4 h-4')"></span>
<span class="ml-2">Add Product</span>
</button>
</div>
</div>
{% endmacro %}
{# ============================================================================
DESKTOP SIDEBAR
============================================================================ #}
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
{{ sidebar_content() }}
</aside>
<!-- Mobile sidebar -->
{# ============================================================================
MOBILE SIDEBAR
============================================================================ #}
<!-- Mobile sidebar backdrop -->
<div x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0"
@@ -241,6 +148,7 @@ Follows same pattern as admin sidebar
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
<!-- Mobile sidebar panel -->
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
@@ -251,15 +159,5 @@ Follows same pattern as admin sidebar
x-transition:leave-end="opacity-0 transform -translate-x-20"
@click.away="closeSideMenu"
@keydown.escape="closeSideMenu">
<!-- Mobile navigation - same structure as desktop -->
<div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
:href="`/vendor/${vendorCode}/dashboard`">
<span class="text-2xl mr-2">🏪</span>
<span x-text="vendor?.name || 'Vendor Portal'"></span>
</a>
<!-- Same menu structure as desktop (omitted for brevity) -->
<!-- Copy the entire menu structure from desktop sidebar above -->
</div>
</aside>
{{ sidebar_content() }}
</aside>