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:
@@ -21,7 +21,7 @@ Authentication:
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
# Import shop routers
|
# Import shop routers
|
||||||
from . import auth, carts, content_pages, orders, products
|
from . import auth, carts, content_pages, messages, orders, products
|
||||||
|
|
||||||
# Create shop router
|
# Create shop router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -42,6 +42,9 @@ router.include_router(carts.router, tags=["shop-cart"])
|
|||||||
# Orders (authenticated)
|
# Orders (authenticated)
|
||||||
router.include_router(orders.router, tags=["shop-orders"])
|
router.include_router(orders.router, tags=["shop-orders"])
|
||||||
|
|
||||||
|
# Messages (authenticated)
|
||||||
|
router.include_router(messages.router, tags=["shop-messages"])
|
||||||
|
|
||||||
# Content pages (public)
|
# Content pages (public)
|
||||||
router.include_router(
|
router.include_router(
|
||||||
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]
|
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]
|
||||||
|
|||||||
543
app/api/v1/shop/messages.py
Normal file
543
app/api/v1/shop/messages.py
Normal 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"
|
||||||
@@ -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)
|
# DYNAMIC CONTENT PAGES (CMS)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -71,6 +71,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Account Summary -->
|
<!-- 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