feat: add vendor messaging interface

- Add vendor messages API endpoints (/api/v1/vendor/messages)
- Add vendor messages page route (/vendor/{code}/messages)
- Add messages.html template for vendor portal
- Add messages.js Alpine component
- Add Messages link to vendor sidebar under Sales section

🤖 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:22 +01:00
parent 081511ff8a
commit abceffb7b8
6 changed files with 1400 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ from . import (
letzshop,
marketplace,
media,
messages,
notifications,
order_item_exceptions,
orders,
@@ -68,6 +69,7 @@ router.include_router(letzshop.router, tags=["vendor-letzshop"])
router.include_router(payments.router, tags=["vendor-payments"])
router.include_router(media.router, tags=["vendor-media"])
router.include_router(notifications.router, tags=["vendor-notifications"])
router.include_router(messages.router, tags=["vendor-messages"])
router.include_router(analytics.router, tags=["vendor-analytics"])
# Content pages management

639
app/api/v1/vendor/messages.py vendored Normal file
View File

@@ -0,0 +1,639 @@
# app/api/v1/vendor/messages.py
"""
Vendor messaging endpoints.
Provides endpoints for:
- Viewing conversations (vendor_customer and admin_vendor channels)
- Sending and receiving messages
- Managing conversation status
- File attachments
Uses get_current_vendor_api dependency which guarantees token_vendor_id is present.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.message_attachment_service import message_attachment_service
from app.services.messaging_service import messaging_service
from models.database.message import ConversationType, ParticipantType
from models.database.user import User
from models.schema.message import (
CloseConversationResponse,
ConversationCreate,
ConversationDetailResponse,
ConversationListResponse,
ConversationSummary,
MarkReadResponse,
MessageResponse,
NotificationPreferencesUpdate,
ParticipantInfo,
ParticipantResponse,
RecipientListResponse,
RecipientOption,
ReopenConversationResponse,
UnreadCountResponse,
)
from models.schema.message import AttachmentResponse
router = APIRouter(prefix="/messages")
logger = logging.getLogger(__name__)
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def _enrich_message(
db: Session, message: Any, include_attachments: bool = True
) -> MessageResponse:
"""Enrich message with sender info and attachments."""
sender_info = messaging_service.get_participant_info(
db, message.sender_type, message.sender_id
)
attachments = []
if include_attachments and message.attachments:
for att in message.attachments:
attachments.append(
AttachmentResponse(
id=att.id,
filename=att.filename,
original_filename=att.original_filename,
file_size=att.file_size,
mime_type=att.mime_type,
is_image=att.is_image,
image_width=att.image_width,
image_height=att.image_height,
download_url=message_attachment_service.get_download_url(
att.file_path
),
thumbnail_url=(
message_attachment_service.get_download_url(att.thumbnail_path)
if att.thumbnail_path
else None
),
)
)
return MessageResponse(
id=message.id,
conversation_id=message.conversation_id,
sender_type=message.sender_type,
sender_id=message.sender_id,
content=message.content,
is_system_message=message.is_system_message,
is_deleted=message.is_deleted,
created_at=message.created_at,
sender_name=sender_info["name"] if sender_info else None,
sender_email=sender_info["email"] if sender_info else None,
attachments=attachments,
)
def _enrich_conversation_summary(
db: Session, conversation: Any, current_user_id: int, vendor_id: int
) -> ConversationSummary:
"""Enrich conversation with other participant info and unread count."""
# Get current user's participant record
my_participant = next(
(
p
for p in conversation.participants
if p.participant_type == ParticipantType.VENDOR
and p.participant_id == current_user_id
and p.vendor_id == vendor_id
),
None,
)
unread_count = my_participant.unread_count if my_participant else 0
# Get other participant info
other = messaging_service.get_other_participant(
conversation, ParticipantType.VENDOR, current_user_id
)
other_info = None
if other:
info = messaging_service.get_participant_info(
db, other.participant_type, other.participant_id
)
if info:
other_info = ParticipantInfo(
id=info["id"],
type=info["type"],
name=info["name"],
email=info.get("email"),
)
# Get last message preview
last_message_preview = None
if conversation.messages:
last_msg = conversation.messages[-1] if conversation.messages else None
if last_msg:
preview = last_msg.content[:100]
if len(last_msg.content) > 100:
preview += "..."
last_message_preview = preview
return ConversationSummary(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
vendor_id=conversation.vendor_id,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
created_at=conversation.created_at,
unread_count=unread_count,
other_participant=other_info,
last_message_preview=last_message_preview,
)
# ============================================================================
# CONVERSATION LIST
# ============================================================================
@router.get("", response_model=ConversationListResponse)
def list_conversations(
conversation_type: ConversationType | None = Query(None, description="Filter by type"),
is_closed: bool | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> ConversationListResponse:
"""List conversations for vendor (vendor_customer and admin_vendor channels)."""
vendor_id = current_user.token_vendor_id
conversations, total, total_unread = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
vendor_id=vendor_id,
conversation_type=conversation_type,
is_closed=is_closed,
skip=skip,
limit=limit,
)
return ConversationListResponse(
conversations=[
_enrich_conversation_summary(db, c, current_user.id, vendor_id)
for c in conversations
],
total=total,
total_unread=total_unread,
skip=skip,
limit=limit,
)
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> UnreadCountResponse:
"""Get total unread message count for header badge."""
vendor_id = current_user.token_vendor_id
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
vendor_id=vendor_id,
)
return UnreadCountResponse(total_unread=count)
# ============================================================================
# RECIPIENTS
# ============================================================================
@router.get("/recipients", response_model=RecipientListResponse)
def get_recipients(
recipient_type: ParticipantType = Query(..., description="Type of recipients to list"),
search: str | None = Query(None, description="Search by name/email"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> RecipientListResponse:
"""Get list of available recipients for compose modal."""
from models.database.customer import Customer
vendor_id = current_user.token_vendor_id
recipients = []
if recipient_type == ParticipantType.CUSTOMER:
# List customers for this vendor (for vendor_customer conversations)
query = db.query(Customer).filter(
Customer.vendor_id == vendor_id,
Customer.is_active == True, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(Customer.email.ilike(search_pattern))
| (Customer.first_name.ilike(search_pattern))
| (Customer.last_name.ilike(search_pattern))
)
total = query.count()
results = query.offset(skip).limit(limit).all()
for customer in results:
name = f"{customer.first_name or ''} {customer.last_name or ''}".strip()
recipients.append(
RecipientOption(
id=customer.id,
type=ParticipantType.CUSTOMER,
name=name or customer.email,
email=customer.email,
vendor_id=customer.vendor_id,
)
)
else:
# Vendors can't start conversations with admins - admins initiate those
total = 0
return RecipientListResponse(recipients=recipients, total=total)
# ============================================================================
# CREATE CONVERSATION
# ============================================================================
@router.post("", response_model=ConversationDetailResponse)
def create_conversation(
data: ConversationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> ConversationDetailResponse:
"""Create a new conversation with a customer."""
vendor_id = current_user.token_vendor_id
# Vendors can only create vendor_customer conversations
if data.conversation_type != ConversationType.VENDOR_CUSTOMER:
raise HTTPException(
status_code=400,
detail="Vendors can only create vendor_customer conversations",
)
if data.recipient_type != ParticipantType.CUSTOMER:
raise HTTPException(
status_code=400,
detail="vendor_customer conversations require a customer recipient",
)
# Create conversation
conversation = messaging_service.create_conversation(
db=db,
conversation_type=ConversationType.VENDOR_CUSTOMER,
subject=data.subject,
initiator_type=ParticipantType.VENDOR,
initiator_id=current_user.id,
recipient_type=ParticipantType.CUSTOMER,
recipient_id=data.recipient_id,
vendor_id=vendor_id,
initial_message=data.initial_message,
)
db.commit()
db.refresh(conversation)
logger.info(
f"Vendor {current_user.username} created conversation {conversation.id} "
f"with customer:{data.recipient_id}"
)
# Return full detail response
return _build_conversation_detail(db, conversation, current_user.id, vendor_id)
# ============================================================================
# CONVERSATION DETAIL
# ============================================================================
def _build_conversation_detail(
db: Session, conversation: Any, current_user_id: int, vendor_id: int
) -> ConversationDetailResponse:
"""Build full conversation detail response."""
# Get my participant for unread count
my_participant = next(
(
p
for p in conversation.participants
if p.participant_type == ParticipantType.VENDOR
and p.participant_id == current_user_id
),
None,
)
unread_count = my_participant.unread_count if my_participant else 0
# Build participant responses
participants = []
for p in conversation.participants:
info = messaging_service.get_participant_info(
db, p.participant_type, p.participant_id
)
participants.append(
ParticipantResponse(
id=p.id,
participant_type=p.participant_type,
participant_id=p.participant_id,
unread_count=p.unread_count,
last_read_at=p.last_read_at,
email_notifications=p.email_notifications,
muted=p.muted,
participant_info=(
ParticipantInfo(
id=info["id"],
type=info["type"],
name=info["name"],
email=info.get("email"),
)
if info
else None
),
)
)
# Build message responses
messages = [_enrich_message(db, m) for m in conversation.messages]
# Get vendor name if applicable
vendor_name = None
if conversation.vendor:
vendor_name = conversation.vendor.name
return ConversationDetailResponse(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
vendor_id=conversation.vendor_id,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
closed_by_type=conversation.closed_by_type,
closed_by_id=conversation.closed_by_id,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
created_at=conversation.created_at,
updated_at=conversation.updated_at,
participants=participants,
messages=messages,
unread_count=unread_count,
vendor_name=vendor_name,
)
@router.get("/{conversation_id}", response_model=ConversationDetailResponse)
def get_conversation(
conversation_id: int,
mark_read: bool = Query(True, description="Automatically mark as read"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> ConversationDetailResponse:
"""Get conversation detail with messages."""
vendor_id = current_user.token_vendor_id
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# Verify vendor context
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Conversation not found")
# Mark as read if requested
if mark_read:
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.VENDOR,
reader_id=current_user.id,
)
db.commit()
return _build_conversation_detail(db, conversation, current_user.id, vendor_id)
# ============================================================================
# SEND MESSAGE
# ============================================================================
@router.post("/{conversation_id}/messages", response_model=MessageResponse)
async def send_message(
conversation_id: int,
content: str = Form(...),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> MessageResponse:
"""Send a message in a conversation, optionally with attachments."""
vendor_id = current_user.token_vendor_id
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# Verify vendor context
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.is_closed:
raise HTTPException(
status_code=400, detail="Cannot send messages to a closed conversation"
)
# Process attachments
attachments = []
for file in files:
try:
att_data = await message_attachment_service.validate_and_store(
db=db, file=file, conversation_id=conversation_id
)
attachments.append(att_data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Send message
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.VENDOR,
sender_id=current_user.id,
content=content,
attachments=attachments if attachments else None,
)
db.commit()
db.refresh(message)
logger.info(
f"Vendor {current_user.username} sent message {message.id} "
f"in conversation {conversation_id}"
)
return _enrich_message(db, message)
# ============================================================================
# CONVERSATION ACTIONS
# ============================================================================
@router.post("/{conversation_id}/close", response_model=CloseConversationResponse)
def close_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> CloseConversationResponse:
"""Close a conversation."""
vendor_id = current_user.token_vendor_id
# Verify access first
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Conversation not found")
conversation = messaging_service.close_conversation(
db=db,
conversation_id=conversation_id,
closer_type=ParticipantType.VENDOR,
closer_id=current_user.id,
)
db.commit()
logger.info(
f"Vendor {current_user.username} closed conversation {conversation_id}"
)
return CloseConversationResponse(
success=True,
message="Conversation closed",
conversation_id=conversation_id,
)
@router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse)
def reopen_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> ReopenConversationResponse:
"""Reopen a closed conversation."""
vendor_id = current_user.token_vendor_id
# Verify access first
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Conversation not found")
conversation = messaging_service.reopen_conversation(
db=db,
conversation_id=conversation_id,
opener_type=ParticipantType.VENDOR,
opener_id=current_user.id,
)
db.commit()
logger.info(
f"Vendor {current_user.username} reopened conversation {conversation_id}"
)
return ReopenConversationResponse(
success=True,
message="Conversation reopened",
conversation_id=conversation_id,
)
@router.put("/{conversation_id}/read", response_model=MarkReadResponse)
def mark_read(
conversation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> MarkReadResponse:
"""Mark conversation as read."""
success = messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.VENDOR,
reader_id=current_user.id,
)
db.commit()
return MarkReadResponse(
success=success,
conversation_id=conversation_id,
unread_count=0,
)
class PreferencesUpdateResponse(BaseModel):
"""Response for preferences update."""
success: bool
@router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse)
def update_preferences(
conversation_id: int,
preferences: NotificationPreferencesUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_api),
) -> PreferencesUpdateResponse:
"""Update notification preferences for a conversation."""
success = messaging_service.update_notification_preferences(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
email_notifications=preferences.email_notifications,
muted=preferences.muted,
)
db.commit()
return PreferencesUpdateResponse(success=success)

View File

@@ -219,6 +219,59 @@ async def vendor_customers_page(
)
# ============================================================================
# MESSAGING
# ============================================================================
@router.get(
"/{vendor_code}/messages", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_messages_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
):
"""
Render messages page.
JavaScript loads conversations and messages via API.
"""
return templates.TemplateResponse(
"vendor/messages.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
},
)
@router.get(
"/{vendor_code}/messages/{conversation_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_message_detail_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
conversation_id: int = Path(..., description="Conversation ID"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
):
"""
Render message detail page.
Shows the full conversation thread.
"""
return templates.TemplateResponse(
"vendor/messages.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
"conversation_id": conversation_id,
},
)
# ============================================================================
# INVENTORY MANAGEMENT
# ============================================================================

293
app/templates/vendor/messages.html vendored Normal file
View File

@@ -0,0 +1,293 @@
{# app/templates/vendor/messages.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Messages{% endblock %}
{% block alpine_data %}vendorMessages({{ conversation_id or 'null' }}){% endblock %}
{% block content %}
{{ page_header('Messages', buttons=[
{'text': 'New Conversation', 'icon': 'plus', 'click': 'showComposeModal = true', 'primary': True}
]) }}
{{ loading_state('Loading conversations...') }}
{{ error_state('Error loading conversations') }}
<!-- Main Messages Layout -->
<div x-show="!loading" class="flex gap-6 h-[calc(100vh-220px)]">
<!-- Conversations List (Left Panel) -->
<div class="w-96 flex-shrink-0 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- Filters -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<select
x-model="filters.conversation_type"
@change="page = 1; loadConversations()"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Types</option>
<option value="vendor_customer">Customers</option>
<option value="admin_vendor">Admin</option>
</select>
<select
x-model="filters.is_closed"
@change="page = 1; loadConversations()"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Status</option>
<option value="false">Open</option>
<option value="true">Closed</option>
</select>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
<template x-if="loadingConversations && conversations.length === 0">
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2 animate-spin')"></span>
<p>Loading...</p>
</div>
</template>
<template x-if="!loadingConversations && conversations.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('chat-bubble-left-right', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No conversations</p>
<p class="text-sm mt-1">Start a new conversation with a customer</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="conv in conversations" :key="conv.id">
<li
@click="selectConversation(conv.id)"
class="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-purple-50 dark:bg-purple-900/20': selectedConversationId === conv.id,
'border-l-4 border-purple-500': selectedConversationId === conv.id
}"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate" x-text="conv.subject"></span>
<span x-show="conv.unread_count > 0"
class="px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="conv.unread_count"></span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded"
:class="conv.conversation_type === 'admin_vendor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'"
x-text="conv.conversation_type === 'admin_vendor' ? 'Admin' : 'Customer'"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="conv.other_participant?.name || 'Unknown'"></span>
</div>
<p x-show="conv.last_message_preview"
class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
x-text="conv.last_message_preview"></p>
</div>
<div class="flex flex-col items-end ml-2">
<span class="text-xs text-gray-400" x-text="formatRelativeTime(conv.last_message_at || conv.created_at)"></span>
<span x-show="conv.is_closed"
class="mt-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded dark:bg-gray-700">
Closed
</span>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
<!-- Conversation Detail (Right Panel) -->
<div class="flex-1 flex flex-col bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<!-- No conversation selected -->
<template x-if="!selectedConversationId">
<div class="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div class="text-center">
<span x-html="$icon('chat-bubble-left-right', 'w-16 h-16 mx-auto mb-4 text-gray-300')"></span>
<p class="font-medium">Select a conversation</p>
<p class="text-sm mt-1">Or start a new one with a customer</p>
</div>
</div>
</template>
<!-- Conversation loaded -->
<template x-if="selectedConversationId && selectedConversation">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="selectedConversation.subject"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
with <span class="font-medium" x-text="getOtherParticipantName()"></span>
</p>
</div>
<div class="flex items-center gap-2">
<template x-if="!selectedConversation.is_closed">
<button @click="closeConversation()"
class="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
Close
</button>
</template>
<template x-if="selectedConversation.is_closed">
<button @click="reopenConversation()"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Reopen
</button>
</template>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" x-ref="messagesContainer">
<template x-for="msg in selectedConversation.messages" :key="msg.id">
<div class="flex"
:class="msg.sender_type === 'vendor' ? 'justify-end' : 'justify-start'">
<!-- System message -->
<template x-if="msg.is_system_message">
<div class="text-center w-full py-2">
<span class="px-3 py-1 text-xs text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400"
x-text="msg.content"></span>
</div>
</template>
<!-- Regular message -->
<template x-if="!msg.is_system_message">
<div class="max-w-[70%]">
<div class="rounded-lg px-4 py-2"
:class="msg.sender_type === 'vendor'
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'">
<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 gap-2 text-xs underline"
:class="msg.sender_type === 'vendor' ? 'text-purple-200 hover:text-white' : 'text-purple-600 hover:text-purple-800 dark:text-purple-400'">
<span x-html="att.is_image ? $icon('photo', 'w-4 h-4') : $icon('paper-clip', 'w-4 h-4')"></span>
<span x-text="att.original_filename"></span>
</a>
</template>
</div>
</template>
</div>
<div class="flex items-center gap-2 mt-1 px-1"
:class="msg.sender_type === 'vendor' ? 'justify-end' : 'justify-start'">
<span class="text-xs text-gray-400" x-text="msg.sender_name || 'Unknown'"></span>
<span class="text-xs text-gray-400" x-text="formatTime(msg.created_at)"></span>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Reply Form -->
<template x-if="!selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<form @submit.prevent="sendMessage()" class="flex gap-3">
<div class="flex-1">
<textarea
x-model="replyContent"
@keydown.enter.meta="sendMessage()"
@keydown.enter.ctrl="sendMessage()"
rows="2"
placeholder="Type your message..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 resize-none"
></textarea>
</div>
<button type="submit"
:disabled="!replyContent.trim() || sendingMessage"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed self-end">
<span x-show="!sendingMessage" x-html="$icon('paper-airplane', 'w-5 h-5')"></span>
<span x-show="sendingMessage" x-html="$icon('spinner', 'w-5 h-5 animate-spin')"></span>
</button>
</form>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Compose Modal -->
<div x-show="showComposeModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center"
@keydown.escape.window="showComposeModal = false">
<div class="absolute inset-0 bg-black bg-opacity-50" @click="showComposeModal = false"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">New Conversation</h3>
<button @click="showComposeModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
<form @submit.prevent="createConversation()" class="p-6 space-y-4">
<!-- Customer -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer</label>
<select
x-model="compose.recipientId"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Select customer...</option>
<template x-for="r in recipients" :key="r.id">
<option :value="r.id" x-text="r.name + ' - ' + (r.email || '')"></option>
</template>
</select>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
<input
type="text"
x-model="compose.subject"
placeholder="What is this about?"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
<textarea
x-model="compose.message"
rows="4"
placeholder="Type your message..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 resize-none"
></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showComposeModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
:disabled="!compose.recipientId || !compose.subject.trim() || creatingConversation"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="!creatingConversation">Start Conversation</span>
<span x-show="creatingConversation">Creating...</span>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="{{ url_for('static', path='vendor/js/messages.js') }}"></script>
{% endblock %}

View File

@@ -117,6 +117,17 @@ Follows same pattern as admin sidebar
<span class="ml-4">Customers</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'messages'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'messages' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/messages`">
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
<span class="ml-4">Messages</span>
</a>
</li>
</ul>
<!-- Settings Section -->

402
static/vendor/js/messages.js vendored Normal file
View File

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