feat: add vendor notifications and analytics pages (Phase 3)

New pages:
- Notifications center: view, mark read, delete, settings modal
- Analytics: period selector, stats overview, feature-gated advanced metrics

Changes:
- Add routes for /vendor/{code}/notifications and /analytics
- Create notifications.js and analytics.js with full functionality
- Create notifications.html and analytics.html templates
- Update sidebar with Analytics link and Notifications in Customers section
- Update vendor-frontend-parity-plan.md to mark Phase 3 complete

Vendor frontend now at ~95% 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 16:35:52 +01:00
parent 463e1b67d5
commit 646d789af7
7 changed files with 1009 additions and 10 deletions

View File

@@ -527,6 +527,60 @@ async def vendor_billing_page(
)
# ============================================================================
# NOTIFICATIONS
# ============================================================================
@router.get(
"/{vendor_code}/notifications", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_notifications_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
):
"""
Render notifications center page.
JavaScript loads notifications via API.
"""
return templates.TemplateResponse(
"vendor/notifications.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
},
)
# ============================================================================
# ANALYTICS
# ============================================================================
@router.get(
"/{vendor_code}/analytics", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_analytics_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
):
"""
Render analytics and reports page.
JavaScript loads analytics data via API.
"""
return templates.TemplateResponse(
"vendor/analytics.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
},
)
# ============================================================================
# CONTENT PAGES MANAGEMENT
# ============================================================================

231
app/templates/vendor/analytics.html vendored Normal file
View File

