diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 09005187..2c3f0159 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -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 # ============================================================================ diff --git a/app/templates/vendor/analytics.html b/app/templates/vendor/analytics.html new file mode 100644 index 00000000..7f261389 --- /dev/null +++ b/app/templates/vendor/analytics.html @@ -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 %} + +{% call page_header_flex(title='Analytics', subtitle='Track your business performance') %} +
+ + + {{ refresh_button(loading_var='loading', onclick='loadAllData()', variant='secondary') }} +
+{% endcall %} + +{{ loading_state('Loading analytics...') }} + +{{ error_state('Error loading analytics') }} + + +
+ +
+ +
+
+ +
+
+

Total Products

+

0

+

+ active + (%) +

+
+
+ + +
+
+ +
+
+

Total Orders

+

0

+

+ pending +

+
+
+ + +
+
+ +
+
+

Customers

+

0

+

Total registered

+
+
+ + +
+
+ +
+
+

Inventory

+

0

+

+ low stock items +

+
+
+
+ + + + + + + + +
+
+

Quick Summary

+
+
+
+ +
+

Active Product Rate

+
+

+ + + +
+
+ + +
+

Featured Products

+

+
+ + +
+

Pending Orders

+
+

+ + + +
+
+ + +
+

Stock Health

+
+ +
+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/notifications.html b/app/templates/vendor/notifications.html new file mode 100644 index 00000000..ef6abfe0 --- /dev/null +++ b/app/templates/vendor/notifications.html @@ -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 %} + +{% call page_header_flex(title='Notifications', subtitle='Stay updated on your store activity') %} +
+ {{ refresh_button(loading_var='loadingNotifications', onclick='loadNotifications()', variant='secondary') }} + +
+{% endcall %} + +{{ loading_state('Loading notifications...') }} + +{{ error_state('Error loading notifications') }} + + +
+ +
+
+ +
+
+

Unread

+

0

+
+
+ + +
+
+ +
+
+

Total

+

0

+
+
+ + +
+
+

Quick Actions

+ + + All caught up! + +
+
+
+ + +
+ +
+ + +
+ + + + + + + + + + +
+ + Showing - of + +
+ + + +
+
+
+ + +
+
+
+

Notification Settings

+ +
+
+ +
+
+

Email Notifications

+

Receive notifications via email

+
+ +
+ + +
+
+

In-App Notifications

+

Show notifications in the dashboard

+
+ +
+ +

+ Note: Full notification settings management coming soon. +

+
+
+ + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/partials/sidebar.html b/app/templates/vendor/partials/sidebar.html index f52c9e80..d1d3408e 100644 --- a/app/templates/vendor/partials/sidebar.html +++ b/app/templates/vendor/partials/sidebar.html @@ -72,6 +72,7 @@ @@ -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 %} diff --git a/docs/implementation/vendor-frontend-parity-plan.md b/docs/implementation/vendor-frontend-parity-plan.md index 6dc160cd..8b720e5c 100644 --- a/docs/implementation/vendor-frontend-parity-plan.md +++ b/docs/implementation/vendor-frontend-parity-plan.md @@ -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) diff --git a/static/vendor/js/analytics.js b/static/vendor/js/analytics.js new file mode 100644 index 00000000..b94fe3c9 --- /dev/null +++ b/static/vendor/js/analytics.js @@ -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' }; + } + } + }; +} diff --git a/static/vendor/js/notifications.js b/static/vendor/js/notifications.js new file mode 100644 index 00000000..2f6ad223 --- /dev/null +++ b/static/vendor/js/notifications.js @@ -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(); + } + }; +}