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:
@@ -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
231
app/templates/vendor/analytics.html
vendored
Normal 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
237
app/templates/vendor/notifications.html
vendored
Normal 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 %}
|
||||
2
app/templates/vendor/partials/sidebar.html
vendored
2
app/templates/vendor/partials/sidebar.html
vendored
@@ -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 -->
|
||||
|
||||
@@ -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
199
static/vendor/js/analytics.js
vendored
Normal 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
274
static/vendor/js/notifications.js
vendored
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user