@@ -0,0 +1,231 @@
{# app/templates/vendor/analytics.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Analytics{% endblock %}
{% block alpine_data %}vendorAnalytics(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Analytics', subtitle='Track your business performance') %}
<div class="flex items-center gap-4">
<!-- Period Selector -->
<select
x-model="period"
@change="changePeriod(period)"
class="px-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"
>
<template x-for="option in periodOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
{{ refresh_button(loading_var='loading', onclick='loadAllData()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state('Loading analytics...') }}
{{ error_state('Error loading analytics') }}
<!-- Analytics Content -->
<div x-show="!loading && !error" class="space-y-8">
<!-- Overview Stats -->
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<!-- 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="formatNumber(dashboardStats.total_products)">0</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="formatNumber(dashboardStats.active_products)"></span> active
(<span x-text="activeProductPercent"></span>%)
</p>
</div>
</div>
<!-- Orders -->
<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('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Orders</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(dashboardStats.total_orders)">0</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-text="formatNumber(dashboardStats.pending_orders)"></span> pending
</p>
</div>
</div>
<!-- Customers -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Customers</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(dashboardStats.total_customers)">0</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Total registered</p>
</div>
</div>
<!-- Inventory -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="{
'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500': stockHealth.color === 'green',
'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500': stockHealth.color === 'yellow',
'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500': stockHealth.color === 'red'
}">
<span x-html="$icon('archive', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Inventory</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(dashboardStats.total_inventory)">0</p>
<p class="text-xs" :class="{
'text-green-600 dark:text-green-400': stockHealth.color === 'green',
'text-yellow-600 dark:text-yellow-400': stockHealth.color === 'yellow',
'text-red-600 dark:text-red-400': stockHealth.color === 'red'
}">
<span x-text="dashboardStats.low_stock_count"></span> low stock items
</p>
</div>
</div>
</div>
<!-- Period Analytics (if available) -->
<template x-if="analytics">
<div class="grid gap-6 md:grid-cols-3">
<!-- Imports This Period -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Imports</h3>
<span class="text-sm text-gray-500 dark:text-gray-400" x-text="getPeriodLabel()"></span>
</div>
<div class="flex items-center">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('cloud-download', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.imports?.count || 0)"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">import jobs</p>
</div>
</div>
</div>
<!-- Products Added This Period -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Products Added</h3>
<span class="text-sm text-gray-500 dark:text-gray-400" x-text="getPeriodLabel()"></span>
</div>
<div class="flex items-center">
<div class="p-3 mr-4 text-indigo-500 bg-indigo-100 rounded-full dark:text-indigo-100 dark:bg-indigo-500">
<span x-html="$icon('plus-circle', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.catalog?.products_added || 0)"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">new products</p>
</div>
</div>
</div>
<!-- Inventory Locations -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Locations</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">Total</span>
</div>
<div class="flex items-center">
<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('location-marker', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">inventory locations</p>
</div>
</div>
</div>
</div>
</template>
<!-- Feature Upgrade Prompt (if analytics not available) -->
<template x-if="!analytics">
<div class="p-8 bg-white rounded-lg shadow-xs dark:bg-gray-800 text-center">
<div class="mx-auto w-16 h-16 mb-4 flex items-center justify-center bg-purple-100 dark:bg-purple-900 rounded-full">
<span x-html="$icon('chart-bar', 'w-8 h-8 text-purple-600 dark:text-purple-400')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">Advanced Analytics</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Upgrade your plan to access detailed analytics including import trends, product performance, and more.
</p>
<a href="#" @click.prevent="$dispatch('navigate', '/vendor/' + vendorCode + '/billing')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('sparkles', 'w-4 h-4 mr-2')"></span>
View Plans
</a>
</div>
</template>
<!-- Quick Stats Summary -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Quick Summary</h3>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Active Products Rate -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Active Product Rate</p>
<div class="flex items-center">
<p class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="activeProductPercent + '%'"></p>
<span class="ml-2 text-green-500" x-show="activeProductPercent >= 80">
<span x-html="$icon('trending-up', 'w-4 h-4')"></span>
</span>
</div>
</div>
<!-- Featured Products -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Featured Products</p>
<p class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(dashboardStats.featured_products)"></p>
</div>
<!-- Pending Orders -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Pending Orders</p>
<div class="flex items-center">
<p class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(dashboardStats.pending_orders)"></p>
<span class="ml-2 text-yellow-500" x-show="dashboardStats.pending_orders > 0">
<span x-html="$icon('clock', 'w-4 h-4')"></span>
</span>
</div>
</div>
<!-- Stock Health -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Stock Health</p>
<div class="flex items-center">
<span class="px-2 py-1 text-sm font-medium rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-900 dark:text-green-300': stockHealth.color === 'green',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300': stockHealth.color === 'yellow',
'text-red-700 bg-red-100 dark:bg-red-900 dark:text-red-300': stockHealth.color === 'red'
}"
x-text="stockHealth.label"></span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/analytics.js') }}"></script>
{% endblock %}

237
app/templates/vendor/notifications.html vendored Normal file
View File

@@ -0,0 +1,237 @@
{# app/templates/vendor/notifications.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/pagination.html' import pagination_simple %}
{% block title %}Notifications{% endblock %}
{% block alpine_data %}vendorNotifications(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Notifications', subtitle='Stay updated on your store activity') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loadingNotifications', onclick='loadNotifications()', variant='secondary') }}
<button
@click="openSettingsModal()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings
</button>
</div>
{% endcall %}
{{ loading_state('Loading notifications...') }}
{{ error_state('Error loading notifications') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Unread Notifications -->
<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('bell', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Unread</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.unread_count">0</p>
</div>
</div>
<!-- Total Notifications -->
<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('inbox', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
</div>
</div>
<!-- Quick Actions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex-1">
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Quick Actions</p>
<button
x-show="stats.unread_count > 0"
@click="markAllAsRead()"
class="text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400"
>
Mark all as read
</button>
<span x-show="stats.unread_count === 0" class="text-sm text-gray-500 dark:text-gray-400">
All caught up!
</span>
</div>
</div>
</div>
<!-- Filters -->
<div x-show="!loading && !error" class="flex flex-wrap items-center gap-4 px-4 py-3 mb-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<select
x-model="filters.is_read"
@change="page = 1; loadNotifications()"
class="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="">All Notifications</option>
<option value="false">Unread Only</option>
<option value="true">Read Only</option>
</select>
</div>
<!-- Notifications List -->
<div x-show="!loading && !error" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Loading state for list -->
<template x-if="loadingNotifications && notifications.length === 0">
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2 animate-spin')"></span>
<p>Loading notifications...</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!loadingNotifications && notifications.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('bell', 'w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600')"></span>
<p class="font-medium">No notifications</p>
<p class="text-sm mt-1">You're all caught up!</p>
</div>
</template>
<!-- Notifications list -->
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="notif in notifications" :key="notif.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="notif.is_read ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Icon -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="getPriorityClass(notif.priority)">
<span x-html="$icon(getNotificationIcon(notif.type), 'w-5 h-5')"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="notif.title"></p>
<span class="text-xs text-gray-400" x-text="formatDate(notif.created_at)"></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="notif.message"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded capitalize"
:class="getPriorityClass(notif.priority)"
x-text="notif.priority || 'normal'"></span>
<span class="text-xs text-gray-500" x-text="(notif.type || '').replace('_', ' ')"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="notif.action_url">
<a :href="notif.action_url"
class="px-3 py-1 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
View
</a>
</template>
<template x-if="!notif.is_read">
<button @click="markAsRead(notif)"
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Mark read
</button>
</template>
<button @click="deleteNotification(notif.id)"
class="p-1 text-gray-400 hover:text-red-500 transition-colors">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</div>
</li>
</template>
</ul>
<!-- Pagination -->
<div x-show="stats.total > limit" class="flex items-center justify-between px-4 py-3 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, stats.total)"></span> of <span x-text="stats.total"></span>
</span>
<div class="flex items-center gap-2">
<button
@click="prevPage()"
:disabled="page <= 1"
class="px-3 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="`${page} / ${totalPages}`"></span>
<button
@click="nextPage()"
:disabled="page >= totalPages"
class="px-3 py-1 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-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
<!-- Settings Modal -->
<div x-show="showSettingsModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div class="w-full max-w-md bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showSettingsModal = false">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Notification Settings</h3>
<button @click="showSettingsModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 space-y-4">
<!-- Email Notifications -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Email Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Receive notifications via email</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="settingsForm.email_notifications" class="sr-only peer" />
<div class="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-600 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-500 peer-checked:bg-purple-600"></div>
</label>
</div>
<!-- In-App Notifications -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">In-App Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Show notifications in the dashboard</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="settingsForm.in_app_notifications" class="sr-only peer" />
<div class="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-600 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-500 peer-checked:bg-purple-600"></div>
</label>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
Note: Full notification settings management coming soon.
</p>
</div>
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
<button @click="showSettingsModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Cancel
</button>
<button
@click="saveSettings()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Save Settings
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/notifications.js') }}"></script>
{% endblock %}

View File

@@ -72,6 +72,7 @@
<!-- Dashboard (always visible) -->
<ul class="mt-6">
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
{{ menu_item('analytics', 'analytics', 'chart-bar', 'Analytics') }}
</ul>
<!-- Products & Inventory Section -->
@@ -95,6 +96,7 @@
{% call section_content('customers') %}
{{ menu_item('customers', 'customers', 'user-group', 'All Customers') }}
{{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }}
{{ menu_item('notifications', 'notifications', 'bell', 'Notifications') }}
{% endcall %}
<!-- Shop & Content Section -->

View File

@@ -5,7 +5,7 @@
## Executive Summary
The vendor frontend is now approximately 90% complete compared to admin. Phase 1 (Sidebar Refactor) and Phase 2 (Core JS Files) are complete. Only Phase 3 (New Features like notifications and analytics) remains.
The vendor frontend is now approximately 95% complete compared to admin. Phase 1 (Sidebar Refactor), Phase 2 (Core JS Files), and Phase 3 (Notifications + Analytics) are complete. Only bulk operations remain as optional enhancements.
---
@@ -21,6 +21,7 @@ The vendor frontend is now approximately 90% complete compared to admin. Phase 1
### New Sidebar Structure
```
Dashboard
Analytics
├── Products & Inventory (collapsible)
│ ├── All Products
│ ├── Inventory
@@ -31,7 +32,8 @@ Dashboard
│ └── Invoices
├── Customers & Communication (collapsible)
│ ├── Customers
── Messages
── Messages
│ └── Notifications
├── Shop & Content (collapsible)
│ └── Content Pages
└── Account & Settings (collapsible)
@@ -97,8 +99,8 @@ Dashboard
| Profile | - | ✅ | Complete |
| Settings | ✅ | ✅ | Complete |
| Content Pages | ✅ | ✅ | Complete |
| Notifications | ✅ | | Missing page + JS |
| Analytics | ✅ | | Missing page |
| Notifications | ✅ | | Complete |
| Analytics | ✅ | | Complete |
---
@@ -106,8 +108,8 @@ Dashboard
| Type | Admin | Vendor | Target |
|------|-------|--------|--------|
| Total JS Files | 52 | 18 | 20+ |
| Page Coverage | ~90% | ~90% | 90%+ |
| Total JS Files | 52 | 20 | 20+ |
| Page Coverage | ~90% | ~95% | 90%+ |
---
@@ -142,7 +144,7 @@ Dashboard
- [x] settings.js
- [x] content-pages.js (already exists)
### Phase 3: New Features
- [ ] Notifications center
- [ ] Analytics page
- [ ] Bulk operations
### Phase 3: New Features
- [x] Notifications center
- [x] Analytics page
- [ ] Bulk operations (optional enhancement)

199
static/vendor/js/analytics.js vendored Normal file
View File

@@ -0,0 +1,199 @@
// static/vendor/js/analytics.js
/**
* Vendor analytics and reports page logic
* View business metrics and performance data
*/
const vendorAnalyticsLog = window.LogConfig.loggers.vendorAnalytics ||
window.LogConfig.createLogger('vendorAnalytics', false);
vendorAnalyticsLog.info('Loading...');
function vendorAnalytics() {
vendorAnalyticsLog.info('vendorAnalytics() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'analytics',
// Loading states
loading: true,
error: '',
// Time period
period: '30d',
periodOptions: [
{ value: '7d', label: 'Last 7 Days' },
{ value: '30d', label: 'Last 30 Days' },
{ value: '90d', label: 'Last 90 Days' },
{ value: '1y', label: 'Last Year' }
],
// Analytics data
analytics: null,
stats: null,
// Dashboard stats (from vendor stats endpoint)
dashboardStats: {
total_products: 0,
active_products: 0,
featured_products: 0,
total_orders: 0,
pending_orders: 0,
total_customers: 0,
total_inventory: 0,
low_stock_count: 0
},
async init() {
vendorAnalyticsLog.info('Analytics init() called');
// Guard against multiple initialization
if (window._vendorAnalyticsInitialized) {
vendorAnalyticsLog.warn('Already initialized, skipping');
return;
}
window._vendorAnalyticsInitialized = true;
try {
await this.loadAllData();
} catch (error) {
vendorAnalyticsLog.error('Init failed:', error);
this.error = 'Failed to initialize analytics page';
} finally {
this.loading = false;
}
vendorAnalyticsLog.info('Analytics initialization complete');
},
/**
* Load all analytics data
*/
async loadAllData() {
this.loading = true;
this.error = '';
try {
// Load analytics and stats in parallel
const [analyticsResponse, statsResponse] = await Promise.all([
this.fetchAnalytics(),
this.fetchStats()
]);
this.analytics = analyticsResponse;
this.dashboardStats = statsResponse;
vendorAnalyticsLog.info('Loaded analytics data');
} catch (error) {
vendorAnalyticsLog.error('Failed to load data:', error);
this.error = error.message || 'Failed to load analytics data';
} finally {
this.loading = false;
}
},
/**
* Fetch analytics data for current period
*/
async fetchAnalytics() {
try {
const response = await apiClient.get(`/vendor/${this.vendorCode}/analytics?period=${this.period}`);
return response;
} catch (error) {
// Analytics might require feature access
if (error.status === 403) {
vendorAnalyticsLog.warn('Analytics feature not available');
return null;
}
throw error;
}
},
/**
* Fetch dashboard stats
*/
async fetchStats() {
try {
const response = await apiClient.get(`/vendor/${this.vendorCode}/dashboard/stats`);
return {
total_products: response.catalog?.total_products || 0,
active_products: response.catalog?.active_products || 0,
featured_products: response.catalog?.featured_products || 0,
total_orders: response.orders?.total || 0,
pending_orders: response.orders?.pending || 0,
total_customers: response.customers?.total || 0,
total_inventory: response.inventory?.total_quantity || 0,
low_stock_count: response.inventory?.low_stock_count || 0
};
} catch (error) {
vendorAnalyticsLog.error('Failed to fetch stats:', error);
return this.dashboardStats;
}
},
/**
* Change time period and reload data
*/
async changePeriod(newPeriod) {
this.period = newPeriod;
await this.loadAllData();
},
/**
* Get period label
*/
getPeriodLabel() {
const option = this.periodOptions.find(p => p.value === this.period);
return option ? option.label : this.period;
},
/**
* Format number with commas
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString();
},
/**
* Format percentage
*/
formatPercent(value) {
if (value === null || value === undefined) return '0%';
return `${value.toFixed(1)}%`;
},
/**
* Calculate active product percentage
*/
get activeProductPercent() {
if (!this.dashboardStats.total_products) return 0;
return (this.dashboardStats.active_products / this.dashboardStats.total_products * 100).toFixed(1);
},
/**
* Calculate pending order percentage
*/
get pendingOrderPercent() {
if (!this.dashboardStats.total_orders) return 0;
return (this.dashboardStats.pending_orders / this.dashboardStats.total_orders * 100).toFixed(1);
},
/**
* Get stock health status
*/
get stockHealth() {
if (this.dashboardStats.low_stock_count === 0) {
return { status: 'good', label: 'Healthy', color: 'green' };
} else if (this.dashboardStats.low_stock_count <= 5) {
return { status: 'warning', label: 'Attention Needed', color: 'yellow' };
} else {
return { status: 'critical', label: 'Critical', color: 'red' };
}
}
};
}

274
static/vendor/js/notifications.js vendored Normal file
View File

@@ -0,0 +1,274 @@
// static/vendor/js/notifications.js
/**
* Vendor notifications center page logic
* View and manage notifications
*/
const vendorNotificationsLog = window.LogConfig.loggers.vendorNotifications ||
window.LogConfig.createLogger('vendorNotifications', false);
vendorNotificationsLog.info('Loading...');
function vendorNotifications() {
vendorNotificationsLog.info('vendorNotifications() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'notifications',
// Loading states
loading: true,
error: '',
loadingNotifications: false,
// Notifications data
notifications: [],
stats: {
total: 0,
unread_count: 0
},
// Pagination
page: 1,
limit: 20,
skip: 0,
// Filters
filters: {
priority: '',
is_read: ''
},
// Settings
settings: null,
showSettingsModal: false,
settingsForm: {
email_notifications: true,
in_app_notifications: true
},
async init() {
vendorNotificationsLog.info('Notifications init() called');
// Guard against multiple initialization
if (window._vendorNotificationsInitialized) {
vendorNotificationsLog.warn('Already initialized, skipping');
return;
}
window._vendorNotificationsInitialized = true;
try {
await this.loadNotifications();
} catch (error) {
vendorNotificationsLog.error('Init failed:', error);
this.error = 'Failed to initialize notifications page';
} finally {
this.loading = false;
}
vendorNotificationsLog.info('Notifications initialization complete');
},
/**
* Load notifications with current filters
*/
async loadNotifications() {
this.loadingNotifications = true;
this.error = '';
try {
this.skip = (this.page - 1) * this.limit;
const params = new URLSearchParams();
params.append('skip', this.skip);
params.append('limit', this.limit);
if (this.filters.is_read === 'false') {
params.append('unread_only', 'true');
}
const response = await apiClient.get(`/vendor/${this.vendorCode}/notifications?${params}`);
this.notifications = response.notifications || [];
this.stats.total = response.total || 0;
this.stats.unread_count = response.unread_count || 0;
vendorNotificationsLog.info(`Loaded ${this.notifications.length} notifications`);
} catch (error) {
vendorNotificationsLog.error('Failed to load notifications:', error);
this.error = error.message || 'Failed to load notifications';
} finally {
this.loadingNotifications = false;
}
},
/**
* Mark notification as read
*/
async markAsRead(notification) {
try {
await apiClient.put(`/vendor/${this.vendorCode}/notifications/${notification.id}/read`);
// Update local state
notification.is_read = true;
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
Utils.showToast('Notification marked as read', 'success');
} catch (error) {
vendorNotificationsLog.error('Failed to mark as read:', error);
Utils.showToast(error.message || 'Failed to mark notification as read', 'error');
}
},
/**
* Mark all notifications as read
*/
async markAllAsRead() {
try {
await apiClient.put(`/vendor/${this.vendorCode}/notifications/mark-all-read`);
// Update local state
this.notifications.forEach(n => n.is_read = true);
this.stats.unread_count = 0;
Utils.showToast('All notifications marked as read', 'success');
} catch (error) {
vendorNotificationsLog.error('Failed to mark all as read:', error);
Utils.showToast(error.message || 'Failed to mark all as read', 'error');
}
},
/**
* Delete notification
*/
async deleteNotification(notificationId) {
if (!confirm('Are you sure you want to delete this notification?')) {
return;
}
try {
await apiClient.delete(`/vendor/${this.vendorCode}/notifications/${notificationId}`);
// Remove from local state
const wasUnread = this.notifications.find(n => n.id === notificationId && !n.is_read);
this.notifications = this.notifications.filter(n => n.id !== notificationId);
this.stats.total = Math.max(0, this.stats.total - 1);
if (wasUnread) {
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
}
Utils.showToast('Notification deleted', 'success');
} catch (error) {
vendorNotificationsLog.error('Failed to delete notification:', error);
Utils.showToast(error.message || 'Failed to delete notification', 'error');
}
},
/**
* Open settings modal
*/
async openSettingsModal() {
try {
const response = await apiClient.get(`/vendor/${this.vendorCode}/notifications/settings`);
this.settingsForm = {
email_notifications: response.email_notifications !== false,
in_app_notifications: response.in_app_notifications !== false
};
this.showSettingsModal = true;
} catch (error) {
vendorNotificationsLog.error('Failed to load settings:', error);
Utils.showToast(error.message || 'Failed to load notification settings', 'error');
}
},
/**
* Save notification settings
*/
async saveSettings() {
try {
await apiClient.put(`/vendor/${this.vendorCode}/notifications/settings`, this.settingsForm);
Utils.showToast('Notification settings saved', 'success');
this.showSettingsModal = false;
} catch (error) {
vendorNotificationsLog.error('Failed to save settings:', error);
Utils.showToast(error.message || 'Failed to save settings', 'error');
}
},
/**
* Get notification icon based on type
*/
getNotificationIcon(type) {
const icons = {
'order_received': 'shopping-cart',
'order_shipped': 'truck',
'order_delivered': 'check-circle',
'low_stock': 'exclamation-triangle',
'import_complete': 'cloud-download',
'import_failed': 'x-circle',
'team_invite': 'user-plus',
'payment_received': 'credit-card',
'system': 'cog'
};
return icons[type] || 'bell';
},
/**
* Get priority color class
*/
getPriorityClass(priority) {
const classes = {
'critical': 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300',
'high': 'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300',
'normal': 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300',
'low': 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
};
return classes[priority] || classes['normal'];
},
/**
* Format date for display
*/
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
// Show relative time for recent dates
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 172800) return 'Yesterday';
// Show full date for older dates
return date.toLocaleDateString();
},
// Pagination methods
get totalPages() {
return Math.ceil(this.stats.total / this.limit);
},
prevPage() {
if (this.page > 1) {
this.page--;
this.loadNotifications();
}
},
nextPage() {
if (this.page < this.totalPages) {
this.page++;
this.loadNotifications();
}
},
goToPage(pageNum) {
this.page = pageNum;
this.loadNotifications();
}
};
}