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>
409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
/**
|
|
* Vendor Messages Page
|
|
*
|
|
* Handles the messaging interface for vendors including:
|
|
* - Conversation list with filtering
|
|
* - Message thread display
|
|
* - Sending messages
|
|
* - Creating new conversations with customers
|
|
*/
|
|
|
|
const messagesLog = window.LogConfig?.createLogger('VENDOR-MESSAGES') || console;
|
|
|
|
/**
|
|
* Vendor Messages Component
|
|
*/
|
|
function vendorMessages(initialConversationId = null) {
|
|
return {
|
|
...data(),
|
|
|
|
// Loading states
|
|
loading: true,
|
|
loadingConversations: false,
|
|
loadingMessages: false,
|
|
sendingMessage: false,
|
|
creatingConversation: false,
|
|
error: '',
|
|
|
|
// Conversations state
|
|
conversations: [],
|
|
page: 1,
|
|
skip: 0,
|
|
limit: 20,
|
|
totalConversations: 0,
|
|
totalUnread: 0,
|
|
|
|
// Filters
|
|
filters: {
|
|
conversation_type: '',
|
|
is_closed: ''
|
|
},
|
|
|
|
// Selected conversation
|
|
selectedConversationId: initialConversationId,
|
|
selectedConversation: null,
|
|
|
|
// Reply form
|
|
replyContent: '',
|
|
|
|
// Compose modal
|
|
showComposeModal: false,
|
|
compose: {
|
|
recipientId: null,
|
|
subject: '',
|
|
message: ''
|
|
},
|
|
recipients: [],
|
|
|
|
// Polling
|
|
pollInterval: null,
|
|
|
|
/**
|
|
* Initialize component
|
|
*/
|
|
async init() {
|
|
// Guard against multiple initialization
|
|
if (window._vendorMessagesInitialized) return;
|
|
window._vendorMessagesInitialized = true;
|
|
|
|
// IMPORTANT: Call parent init first to set vendorCode from URL
|
|
const parentInit = data().init;
|
|
if (parentInit) {
|
|
await parentInit.call(this);
|
|
}
|
|
|
|
try {
|
|
messagesLog.debug('Initializing vendor messages page');
|
|
await Promise.all([
|
|
this.loadConversations(),
|
|
this.loadRecipients()
|
|
]);
|
|
|
|
if (this.selectedConversationId) {
|
|
await this.loadConversation(this.selectedConversationId);
|
|
}
|
|
|
|
// Start polling for new messages
|
|
this.startPolling();
|
|
} catch (error) {
|
|
messagesLog.error('Failed to initialize messages page:', error);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Start polling for updates
|
|
*/
|
|
startPolling() {
|
|
this.pollInterval = setInterval(async () => {
|
|
if (this.selectedConversationId && !document.hidden) {
|
|
await this.refreshCurrentConversation();
|
|
}
|
|
await this.updateUnreadCount();
|
|
}, 30000);
|
|
},
|
|
|
|
/**
|
|
* Stop polling
|
|
*/
|
|
destroy() {
|
|
if (this.pollInterval) {
|
|
clearInterval(this.pollInterval);
|
|
}
|
|
},
|
|
|
|
// ============================================================================
|
|
// CONVERSATIONS LIST
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Load conversations with current filters
|
|
*/
|
|
async loadConversations() {
|
|
this.loadingConversations = true;
|
|
try {
|
|
this.skip = (this.page - 1) * this.limit;
|
|
const params = new URLSearchParams();
|
|
params.append('skip', this.skip);
|
|
params.append('limit', this.limit);
|
|
|
|
if (this.filters.conversation_type) {
|
|
params.append('conversation_type', this.filters.conversation_type);
|
|
}
|
|
if (this.filters.is_closed !== '') {
|
|
params.append('is_closed', this.filters.is_closed);
|
|
}
|
|
|
|
const response = await apiClient.get(`/vendor/messages?${params}`);
|
|
this.conversations = response.conversations || [];
|
|
this.totalConversations = response.total || 0;
|
|
this.totalUnread = response.total_unread || 0;
|
|
|
|
messagesLog.debug(`Loaded ${this.conversations.length} conversations`);
|
|
} catch (error) {
|
|
messagesLog.error('Failed to load conversations:', error);
|
|
Utils.showToast('Failed to load conversations', 'error');
|
|
} finally {
|
|
this.loadingConversations = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update unread count
|
|
*/
|
|
async updateUnreadCount() {
|
|
try {
|
|
const response = await apiClient.get('/vendor/messages/unread-count');
|
|
this.totalUnread = response.total_unread || 0;
|
|
} catch (error) {
|
|
messagesLog.error('Failed to update unread count:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Select a conversation
|
|
*/
|
|
async selectConversation(conversationId) {
|
|
if (this.selectedConversationId === conversationId) return;
|
|
|
|
this.selectedConversationId = conversationId;
|
|
await this.loadConversation(conversationId);
|
|
},
|
|
|
|
/**
|
|
* Load conversation detail
|
|
*/
|
|
async loadConversation(conversationId) {
|
|
this.loadingMessages = true;
|
|
try {
|
|
const response = await apiClient.get(`/vendor/messages/${conversationId}?mark_read=true`);
|
|
this.selectedConversation = response;
|
|
|
|
// Update unread count in list
|
|
const conv = this.conversations.find(c => c.id === conversationId);
|
|
if (conv) {
|
|
this.totalUnread = Math.max(0, this.totalUnread - conv.unread_count);
|
|
conv.unread_count = 0;
|
|
}
|
|
|
|
// Scroll to bottom
|
|
await this.$nextTick();
|
|
this.scrollToBottom();
|
|
} catch (error) {
|
|
messagesLog.error('Failed to load conversation:', error);
|
|
Utils.showToast('Failed to load conversation', 'error');
|
|
} finally {
|
|
this.loadingMessages = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh current conversation
|
|
*/
|
|
async refreshCurrentConversation() {
|
|
if (!this.selectedConversationId) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(`/vendor/messages/${this.selectedConversationId}?mark_read=true`);
|
|
const oldCount = this.selectedConversation?.messages?.length || 0;
|
|
const newCount = response.messages?.length || 0;
|
|
|
|
this.selectedConversation = response;
|
|
|
|
if (newCount > oldCount) {
|
|
await this.$nextTick();
|
|
this.scrollToBottom();
|
|
}
|
|
} catch (error) {
|
|
messagesLog.error('Failed to refresh conversation:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Scroll messages to bottom
|
|
*/
|
|
scrollToBottom() {
|
|
const container = this.$refs.messagesContainer;
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
},
|
|
|
|
// ============================================================================
|
|
// SENDING MESSAGES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Send a message
|
|
*/
|
|
async sendMessage() {
|
|
if (!this.replyContent.trim()) return;
|
|
if (!this.selectedConversationId) return;
|
|
|
|
this.sendingMessage = true;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('content', this.replyContent);
|
|
|
|
const message = await apiClient.postFormData(`/vendor/messages/${this.selectedConversationId}/messages`, formData);
|
|
|
|
// Add to messages
|
|
if (this.selectedConversation) {
|
|
this.selectedConversation.messages.push(message);
|
|
this.selectedConversation.message_count++;
|
|
}
|
|
|
|
// Clear form
|
|
this.replyContent = '';
|
|
|
|
// Scroll to bottom
|
|
await this.$nextTick();
|
|
this.scrollToBottom();
|
|
} catch (error) {
|
|
messagesLog.error('Failed to send message:', error);
|
|
Utils.showToast(error.message || 'Failed to send message', 'error');
|
|
} finally {
|
|
this.sendingMessage = false;
|
|
}
|
|
},
|
|
|
|
// ============================================================================
|
|
// CONVERSATION ACTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Close conversation
|
|
*/
|
|
async closeConversation() {
|
|
if (!confirm('Close this conversation?')) return;
|
|
|
|
try {
|
|
await apiClient.post(`/vendor/messages/${this.selectedConversationId}/close`);
|
|
|
|
if (this.selectedConversation) {
|
|
this.selectedConversation.is_closed = true;
|
|
}
|
|
|
|
const conv = this.conversations.find(c => c.id === this.selectedConversationId);
|
|
if (conv) conv.is_closed = true;
|
|
|
|
Utils.showToast('Conversation closed', 'success');
|
|
} catch (error) {
|
|
messagesLog.error('Failed to close conversation:', error);
|
|
Utils.showToast('Failed to close conversation', 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reopen conversation
|
|
*/
|
|
async reopenConversation() {
|
|
try {
|
|
await apiClient.post(`/vendor/messages/${this.selectedConversationId}/reopen`);
|
|
|
|
if (this.selectedConversation) {
|
|
this.selectedConversation.is_closed = false;
|
|
}
|
|
|
|
const conv = this.conversations.find(c => c.id === this.selectedConversationId);
|
|
if (conv) conv.is_closed = false;
|
|
|
|
Utils.showToast('Conversation reopened', 'success');
|
|
} catch (error) {
|
|
messagesLog.error('Failed to reopen conversation:', error);
|
|
Utils.showToast('Failed to reopen conversation', 'error');
|
|
}
|
|
},
|
|
|
|
// ============================================================================
|
|
// CREATE CONVERSATION
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Load recipients (customers)
|
|
*/
|
|
async loadRecipients() {
|
|
try {
|
|
const response = await apiClient.get('/vendor/messages/recipients?recipient_type=customer&limit=100');
|
|
this.recipients = response.recipients || [];
|
|
} catch (error) {
|
|
messagesLog.error('Failed to load recipients:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Create new conversation
|
|
*/
|
|
async createConversation() {
|
|
if (!this.compose.recipientId || !this.compose.subject.trim()) return;
|
|
|
|
this.creatingConversation = true;
|
|
try {
|
|
const response = await apiClient.post('/vendor/messages', {
|
|
conversation_type: 'vendor_customer',
|
|
subject: this.compose.subject,
|
|
recipient_type: 'customer',
|
|
recipient_id: parseInt(this.compose.recipientId),
|
|
initial_message: this.compose.message || null
|
|
});
|
|
|
|
// Close modal and reset
|
|
this.showComposeModal = false;
|
|
this.compose = { recipientId: null, subject: '', message: '' };
|
|
|
|
// Reload and select
|
|
await this.loadConversations();
|
|
await this.selectConversation(response.id);
|
|
|
|
Utils.showToast('Conversation created', 'success');
|
|
} catch (error) {
|
|
messagesLog.error('Failed to create conversation:', error);
|
|
Utils.showToast(error.message || 'Failed to create conversation', 'error');
|
|
} finally {
|
|
this.creatingConversation = false;
|
|
}
|
|
},
|
|
|
|
// ============================================================================
|
|
// HELPERS
|
|
// ============================================================================
|
|
|
|
getOtherParticipantName() {
|
|
if (!this.selectedConversation?.participants) return 'Unknown';
|
|
const other = this.selectedConversation.participants.find(p => p.participant_type !== 'vendor');
|
|
return other?.participant_info?.name || 'Unknown';
|
|
},
|
|
|
|
formatRelativeTime(dateString) {
|
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diff = Math.floor((now - date) / 1000);
|
|
|
|
if (diff < 60) return 'Now';
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
|
if (diff < 172800) return 'Yesterday';
|
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
|
return date.toLocaleDateString(locale);
|
|
},
|
|
|
|
formatTime(dateString) {
|
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const isToday = date.toDateString() === now.toDateString();
|
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
|
|
|
if (isToday) {
|
|
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
return date.toLocaleString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
};
|
|
}
|
|
|
|
// Make available globally
|
|
window.vendorMessages = vendorMessages;
|