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:
2025-12-21 14:10:38 +01:00
parent abceffb7b8
commit 2f98c4389d
5 changed files with 1125 additions and 1 deletions

View File

@@ -21,7 +21,7 @@ Authentication:
from fastapi import APIRouter
# Import shop routers
from . import auth, carts, content_pages, orders, products
from . import auth, carts, content_pages, messages, orders, products
# Create shop router
router = APIRouter()
@@ -42,6 +42,9 @@ router.include_router(carts.router, tags=["shop-cart"])
# Orders (authenticated)
router.include_router(orders.router, tags=["shop-orders"])
# Messages (authenticated)
router.include_router(messages.router, tags=["shop-messages"])
# Content pages (public)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]

543
app/api/v1/shop/messages.py Normal file
View File

@@ -0,0 +1,543 @@
# app/api/v1/shop/messages.py
"""
Shop Messages API (Customer authenticated)
Endpoints for customer messaging in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication.
Customers can only:
- View their own vendor_customer conversations
- Reply to existing conversations
- Mark conversations as read
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import ConversationNotFoundException, VendorNotFoundException
from app.services.message_attachment_service import message_attachment_service
from app.services.messaging_service import messaging_service
from models.database.customer import Customer
from models.database.message import ConversationType, ParticipantType
from models.schema.message import (
ConversationDetailResponse,
ConversationListResponse,
ConversationSummary,
MessageResponse,
UnreadCountResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# Response Models
# ============================================================================
class SendMessageResponse(BaseModel):
"""Response for send message."""
success: bool
message: MessageResponse
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/messages", response_model=ConversationListResponse)
def list_conversations(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: Optional[str] = Query(None, regex="^(open|closed)$"),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List conversations for authenticated customer.
Customers only see their vendor_customer conversations.
Query Parameters:
- skip: Pagination offset
- limit: Max items to return
- status: Filter by open/closed
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] list_conversations for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"skip": skip,
"limit": limit,
"status": status,
},
)
# Build filter - customers only see vendor_customer conversations
is_closed = None
if status == "open":
is_closed = False
elif status == "closed":
is_closed = True
# Get conversations where customer is a participant
conversations, total = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
conversation_type=ConversationType.VENDOR_CUSTOMER,
is_closed=is_closed,
skip=skip,
limit=limit,
)
# Convert to summaries
summaries = []
for conv, unread in conversations:
summaries.append(
ConversationSummary(
id=conv.id,
subject=conv.subject,
conversation_type=conv.conversation_type.value,
is_closed=conv.is_closed,
last_message_at=conv.last_message_at,
message_count=conv.message_count,
unread_count=unread,
other_participant_name=_get_other_participant_name(conv, customer.id),
)
)
return ConversationListResponse(
conversations=summaries,
total=total,
skip=skip,
limit=limit,
)
@router.get("/messages/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get total unread message count for header badge.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
return UnreadCountResponse(unread_count=count)
@router.get("/messages/{conversation_id}", response_model=ConversationDetailResponse)
def get_conversation(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get conversation detail with messages.
Validates that customer is a participant.
Automatically marks conversation as read.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_conversation {conversation_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
},
)
# Get conversation with access check
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Mark as read
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
# Build response
messages = []
for msg in conversation.messages:
if msg.is_deleted:
continue
messages.append(
MessageResponse(
id=msg.id,
content=msg.content,
sender_type=msg.sender_type.value,
sender_id=msg.sender_id,
sender_name=_get_sender_name(msg),
is_system_message=msg.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in msg.attachments
],
created_at=msg.created_at,
)
)
return ConversationDetailResponse(
id=conversation.id,
subject=conversation.subject,
conversation_type=conversation.conversation_type.value,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
messages=messages,
other_participant_name=_get_other_participant_name(conversation, customer.id),
)
@router.post("/messages/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
content: str = Form(..., min_length=1, max_length=10000),
attachments: List[UploadFile] = File(default=[]),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Send a message in a conversation.
Validates that customer is a participant.
Supports file attachments.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] send_message in {conversation_id} from customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
"attachment_count": len(attachments),
},
)
# Verify conversation exists and customer has access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Check if conversation is closed
if conversation.is_closed:
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Cannot send messages to a closed conversation",
)
# Process attachments
attachment_data = []
for upload_file in attachments:
if upload_file.filename:
file_data = await message_attachment_service.validate_and_store(
db=db,
upload_file=upload_file,
conversation_id=conversation_id,
)
attachment_data.append(file_data)
# Send message
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.CUSTOMER,
sender_id=customer.id,
content=content,
attachments=attachment_data,
)
logger.info(
f"[SHOP_API] Message sent in conversation {conversation_id}",
extra={
"message_id": message.id,
"customer_id": customer.id,
"vendor_id": vendor.id,
},
)
return SendMessageResponse(
success=True,
message=MessageResponse(
id=message.id,
content=message.content,
sender_type=message.sender_type.value,
sender_id=message.sender_id,
sender_name=_get_sender_name(message),
is_system_message=message.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in message.attachments
],
created_at=message.created_at,
),
)
@router.put("/messages/{conversation_id}/read", response_model=dict)
def mark_as_read(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""Mark conversation as read."""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
return {"success": True}
@router.get("/messages/{conversation_id}/attachments/{attachment_id}")
async def download_attachment(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download a message attachment.
Validates that customer has access to the conversation.
"""
from fastapi import HTTPException
from fastapi.responses import FileResponse
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Find attachment
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
return FileResponse(
path=attachment.file_path,
filename=attachment.original_filename,
media_type=attachment.mime_type,
)
@router.get("/messages/{conversation_id}/attachments/{attachment_id}/thumbnail")
async def get_attachment_thumbnail(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get thumbnail for an image attachment.
Validates that customer has access to the conversation.
"""
from fastapi import HTTPException
from fastapi.responses import FileResponse
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Find attachment
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment or not attachment.thumbnail_path:
raise HTTPException(status_code=404, detail="Thumbnail not found")
return FileResponse(
path=attachment.thumbnail_path,
media_type="image/jpeg",
)
# ============================================================================
# Helper Functions
# ============================================================================
def _get_other_participant_name(conversation, customer_id: int) -> str:
"""Get the name of the other participant (the vendor user)."""
for participant in conversation.participants:
if participant.participant_type == ParticipantType.VENDOR:
# Get vendor user name
from models.database.user import User
user = (
User.query.filter_by(id=participant.participant_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
return "Shop Support"
def _get_sender_name(message) -> str:
"""Get sender name for a message."""
if message.sender_type == ParticipantType.CUSTOMER:
from models.database.customer import Customer
customer = (
Customer.query.filter_by(id=message.sender_id).first()
if hasattr(Customer, "query")
else None
)
if customer:
return f"{customer.first_name} {customer.last_name}"
return "Customer"
elif message.sender_type == ParticipantType.VENDOR:
from models.database.user import User
user = (
User.query.filter_by(id=message.sender_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
elif message.sender_type == ParticipantType.ADMIN:
return "Platform Support"
return "Unknown"

View File

@@ -581,6 +581,65 @@ async def shop_settings_page(
)
@router.get("/account/messages", response_class=HTMLResponse, include_in_schema=False)
async def shop_messages_page(
request: Request,
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render customer messages page.
View and reply to conversations with the vendor.
Requires customer authentication.
"""
logger.debug(
"[SHOP_HANDLER] shop_messages_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"shop/account/messages.html", get_shop_context(request, db=db, user=current_customer)
)
@router.get(
"/account/messages/{conversation_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def shop_message_detail_page(
request: Request,
conversation_id: int = Path(..., description="Conversation ID"),
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render message conversation detail page.
Shows the full conversation thread.
Requires customer authentication.
"""
logger.debug(
"[SHOP_HANDLER] shop_message_detail_page REACHED",
extra={
"path": request.url.path,
"conversation_id": conversation_id,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"shop/account/messages.html",
get_shop_context(
request, db=db, user=current_customer, conversation_id=conversation_id
),
)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================

View File

@@ -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 -->

View 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 %}