Files
orion/app/templates/shop/account/messages.html
Samir Boulahtit 36603178c3 feat: add email settings with database overrides for admin and vendor
Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration

Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+

Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers

Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:23:47 +01:00

521 lines
26 KiB
HTML

{# app/templates/shop/account/messages.html #}
{% extends "shop/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 %}