refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -0,0 +1,366 @@
{% extends "admin/base.html" %}
{% block title %}Email Templates{% endblock %}
{% block alpine_data %}emailTemplatesPage(){% endblock %}
{% block content %}
<div class="py-6">
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Email Templates
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage platform email templates. Vendors can override non-platform-only templates.
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
</div>
<div x-show="!loading" x-cloak>
<!-- Category Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<button
@click="selectedCategory = null"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === null,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== null
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
All Templates
</button>
<template x-for="cat in categories" :key="cat.code">
<button
@click="selectedCategory = cat.code"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === cat.code,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== cat.code
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
<span x-text="cat.name"></span>
</button>
</template>
</nav>
</div>
<!-- Templates List -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Template
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Category
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Languages
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in filteredTemplates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span x-html="$icon('mail', 'h-5 w-5 text-gray-400 mr-3')"></span>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="template.name"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full"
:class="getCategoryClass(template.category)"
x-text="template.category"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-1">
<template x-for="lang in template.languages" :key="lang">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded uppercase"
x-text="lang"></span>
</template>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span x-show="template.is_platform_only"
class="px-2 py-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded-full">
Platform Only
</span>
<span x-show="!template.is_platform_only"
class="px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
Overridable
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button @click="editTemplate(template)"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300 mr-3">
Edit
</button>
<button @click="previewTemplate(template)"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300">
Preview
</button>
</td>
</tr>
</template>
<tr x-show="filteredTemplates.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
No templates found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Edit Template Modal -->
<div x-show="showEditModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="closeEditModal()"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white" x-text="editingTemplate?.name || 'Edit Template'"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="editingTemplate?.code"></p>
</div>
<button @click="closeEditModal()" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
<!-- Language Tabs -->
<div class="mt-4 flex space-x-2">
<template x-for="lang in ['en', 'fr', 'de', 'lb']" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="{
'bg-purple-600 text-white': editLanguage === lang,
'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500': editLanguage !== lang
}"
class="px-3 py-1 text-sm font-medium rounded-md uppercase transition-colors">
<span x-text="lang"></span>
</button>
</template>
</div>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[60vh] overflow-y-auto">
<!-- Loading -->
<div x-show="loadingTemplate" class="flex justify-center py-8">
<span x-html="$icon('spinner', 'h-6 w-6 text-purple-600')"></span>
</div>
<div x-show="!loadingTemplate" class="space-y-4">
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject
</label>
<input type="text"
x-model="editForm.subject"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }}
</p>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Body
</label>
<textarea x-model="editForm.body_html"
rows="12"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text Body
</label>
<textarea x-model="editForm.body_text"
rows="6"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Variables Reference -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables</h4>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editForm.variables || []" :key="variable">
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs font-mono"
x-text="'{{ ' + variable + ' }}'"></span>
</template>
<span x-show="!editForm.variables || editForm.variables.length === 0"
class="text-gray-500 dark:text-gray-400 text-sm">No variables defined</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<div>
<button @click="sendTestEmail()"
:disabled="sendingTest"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-html="$icon('mail', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test Email'"></span>
</button>
</div>
<div class="flex space-x-3">
<button @click="closeEditModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="saveTemplate()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div x-show="showPreviewModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showPreviewModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Email Preview</h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="previewData?.subject"></p>
</div>
<button @click="showPreviewModal = false" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
</div>
<!-- Body -->
<div class="p-6 max-h-[70vh] overflow-y-auto bg-gray-100 dark:bg-gray-900">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<iframe :srcdoc="previewData?.body_html"
class="w-full h-96 border-0"
sandbox="allow-same-origin"></iframe>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end">
<button @click="showPreviewModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Test Email Modal -->
<div x-show="showTestEmailModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showTestEmailModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-md sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Send Test Email</h3>
</div>
<!-- Body -->
<div class="px-6 py-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send to Email
</label>
<input type="email"
x-model="testEmailAddress"
placeholder="your@email.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end space-x-3">
<button @click="showTestEmailModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="confirmSendTestEmail()"
:disabled="!testEmailAddress || sendingTest"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test'"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,336 @@
{# app/templates/admin/messages.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import form_modal %}
{% block title %}Messages{% endblock %}
{% block alpine_data %}adminMessages({{ conversation_id or 'null' }}){% endblock %}
{% block content %}
{% call page_header_flex(title='Messages') %}
<button @click="showComposeModal = true"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
New Conversation
</button>
{% endcall %}
{{ loading_state('Loading conversations...') }}
{{ error_state('Error loading conversations') }}
<!-- Main Messages Layout -->
<div x-show="!loading" class="flex gap-6 h-[calc(100vh-220px)]">
<!-- Conversations List (Left Panel) -->
<div class="w-96 flex-shrink-0 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- Filters -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<select
x-model="filters.conversation_type"
@change="page = 1; loadConversations()"
class="flex-1 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 Types</option>
<option value="admin_vendor">Vendors</option>
<option value="admin_customer">Customers</option>
</select>
<select
x-model="filters.is_closed"
@change="page = 1; loadConversations()"
class="flex-1 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 Status</option>
<option value="false">Open</option>
<option value="true">Closed</option>
</select>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
<template x-if="loadingConversations && conversations.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...</p>
</div>
</template>
<template x-if="!loadingConversations && conversations.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('chat-bubble-left-right', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No conversations</p>
<p class="text-sm mt-1">Start a new conversation</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="conv in conversations" :key="conv.id">
<li
@click="selectConversation(conv.id)"
class="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-purple-50 dark:bg-purple-900/20': selectedConversationId === conv.id,
'border-l-4 border-purple-500': selectedConversationId === conv.id
}"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate" x-text="conv.subject"></span>
<span x-show="conv.unread_count > 0"
class="px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="conv.unread_count"></span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded"
:class="conv.conversation_type === 'admin_vendor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'"
x-text="conv.conversation_type === 'admin_vendor' ? 'Vendor' : 'Customer'"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="conv.other_participant?.name || 'Unknown'"></span>
</div>
<p x-show="conv.last_message_preview"
class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
x-text="conv.last_message_preview"></p>
</div>
<div class="flex flex-col items-end ml-2">
<span class="text-xs text-gray-400" x-text="formatRelativeTime(conv.last_message_at || conv.created_at)"></span>
<span x-show="conv.is_closed"
class="mt-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded dark:bg-gray-700">
Closed
</span>
</div>
</div>
</li>
</template>
</ul>
</div>
<!-- Pagination -->
<div x-show="totalConversations > limit" class="p-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400" x-text="`${skip + 1}-${Math.min(skip + limit, totalConversations)} of ${totalConversations}`"></span>
<div class="flex gap-1">
<button @click="page--; loadConversations()" :disabled="page <= 1"
class="px-2 py-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50">
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<button @click="page++; loadConversations()" :disabled="page * limit >= totalConversations"
class="px-2 py-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50">
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Conversation Detail (Right Panel) -->
<div class="flex-1 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- No conversation selected -->
<template x-if="!selectedConversationId">
<div class="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div class="text-center">
<span x-html="$icon('chat-bubble-left-right', 'w-16 h-16 mx-auto mb-4 text-gray-300')"></span>
<p class="font-medium">Select a conversation</p>
<p class="text-sm mt-1">Or start a new one</p>
</div>
</div>
</template>
<!-- Conversation loaded -->
<template x-if="selectedConversationId && selectedConversation">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="selectedConversation.subject"></h3>
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-500 dark:text-gray-400">
with <span class="font-medium" x-text="getOtherParticipantName()"></span>
</span>
<span x-show="selectedConversation.vendor_name"
class="text-xs text-gray-400">
(<span x-text="selectedConversation.vendor_name"></span>)
</span>
</div>
</div>
<div class="flex items-center gap-2">
<template x-if="!selectedConversation.is_closed">
<button @click="closeConversation()"
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
Close
</button>
</template>
<template x-if="selectedConversation.is_closed">
<button @click="reopenConversation()"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Reopen
</button>
</template>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" x-ref="messagesContainer">
<template x-if="loadingMessages">
<div class="flex items-center justify-center py-8">
<span x-html="$icon('spinner', 'w-6 h-6 animate-spin text-purple-500')"></span>
</div>
</template>
<template x-for="msg in selectedConversation.messages" :key="msg.id">
<div class="flex"
:class="msg.sender_type === 'admin' ? 'justify-end' : 'justify-start'">
<!-- System message -->
<template x-if="msg.is_system_message">
<div class="text-center w-full py-2">
<span class="px-3 py-1 text-xs text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400"
x-text="msg.content"></span>
</div>
</template>
<!-- Regular message -->
<template x-if="!msg.is_system_message">
<div class="max-w-[70%]">
<div class="rounded-lg px-4 py-2"
:class="msg.sender_type === 'admin'
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'">
<p class="text-sm whitespace-pre-wrap" x-text="msg.content"></p>
<!-- Attachments -->
<template x-if="msg.attachments && msg.attachments.length > 0">
<div class="mt-2 space-y-1">
<template x-for="att in msg.attachments" :key="att.id">
<a :href="att.download_url"
target="_blank"
class="flex items-center gap-2 text-xs underline"
:class="msg.sender_type === 'admin' ? 'text-purple-200 hover:text-white' : 'text-purple-600 hover:text-purple-800 dark:text-purple-400'">
<span x-html="att.is_image ? $icon('photo', 'w-4 h-4') : $icon('paper-clip', 'w-4 h-4')"></span>
<span x-text="att.original_filename"></span>
</a>
</template>
</div>
</template>
</div>
<div class="flex items-center gap-2 mt-1 px-1"
:class="msg.sender_type === 'admin' ? 'justify-end' : 'justify-start'">
<span class="text-xs text-gray-400" x-text="msg.sender_name || 'Unknown'"></span>
<span class="text-xs text-gray-400" x-text="formatTime(msg.created_at)"></span>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Reply Form -->
<template x-if="!selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<form @submit.prevent="sendMessage()" class="flex gap-3">
<div class="flex-1">
<textarea
x-model="replyContent"
@keydown.enter.meta="sendMessage()"
@keydown.enter.ctrl="sendMessage()"
rows="2"
placeholder="Type your message... (Cmd/Ctrl+Enter to send)"
class="w-full 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 resize-none"
></textarea>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
<input type="file" multiple @change="handleFileSelect" class="hidden" x-ref="fileInput">
<span x-html="$icon('paper-clip', 'w-5 h-5')"></span>
<span>Attach files</span>
</label>
<template x-if="attachedFiles.length > 0">
<div class="flex items-center gap-2 text-sm text-gray-500">
<span x-text="attachedFiles.length + ' file(s)'"></span>
<button type="button" @click="attachedFiles = []" class="text-red-500 hover:text-red-700">
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
</div>
<button type="submit"
:disabled="!replyContent.trim() && attachedFiles.length === 0 || sendingMessage"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed self-end">
<span x-show="!sendingMessage" x-html="$icon('paper-airplane', 'w-5 h-5')"></span>
<span x-show="sendingMessage" x-html="$icon('spinner', 'w-5 h-5 animate-spin')"></span>
</button>
</form>
</div>
</template>
<!-- Closed message -->
<template x-if="selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 p-4 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">This conversation is closed. Reopen it to send messages.</p>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Compose Modal -->
{% call form_modal('composeModal', 'New Conversation', show_var='showComposeModal', submit_action='createConversation()', submit_text='Start Conversation', loading_var='creatingConversation', loading_text='Creating...') %}
<!-- Recipient Type -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send to</label>
<select
x-model="compose.recipientType"
@change="compose.recipientId = null; loadRecipients()"
class="w-full px-3 py-2 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="">Select type...</option>
<option value="vendor">Vendor</option>
<option value="customer">Customer</option>
</select>
</div>
<!-- Recipient -->
<div x-show="compose.recipientType">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient</label>
<select
x-model="compose.recipientId"
class="w-full px-3 py-2 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="">Select recipient...</option>
<template x-for="r in recipients" :key="r.id">
<option :value="r.id" x-text="r.name + (r.vendor_name ? ' (' + r.vendor_name + ')' : '') + ' - ' + (r.email || '')"></option>
</template>
</select>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
<input
type="text"
x-model="compose.subject"
placeholder="What is this about?"
class="w-full px-3 py-2 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"
>
</div>
<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
<textarea
x-model="compose.message"
rows="4"
placeholder="Type your message..."
class="w-full px-3 py-2 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 resize-none"
></textarea>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/messages.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,361 @@
{# app/templates/admin/notifications.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Notifications{% endblock %}
{% block alpine_data %}adminNotifications(){% endblock %}
{% block content %}
{{ page_header('Notifications & Alerts') }}
{{ loading_state('Loading notifications...') }}
{{ error_state('Error loading notifications') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: 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">
0
</p>
</div>
</div>
<!-- Card: Active Alerts -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<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('exclamation-triangle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Alerts
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.active_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Critical -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Critical
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.critical_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Resolved Today -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Resolved Today
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.resolved_today || 0">
0
</p>
</div>
</div>
</div>
<!-- Tabs -->
<div x-show="!loading" class="mb-6">
<div class="flex border-b border-gray-200 dark:border-gray-700">
<button
@click="activeTab = 'notifications'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'notifications'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Notifications
<span x-show="stats.unread_count > 0"
class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="stats.unread_count"></span>
</button>
<button
@click="activeTab = 'alerts'; loadAlerts()"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'alerts'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Platform Alerts
<span x-show="alertStats.active_alerts > 0"
class="ml-2 px-2 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full dark:bg-orange-600 dark:text-white"
x-text="alertStats.active_alerts"></span>
</button>
</div>
</div>
<!-- Notifications Tab -->
<div x-show="!loading && activeTab === 'notifications'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center gap-4">
<select
x-model="filters.priority"
@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 Priorities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
<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 Status</option>
<option value="false">Unread</option>
<option value="true">Read</option>
</select>
</div>
<button
x-show="stats.unread_count > 0"
@click="markAllAsRead()"
class="px-4 py-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400"
>
Mark all as read
</button>
</div>
<!-- Notifications List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<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')"></span>
<p>Loading notifications...</p>
</div>
</template>
<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')"></span>
<p class="font-medium">No notifications</p>
<p class="text-sm mt-1">You're all caught up!</p>
</div>
</template>
<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">
<!-- Priority indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': notif.priority === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': notif.priority === 'high',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': notif.priority === 'normal',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300': notif.priority === 'low'
}">
<span x-html="getNotificationIcon(notif.type)"></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"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': notif.priority === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': notif.priority === 'high',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': notif.priority === 'normal',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': notif.priority === 'low'
}"
x-text="notif.priority"></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="page--; loadNotifications()"
: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>
<button
@click="page++; loadNotifications()"
:disabled="page * limit >= stats.total"
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>
</div>
<!-- Alerts Tab -->
<div x-show="!loading && activeTab === 'alerts'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<select
x-model="alertFilters.severity"
@change="alertPage = 1; loadAlerts()"
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 Severities</option>
<option value="critical">Critical</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select
x-model="alertFilters.is_resolved"
@change="alertPage = 1; loadAlerts()"
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 Status</option>
<option value="false">Active</option>
<option value="true">Resolved</option>
</select>
</div>
<!-- Alerts List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<template x-if="loadingAlerts && alerts.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')"></span>
<p>Loading alerts...</p>
</div>
</template>
<template x-if="!loadingAlerts && alerts.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield-check', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No alerts</p>
<p class="text-sm mt-1">All systems are running smoothly</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="alert in alerts" :key="alert.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="alert.is_resolved ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Severity indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': alert.severity === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': alert.severity === 'error',
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-300': alert.severity === 'warning',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': alert.severity === 'info'
}">
<span x-html="$icon('exclamation-triangle', '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="alert.title"></p>
<template x-if="alert.occurrence_count > 1">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300"
x-text="alert.occurrence_count + 'x'"></span>
</template>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="alert.description"></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 uppercase"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': alert.severity === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': alert.severity === 'error',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': alert.severity === 'warning',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': alert.severity === 'info'
}"
x-text="alert.severity"></span>
<span class="text-xs text-gray-500" x-text="alert.alert_type"></span>
<span class="text-xs text-gray-400" x-text="'Last: ' + formatDate(alert.last_occurred_at)"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="!alert.is_resolved">
<button @click="resolveAlert(alert)"
class="px-3 py-1 text-xs font-medium text-green-600 bg-green-100 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-300">
Resolve
</button>
</template>
<template x-if="alert.is_resolved">
<span class="px-3 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded dark:bg-gray-700">
Resolved
</span>
</template>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/notifications.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,520 @@
{# app/modules/messaging/templates/messaging/storefront/messages.html #}
{% extends "storefront/base.html" %}
{% block title %}Messages - {{ vendor.name }}{% endblock %}
{% block alpine_data %}shopMessages(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8 flex justify-between items-center">
<div>
<nav class="flex mb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{{ base_url }}shop/account/dashboard"
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary dark:text-gray-400 dark:hover:text-white"
style="--hover-color: var(--color-primary)">
<span class="w-4 h-4 mr-2" x-html="$icon('home', 'w-4 h-4')"></span>
My Account
</a>
</li>
<li>
<div class="flex items-center">
<span class="w-6 h-6 text-gray-400" x-html="$icon('chevron-right', 'w-6 h-6')"></span>
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">Messages</span>
</div>
</li>
</ol>
</nav>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Messages</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">View your conversations with the shop</p>
</div>
</div>
<!-- Loading State -->
<template x-if="loading">
<div class="flex justify-center items-center py-12">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('spinner', 'h-8 w-8 animate-spin')"></span>
</div>
</template>
<!-- Main Content -->
<template x-if="!loading">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<!-- Conversation List View -->
<template x-if="!selectedConversation">
<div>
<!-- Filter Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div class="flex space-x-4">
<button @click="statusFilter = null; loadConversations()"
:class="statusFilter === null ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
style="--color: var(--color-primary)">
All
</button>
<button @click="statusFilter = 'open'; loadConversations()"
:class="statusFilter === 'open' ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
style="--color: var(--color-primary)">
Open
</button>
<button @click="statusFilter = 'closed'; loadConversations()"
:class="statusFilter === 'closed' ? 'text-primary border-primary' : 'text-gray-500 border-transparent'"
class="pb-2 px-1 border-b-2 font-medium text-sm transition-colors"
style="--color: var(--color-primary)">
Closed
</button>
</div>
</div>
<!-- Conversation List -->
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-if="conversations.length === 0">
<div class="px-6 py-12 text-center">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('chat-bubble-left', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No messages</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
You don't have any conversations yet.
</p>
</div>
</template>
<template x-for="conv in conversations" :key="conv.id">
<div @click="selectConversation(conv.id)"
class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:class="conv.unread_count > 0 ? 'font-bold' : ''"
x-text="conv.subject"></h3>
<template x-if="conv.unread_count > 0">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary text-white"
style="background-color: var(--color-primary)"
x-text="conv.unread_count"></span>
</template>
<template x-if="conv.is_closed">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Closed
</span>
</template>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="conv.other_participant_name"></p>
</div>
<div class="flex-shrink-0 ml-4 text-right">
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="formatDate(conv.last_message_at)"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="conv.message_count + ' messages'"></p>
</div>
</div>
</div>
</template>
</div>
<!-- Pagination -->
{# noqa: FE-001 - Custom pagination with currentPage/totalPages vars (not pagination.page/pagination.total) #}
<template x-if="totalPages > 1">
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
<button @click="prevPage()" :disabled="currentPage === 1"
class="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed">
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
</span>
<button @click="nextPage()" :disabled="currentPage === totalPages"
class="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed">
Next
</button>
</div>
</template>
</div>
</template>
<!-- Conversation Detail View -->
<template x-if="selectedConversation">
<div class="flex flex-col h-[600px]">
<!-- Conversation Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-4">
<button @click="backToList()" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<span class="w-5 h-5" x-html="$icon('chevron-left', 'w-5 h-5')"></span>
</button>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedConversation.subject"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedConversation.other_participant_name"></p>
</div>
</div>
<template x-if="selectedConversation.is_closed">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Closed
</span>
</template>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4" x-ref="messagesContainer">
<template x-for="msg in selectedConversation.messages" :key="msg.id">
<div :class="msg.sender_type === 'customer' ? 'flex justify-end' : 'flex justify-start'">
<div :class="msg.sender_type === 'customer' ? 'bg-primary text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'"
class="max-w-xs lg:max-w-md px-4 py-2 rounded-lg"
:style="msg.sender_type === 'customer' ? 'background-color: var(--color-primary)' : ''">
<!-- System message styling -->
<template x-if="msg.is_system_message">
<div class="text-center text-gray-500 dark:text-gray-400 italic text-sm" x-text="msg.content"></div>
</template>
<template x-if="!msg.is_system_message">
<div>
<p class="text-sm whitespace-pre-wrap" x-text="msg.content"></p>
<!-- Attachments -->
<template x-if="msg.attachments && msg.attachments.length > 0">
<div class="mt-2 space-y-1">
<template x-for="att in msg.attachments" :key="att.id">
<a :href="att.download_url"
target="_blank"
class="flex items-center space-x-2 text-xs underline opacity-80 hover:opacity-100">
<span class="w-4 h-4" x-html="$icon('paperclip', 'w-4 h-4')"></span>
<span x-text="att.filename"></span>
</a>
</template>
</div>
</template>
<p class="text-xs mt-1 opacity-70" x-text="formatTime(msg.created_at)"></p>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- Reply Form -->
<template x-if="!selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4">
<form @submit.prevent="sendReply()">
<div class="flex space-x-3">
<div class="flex-1">
<textarea x-model="replyContent"
placeholder="Type your message..."
rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white resize-none"
style="--ring-color: var(--color-primary)"></textarea>
</div>
<div class="flex flex-col justify-end space-y-2">
<!-- File upload -->
<label class="cursor-pointer text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<span class="w-6 h-6" x-html="$icon('paperclip', 'w-6 h-6')"></span>
<input type="file" multiple class="hidden" @change="handleFileSelect($event)">
</label>
<button type="submit"
:disabled="!replyContent.trim() && attachments.length === 0 || sending"
class="px-4 py-2 bg-primary text-white rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
style="background-color: var(--color-primary)">
<span x-show="!sending">Send</span>
<span x-show="sending" class="flex items-center">
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
Sending
</span>
</button>
</div>
</div>
<!-- Selected files preview -->
<template x-if="attachments.length > 0">
<div class="mt-2 flex flex-wrap gap-2">
<template x-for="(file, index) in attachments" :key="index">
<div class="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm">
<span x-text="file.name" class="max-w-[150px] truncate"></span>
<button type="button" @click="removeAttachment(index)" class="text-gray-500 hover:text-red-500">
<span class="w-4 h-4" x-html="$icon('x-mark', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
</template>
</form>
</div>
</template>
<!-- Closed conversation notice -->
<template x-if="selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4 text-center text-gray-500 dark:text-gray-400">
This conversation is closed and cannot receive new messages.
</div>
</template>
</div>
</template>
</div>
</template>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function shopMessages() {
return {
...shopLayoutData(),
loading: true,
conversations: [],
selectedConversation: null,
statusFilter: null,
currentPage: 1,
totalPages: 1,
total: 0,
limit: 20,
replyContent: '',
attachments: [],
sending: false,
pollInterval: null,
// Preloaded conversation ID from URL
preloadConversationId: {{ conversation_id|default('null') }},
async init() {
await this.loadConversations();
// If conversation ID provided in URL, load it
if (this.preloadConversationId) {
await this.selectConversation(this.preloadConversationId);
}
// Start polling for new messages
this.pollInterval = setInterval(() => {
if (this.selectedConversation) {
this.refreshConversation();
} else {
this.loadConversations();
}
}, 30000);
},
async loadConversations() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const params = new URLSearchParams({
skip: (this.currentPage - 1) * this.limit,
limit: this.limit,
});
if (this.statusFilter) {
params.append('status', this.statusFilter);
}
const response = await fetch(`/api/v1/shop/messages?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load conversations');
}
const data = await response.json();
this.conversations = data.conversations;
this.total = data.total;
this.totalPages = Math.ceil(data.total / this.limit);
} catch (error) {
console.error('Error loading conversations:', error);
this.showToast('Failed to load messages', 'error');
} finally {
this.loading = false;
}
},
async selectConversation(conversationId) {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load conversation');
this.selectedConversation = await response.json();
// Scroll to bottom
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
// Update URL without reload
const url = `{{ base_url }}shop/account/messages/${conversationId}`;
history.pushState({}, '', url);
} catch (error) {
console.error('Error loading conversation:', error);
this.showToast('Failed to load conversation', 'error');
}
},
async refreshConversation() {
if (!this.selectedConversation) return;
try {
const token = localStorage.getItem('customer_token');
if (!token) return;
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) return;
const data = await response.json();
const oldCount = this.selectedConversation.messages.length;
this.selectedConversation = data;
// Scroll if new messages
if (data.messages.length > oldCount) {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
} catch (error) {
console.error('Error refreshing conversation:', error);
}
},
backToList() {
this.selectedConversation = null;
this.replyContent = '';
this.attachments = [];
this.loadConversations();
// Update URL
history.pushState({}, '', '{{ base_url }}shop/account/messages');
},
async sendReply() {
if ((!this.replyContent.trim() && this.attachments.length === 0) || this.sending) return;
this.sending = true;
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const formData = new FormData();
formData.append('content', this.replyContent);
for (const file of this.attachments) {
formData.append('attachments', file);
}
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to send message');
}
const data = await response.json();
// Add message to list
this.selectedConversation.messages.push(data.message);
// Clear form
this.replyContent = '';
this.attachments = [];
// Scroll to bottom
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
this.showToast('Message sent', 'success');
} catch (error) {
console.error('Error sending message:', error);
this.showToast(error.message || 'Failed to send message', 'error');
} finally {
this.sending = false;
}
},
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.attachments.push(...files);
event.target.value = ''; // Reset input
},
removeAttachment(index) {
this.attachments.splice(index, 1);
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadConversations();
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.loadConversations();
}
},
formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
// Less than 24 hours
if (diff < 86400000) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Less than 7 days
if (diff < 604800000) {
return date.toLocaleDateString([], { weekday: 'short' });
}
return date.toLocaleDateString();
},
formatTime(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,330 @@
{# app/templates/vendor/email-templates.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_dialog %}
{% block title %}Email Templates{% endblock %}
{% block alpine_data %}vendorEmailTemplates(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Email Templates', subtitle='Customize email templates sent to your customers') %}
{% endcall %}
{{ loading_state('Loading email templates...') }}
{{ error_state('Error loading templates') }}
<!-- Main Content -->
<div x-show="!loading && !error" class="space-y-6">
<!-- Info Banner -->
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-3">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
Customize how emails appear to your customers. Platform templates are used by default,
and you can override them with your own versions. Some templates (billing, subscriptions)
are platform-only and cannot be customized.
</p>
</div>
</div>
</div>
<!-- Templates Table -->
{# noqa: FE-005 - Table has custom header section and styling not compatible with table_wrapper #}
<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">Available Templates</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Click a template to customize it</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Template</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Languages</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in templates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-4">
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="template.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></p>
</td>
<td class="px-4 py-4">
<span
:class="getCategoryClass(template.category)"
class="px-2 py-1 text-xs font-medium rounded-full"
x-text="template.category"
></span>
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<span
:class="template.override_languages.includes(lang)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
class="px-2 py-0.5 text-xs font-medium rounded uppercase"
x-text="lang"
></span>
</template>
</div>
</td>
<td class="px-4 py-4">
<template x-if="template.has_override">
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900/30 dark:text-green-400">
<span x-html="$icon('check-circle', 'w-3 h-3')"></span>
Customized
</span>
</template>
<template x-if="!template.has_override">
<span class="px-2 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400">
Platform Default
</span>
</template>
</td>
<td class="px-4 py-4 text-right">
<button
@click="editTemplate(template)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg dark:text-purple-400 dark:hover:bg-purple-900/20"
>
Customize
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="templates.length === 0">
<div class="p-8 text-center">
<span x-html="$icon('mail', 'w-12 h-12 mx-auto text-gray-400 dark:text-gray-500')"></span>
<p class="mt-2 text-gray-500 dark:text-gray-400">No customizable templates available</p>
</div>
</template>
</div>
</div>
<!-- Edit Template Modal -->
{% call modal_dialog(
show_var="showEditModal",
title_var="editingTemplate ? 'Customize: ' + editingTemplate.name : 'Edit Template'",
size="4xl"
) %}
<template x-if="editingTemplate">
<div class="space-y-6">
<!-- Language Tabs -->
<div class="border-b dark:border-gray-700">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="editLanguage === lang
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="px-4 py-2 text-sm font-medium border-b-2 uppercase"
x-text="lang"
></button>
</template>
</div>
</div>
<!-- Loading -->
<div x-show="loadingTemplate" class="flex items-center justify-center py-8">
<span x-html="$icon('loading', 'w-8 h-8 animate-spin text-purple-600')"></span>
</div>
<!-- Edit Form -->
<div x-show="!loadingTemplate" class="space-y-4">
<!-- Source Indicator -->
<div class="flex items-center gap-2 text-sm">
<template x-if="templateSource === 'vendor_override'">
<span class="text-green-600 dark:text-green-400">Using your customized version</span>
</template>
<template x-if="templateSource === 'platform'">
<span class="text-gray-500 dark:text-gray-400">Using platform default - edit to create your version</span>
</template>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject Line
</label>
<input
type="text"
x-model="editForm.subject"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Email subject..."
/>
</div>
<!-- Variables Info -->
<div x-show="editingTemplate.variables?.length > 0" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables:</p>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editingTemplate.variables" :key="variable">
<code class="px-2 py-0.5 text-xs bg-white dark:bg-gray-600 rounded border dark:border-gray-500" x-text="'{{ ' + variable + ' }}'"></code>
</template>
</div>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Content
</label>
<textarea
x-model="editForm.body_html"
rows="12"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="<html>...</html>"
></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text (Optional)
</label>
<textarea
x-model="editForm.body_text"
rows="4"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Plain text fallback..."
></textarea>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t dark:border-gray-700">
<div>
<!-- Revert to Default Button -->
<template x-if="templateSource === 'vendor_override'">
<button
@click="revertToDefault()"
:disabled="reverting"
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg dark:text-red-400 dark:hover:bg-red-900/20"
>
<span x-show="!reverting">Revert to Platform Default</span>
<span x-show="reverting">Reverting...</span>
</button>
</template>
</div>
<div class="flex items-center gap-3">
<button
@click="previewTemplate()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Preview
</button>
<button
@click="sendTestEmail()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Send Test
</button>
<button
@click="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="saveTemplate()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!saving">Save Override</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
</template>
{% endcall %}
<!-- Preview Modal -->
{% call modal_dialog(
show_var="showPreviewModal",
title="Email Preview",
size="4xl"
) %}
<template x-if="previewData">
<div class="space-y-4">
<div class="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<p class="text-sm"><strong>Subject:</strong> <span x-text="previewData.subject"></span></p>
</div>
<div class="border dark:border-gray-700 rounded-lg overflow-hidden">
<iframe
:srcdoc="previewData.body_html"
class="w-full h-96 bg-white"
sandbox="allow-same-origin"
></iframe>
</div>
<div class="flex justify-end">
<button
@click="showPreviewModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Close
</button>
</div>
</div>
</template>
{% endcall %}
<!-- Test Email Modal -->
{% call modal_dialog(
show_var="showTestEmailModal",
title="Send Test Email",
size="md"
) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send test email to:
</label>
<input
type="email"
x-model="testEmailAddress"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="your@email.com"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
A test email will be sent using sample data for template variables.
</p>
<div class="flex justify-end gap-3">
<button
@click="showTestEmailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="confirmSendTestEmail()"
:disabled="sendingTest || !testEmailAddress"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!sendingTest">Send Test</span>
<span x-show="sendingTest">Sending...</span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,279 @@
{# app/templates/vendor/messages.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Messages{% endblock %}
{% block alpine_data %}vendorMessages({{ conversation_id or 'null' }}){% endblock %}
{% block content %}
{{ page_header('Messages', action_label='New Conversation', action_icon='plus', action_onclick='showComposeModal = true') }}
{{ loading_state('Loading conversations...') }}
{{ error_state('Error loading conversations') }}
<!-- Main Messages Layout -->
<div x-show="!loading" class="flex gap-6 h-[calc(100vh-220px)]">
<!-- Conversations List (Left Panel) -->
<div class="w-96 flex-shrink-0 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- Filters -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<select
x-model="filters.conversation_type"
@change="page = 1; loadConversations()"
class="flex-1 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 Types</option>
<option value="vendor_customer">Customers</option>
<option value="admin_vendor">Admin</option>
</select>
<select
x-model="filters.is_closed"
@change="page = 1; loadConversations()"
class="flex-1 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 Status</option>
<option value="false">Open</option>
<option value="true">Closed</option>
</select>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
<template x-if="loadingConversations && conversations.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...</p>
</div>
</template>
<template x-if="!loadingConversations && conversations.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('chat-bubble-left-right', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No conversations</p>
<p class="text-sm mt-1">Start a new conversation with a customer</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="conv in conversations" :key="conv.id">
<li
@click="selectConversation(conv.id)"
class="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-purple-50 dark:bg-purple-900/20': selectedConversationId === conv.id,
'border-l-4 border-purple-500': selectedConversationId === conv.id
}"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate" x-text="conv.subject"></span>
<span x-show="conv.unread_count > 0"
class="px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="conv.unread_count"></span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded"
:class="conv.conversation_type === 'admin_vendor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'"
x-text="conv.conversation_type === 'admin_vendor' ? 'Admin' : 'Customer'"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="conv.other_participant?.name || 'Unknown'"></span>
</div>
<p x-show="conv.last_message_preview"
class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
x-text="conv.last_message_preview"></p>
</div>
<div class="flex flex-col items-end ml-2">
<span class="text-xs text-gray-400" x-text="formatRelativeTime(conv.last_message_at || conv.created_at)"></span>
<span x-show="conv.is_closed"
class="mt-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded dark:bg-gray-700">
Closed
</span>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
<!-- Conversation Detail (Right Panel) -->
<div class="flex-1 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- No conversation selected -->
<template x-if="!selectedConversationId">
<div class="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div class="text-center">
<span x-html="$icon('chat-bubble-left-right', 'w-16 h-16 mx-auto mb-4 text-gray-300')"></span>
<p class="font-medium">Select a conversation</p>
<p class="text-sm mt-1">Or start a new one with a customer</p>
</div>
</div>
</template>
<!-- Conversation loaded -->
<template x-if="selectedConversationId && selectedConversation">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="selectedConversation.subject"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
with <span class="font-medium" x-text="getOtherParticipantName()"></span>
</p>
</div>
<div class="flex items-center gap-2">
<template x-if="!selectedConversation.is_closed">
<button @click="closeConversation()"
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
Close
</button>
</template>
<template x-if="selectedConversation.is_closed">
<button @click="reopenConversation()"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Reopen
</button>
</template>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" x-ref="messagesContainer">
<template x-for="msg in selectedConversation.messages" :key="msg.id">
<div class="flex"
:class="msg.sender_type === 'vendor' ? 'justify-end' : 'justify-start'">
<!-- System message -->
<template x-if="msg.is_system_message">
<div class="text-center w-full py-2">
<span class="px-3 py-1 text-xs text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400"
x-text="msg.content"></span>
</div>
</template>
<!-- Regular message -->
<template x-if="!msg.is_system_message">
<div class="max-w-[70%]">
<div class="rounded-lg px-4 py-2"
:class="msg.sender_type === 'vendor'
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'">
<p class="text-sm whitespace-pre-wrap" x-text="msg.content"></p>
<!-- Attachments -->
<template x-if="msg.attachments && msg.attachments.length > 0">
<div class="mt-2 space-y-1">
<template x-for="att in msg.attachments" :key="att.id">
<a :href="att.download_url"
target="_blank"
class="flex items-center gap-2 text-xs underline"
:class="msg.sender_type === 'vendor' ? 'text-purple-200 hover:text-white' : 'text-purple-600 hover:text-purple-800 dark:text-purple-400'">
<span x-html="att.is_image ? $icon('photo', 'w-4 h-4') : $icon('paper-clip', 'w-4 h-4')"></span>
<span x-text="att.original_filename"></span>
</a>
</template>
</div>
</template>
</div>
<div class="flex items-center gap-2 mt-1 px-1"
:class="msg.sender_type === 'vendor' ? 'justify-end' : 'justify-start'">
<span class="text-xs text-gray-400" x-text="msg.sender_name || 'Unknown'"></span>
<span class="text-xs text-gray-400" x-text="formatTime(msg.created_at)"></span>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Reply Form -->
<template x-if="!selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<form @submit.prevent="sendMessage()" class="flex gap-3">
<div class="flex-1">
<textarea
x-model="replyContent"
@keydown.enter.meta="sendMessage()"
@keydown.enter.ctrl="sendMessage()"
rows="2"
placeholder="Type your message..."
class="w-full 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 resize-none"
></textarea>
</div>
<button type="submit"
:disabled="!replyContent.trim() || sendingMessage"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed self-end">
<span x-show="!sendingMessage" x-html="$icon('paper-airplane', 'w-5 h-5')"></span>
<span x-show="sendingMessage" x-html="$icon('spinner', 'w-5 h-5 animate-spin')"></span>
</button>
</form>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Compose Modal -->
{% call modal_simple('composeMessageModal', 'New Conversation', show_var='showComposeModal', size='md') %}
<form @submit.prevent="createConversation()" class="space-y-4">
<!-- Customer -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer</label>
<select
x-model="compose.recipientId"
class="w-full px-3 py-2 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="">Select customer...</option>
<template x-for="r in recipients" :key="r.id">
<option :value="r.id" x-text="r.name + ' - ' + (r.email || '')"></option>
</template>
</select>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
<input
type="text"
x-model="compose.subject"
placeholder="What is this about?"
class="w-full px-3 py-2 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"
>
</div>
<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
<textarea
x-model="compose.message"
rows="4"
placeholder="Type your message..."
class="w-full px-3 py-2 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 resize-none"
></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
<button type="button" @click="showComposeModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
:disabled="!compose.recipientId || !compose.subject.trim() || creatingConversation"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="!creatingConversation">Start Conversation</span>
<span x-show="creatingConversation">Creating...</span>
</button>
</div>
</form>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/messages.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{# 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 %}
{% from 'shared/macros/modals.html' import modal_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 -->
{% call modal_simple('notificationSettingsModal', 'Notification Settings', show_var='showSettingsModal', size='md') %}
<div class="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 pt-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>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/notifications.js') }}"></script>
{% endblock %}