feat: add customer messaging interface in shop
- Add shop messages API endpoints (/api/v1/shop/messages) - Add shop messages page routes (/shop/account/messages) - Add messages.html template for customer account - Add Messages card to account dashboard with unread badge - Customers can view/reply to vendor_customer conversations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Messages Card -->
|
||||
<a href="{{ base_url }}shop/account/messages"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ unreadCount: 0 }"
|
||||
x-init="fetch('/api/v1/shop/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<svg class="h-8 w-8 text-primary" style="color: var(--color-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-600 rounded-full"
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Messages</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Contact support</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="unreadCount > 0">
|
||||
<p class="text-sm text-primary font-medium" style="color: var(--color-primary)" x-text="unreadCount + ' unread message' + (unreadCount > 1 ? 's' : '')"></p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Account Summary -->
|
||||
|
||||
495
app/templates/shop/account/messages.html
Normal file
495
app/templates/shop/account/messages.html
Normal file
@@ -0,0 +1,495 @@
|
||||
{# 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)">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
My Account
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<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">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" style="color: var(--color-primary)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<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 -->
|
||||
<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">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
<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">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
<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">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</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 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}`);
|
||||
if (!response.ok) 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 response = await fetch(`/api/v1/shop/messages/${conversationId}`);
|
||||
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 response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`);
|
||||
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 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',
|
||||
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 %}
|
||||
Reference in New Issue
Block a user