refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -2,19 +2,13 @@
"""
Messaging module exceptions.
Re-exports messaging-related exceptions from their source locations.
This module provides exception classes for messaging operations including:
- Conversation management
- Message handling
- Attachment processing
"""
from app.exceptions.message import (
ConversationNotFoundException,
MessageNotFoundException,
ConversationClosedException,
MessageAttachmentException,
UnauthorizedConversationAccessException,
InvalidConversationTypeException,
InvalidRecipientTypeException,
AttachmentNotFoundException,
)
from app.exceptions.base import BusinessLogicException, ResourceNotFoundException
__all__ = [
"ConversationNotFoundException",
@@ -26,3 +20,97 @@ __all__ = [
"InvalidRecipientTypeException",
"AttachmentNotFoundException",
]
class ConversationNotFoundException(ResourceNotFoundException):
"""Raised when a conversation is not found."""
def __init__(self, conversation_identifier: str):
super().__init__(
resource_type="Conversation",
identifier=conversation_identifier,
message=f"Conversation '{conversation_identifier}' not found",
error_code="CONVERSATION_NOT_FOUND",
)
class MessageNotFoundException(ResourceNotFoundException):
"""Raised when a message is not found."""
def __init__(self, message_identifier: str):
super().__init__(
resource_type="Message",
identifier=message_identifier,
message=f"Message '{message_identifier}' not found",
error_code="MESSAGE_NOT_FOUND",
)
class ConversationClosedException(BusinessLogicException):
"""Raised when trying to send message to a closed conversation."""
def __init__(self, conversation_id: int):
super().__init__(
message=f"Cannot send message to closed conversation {conversation_id}",
error_code="CONVERSATION_CLOSED",
details={"conversation_id": conversation_id},
)
class MessageAttachmentException(BusinessLogicException):
"""Raised when attachment validation fails."""
def __init__(self, message: str, details: dict | None = None):
super().__init__(
message=message,
error_code="MESSAGE_ATTACHMENT_INVALID",
details=details,
)
class UnauthorizedConversationAccessException(BusinessLogicException):
"""Raised when user tries to access a conversation they don't have access to."""
def __init__(self, conversation_id: int):
super().__init__(
message=f"You do not have access to conversation {conversation_id}",
error_code="CONVERSATION_ACCESS_DENIED",
details={"conversation_id": conversation_id},
)
class InvalidConversationTypeException(BusinessLogicException):
"""Raised when conversation type is not valid for the operation."""
def __init__(self, message: str, allowed_types: list[str] | None = None):
super().__init__(
message=message,
error_code="INVALID_CONVERSATION_TYPE",
details={"allowed_types": allowed_types} if allowed_types else None,
)
class InvalidRecipientTypeException(BusinessLogicException):
"""Raised when recipient type doesn't match conversation type."""
def __init__(self, conversation_type: str, expected_recipient_type: str):
super().__init__(
message=f"{conversation_type} conversations require a {expected_recipient_type} recipient",
error_code="INVALID_RECIPIENT_TYPE",
details={
"conversation_type": conversation_type,
"expected_recipient_type": expected_recipient_type,
},
)
class AttachmentNotFoundException(ResourceNotFoundException):
"""Raised when an attachment is not found."""
def __init__(self, attachment_id: int | str):
super().__init__(
resource_type="Attachment",
identifier=str(attachment_id),
message=f"Attachment '{attachment_id}' not found",
error_code="ATTACHMENT_NOT_FOUND",
)

View File

@@ -1,39 +0,0 @@
# app/modules/messaging/routes/admin.py
"""
Messaging module admin routes.
This module wraps the existing admin messages and notifications routes
and adds module-based access control. Routes are re-exported from the
original location with the module access dependency.
Includes:
- /messages/* - Message management
- /notifications/* - Notification management
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
# Import original routers (direct import to avoid circular dependency)
from app.api.v1.admin.messages import router as messages_original_router
from app.api.v1.admin.notifications import router as notifications_original_router
# Create module-aware router for messages
admin_router = APIRouter(
prefix="/messages",
dependencies=[Depends(require_module_access("messaging"))],
)
# Re-export all routes from the original messages module
for route in messages_original_router.routes:
admin_router.routes.append(route)
# Create separate router for notifications
admin_notifications_router = APIRouter(
prefix="/notifications",
dependencies=[Depends(require_module_access("messaging"))],
)
for route in notifications_original_router.routes:
admin_notifications_router.routes.append(route)

View File

@@ -2,6 +2,10 @@
"""
Messaging module API routes.
Admin routes:
- /messages/* - Conversation and message management
- /notifications/* - Admin notifications and platform alerts
Vendor routes:
- /messages/* - Conversation and message management
- /notifications/* - Vendor notifications
@@ -12,10 +16,11 @@ Storefront routes:
- Customer-facing messaging
"""
from app.modules.messaging.routes.api.admin import admin_router
from app.modules.messaging.routes.api.storefront import router as storefront_router
from app.modules.messaging.routes.api.vendor import vendor_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Messages (Storefront)"
__all__ = ["storefront_router", "vendor_router", "STOREFRONT_TAG"]
__all__ = ["admin_router", "storefront_router", "vendor_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,26 @@
# app/modules/messaging/routes/api/admin.py
"""
Messaging module admin API routes.
Aggregates all admin messaging routes:
- /messages/* - Conversation and message management
- /notifications/* - Admin notifications and platform alerts
- /email-templates/* - Email template management
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
from .admin_messages import admin_messages_router
from .admin_notifications import admin_notifications_router
from .admin_email_templates import admin_email_templates_router
admin_router = APIRouter(
dependencies=[Depends(require_module_access("messaging"))],
)
# Aggregate all messaging admin routes
admin_router.include_router(admin_messages_router, tags=["admin-messages"])
admin_router.include_router(admin_notifications_router, tags=["admin-notifications"])
admin_router.include_router(admin_email_templates_router, tags=["admin-email-templates"])

View File

@@ -0,0 +1,356 @@
# app/modules/messaging/routes/api/admin_email_templates.py
"""
Admin email template management endpoints.
Allows platform administrators to:
- View all email templates
- Edit template content for all languages
- Preview templates with sample data
- Send test emails
- View email logs
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.messaging.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from models.schema.auth import UserContext
admin_email_templates_router = APIRouter(prefix="/email-templates")
logger = logging.getLogger(__name__)
# =============================================================================
# SCHEMAS
# =============================================================================
class TemplateUpdate(BaseModel):
"""Schema for updating a platform template."""
subject: str = Field(..., min_length=1, max_length=500)
body_html: str = Field(..., min_length=1)
body_text: str | None = None
class PreviewRequest(BaseModel):
"""Schema for previewing a template."""
template_code: str
language: str = "en"
variables: dict[str, Any] = {}
class TestEmailRequest(BaseModel):
"""Schema for sending a test email."""
template_code: str
language: str = "en"
to_email: EmailStr
variables: dict[str, Any] = {}
class TemplateListItem(BaseModel):
"""Schema for a template in the list."""
code: str
name: str
description: str | None = None
category: str
languages: list[str] # Matches service output field name
is_platform_only: bool = False
variables: list[str] = []
class Config:
from_attributes = True
class TemplateListResponse(BaseModel):
"""Response schema for listing templates."""
templates: list[TemplateListItem]
class CategoriesResponse(BaseModel):
"""Response schema for template categories."""
categories: list[str]
# =============================================================================
# ENDPOINTS
# =============================================================================
@admin_email_templates_router.get("", response_model=TemplateListResponse)
def list_templates(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all platform email templates.
Returns templates grouped by code with available languages.
"""
service = EmailTemplateService(db)
return TemplateListResponse(templates=service.list_platform_templates())
@admin_email_templates_router.get("/categories", response_model=CategoriesResponse)
def get_categories(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get list of email template categories."""
service = EmailTemplateService(db)
return CategoriesResponse(categories=service.get_template_categories())
@admin_email_templates_router.get("/{code}")
def get_template(
code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a specific template with all language versions.
Returns template metadata and content for all available languages.
"""
service = EmailTemplateService(db)
return service.get_platform_template(code)
@admin_email_templates_router.get("/{code}/{language}")
def get_template_language(
code: str,
language: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a specific template for a specific language.
Returns template content with variables information.
"""
service = EmailTemplateService(db)
template = service.get_platform_template_language(code, language)
return {
"code": template.code,
"language": template.language,
"name": template.name,
"description": template.description,
"category": template.category,
"subject": template.subject,
"body_html": template.body_html,
"body_text": template.body_text,
"variables": template.variables,
"required_variables": template.required_variables,
"is_platform_only": template.is_platform_only,
}
@admin_email_templates_router.put("/{code}/{language}")
def update_template(
code: str,
language: str,
template_data: TemplateUpdate,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a platform email template.
Updates the template content for a specific language.
"""
service = EmailTemplateService(db)
service.update_platform_template(
code=code,
language=language,
subject=template_data.subject,
body_html=template_data.body_html,
body_text=template_data.body_text,
)
db.commit()
return {"message": "Template updated successfully"}
@admin_email_templates_router.post("/{code}/preview")
def preview_template(
code: str,
preview_data: PreviewRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Preview a template with sample variables.
Renders the template with provided variables and returns the result.
"""
service = EmailTemplateService(db)
# Merge with sample variables if not provided
variables = {
**_get_sample_variables(code),
**preview_data.variables,
}
return service.preview_template(code, preview_data.language, variables)
@admin_email_templates_router.post("/{code}/test")
def send_test_email(
code: str,
test_data: TestEmailRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Send a test email using the template.
Sends the template to the specified email address with sample data.
"""
# Merge with sample variables
variables = {
**_get_sample_variables(code),
**test_data.variables,
}
try:
email_svc = EmailService(db)
email_log = email_svc.send_template(
template_code=code,
to_email=test_data.to_email,
variables=variables,
language=test_data.language,
)
if email_log.status == "sent":
return {
"success": True,
"message": f"Test email sent to {test_data.to_email}",
}
else:
return {
"success": False,
"message": email_log.error_message or "Failed to send email",
}
except Exception as e:
logger.exception(f"Failed to send test email: {e}")
return {
"success": False,
"message": str(e),
}
@admin_email_templates_router.get("/{code}/logs")
def get_template_logs(
code: str,
limit: int = 50,
offset: int = 0,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get email logs for a specific template.
Returns recent email send attempts for the template.
"""
service = EmailTemplateService(db)
logs, total = service.get_template_logs(code, limit, offset)
return {
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
}
# =============================================================================
# HELPERS
# =============================================================================
def _get_sample_variables(template_code: str) -> dict[str, Any]:
"""Get sample variables for testing templates."""
samples = {
"signup_welcome": {
"first_name": "John",
"company_name": "Acme Corp",
"email": "john@example.com",
"vendor_code": "acme",
"login_url": "https://example.com/login",
"trial_days": "14",
"tier_name": "Business",
"platform_name": "Wizamart",
},
"order_confirmation": {
"customer_name": "Jane Doe",
"order_number": "ORD-12345",
"order_total": "€99.99",
"order_items_count": "3",
"order_date": "2024-01-15",
"shipping_address": "123 Main St, Luxembourg City, L-1234",
"platform_name": "Wizamart",
},
"password_reset": {
"customer_name": "John Doe",
"reset_link": "https://example.com/reset?token=abc123",
"expiry_hours": "1",
"platform_name": "Wizamart",
},
"team_invite": {
"invitee_name": "Jane",
"inviter_name": "John",
"vendor_name": "Acme Corp",
"role": "Admin",
"accept_url": "https://example.com/accept",
"expires_in_days": "7",
"platform_name": "Wizamart",
},
"subscription_welcome": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"billing_cycle": "Monthly",
"amount": "€49.99",
"next_billing_date": "2024-02-15",
"dashboard_url": "https://example.com/dashboard",
"platform_name": "Wizamart",
},
"payment_failed": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"amount": "€49.99",
"retry_date": "2024-01-18",
"update_payment_url": "https://example.com/billing",
"support_email": "support@wizamart.com",
"platform_name": "Wizamart",
},
"subscription_cancelled": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"end_date": "2024-02-15",
"reactivate_url": "https://example.com/billing",
"platform_name": "Wizamart",
},
"trial_ending": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"days_remaining": "3",
"trial_end_date": "2024-01-18",
"upgrade_url": "https://example.com/upgrade",
"features_list": "Unlimited products, API access, Priority support",
"platform_name": "Wizamart",
},
}
return samples.get(template_code, {"platform_name": "Wizamart"})

View File

@@ -0,0 +1,624 @@
# app/modules/messaging/routes/api/admin_messages.py
"""
Admin messaging endpoints.
Provides endpoints for:
- Viewing conversations (admin_vendor and admin_customer channels)
- Sending and receiving messages
- Managing conversation status
- File attachments
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.messaging.exceptions import (
ConversationClosedException,
ConversationNotFoundException,
InvalidConversationTypeException,
InvalidRecipientTypeException,
MessageAttachmentException,
)
from app.modules.messaging.services.message_attachment_service import message_attachment_service
from app.modules.messaging.services.messaging_service import messaging_service
from app.modules.messaging.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AdminConversationListResponse,
AdminConversationSummary,
AttachmentResponse,
CloseConversationResponse,
ConversationCreate,
ConversationDetailResponse,
MarkReadResponse,
MessageCreate,
MessageResponse,
NotificationPreferencesUpdate,
ParticipantInfo,
ParticipantResponse,
RecipientListResponse,
RecipientOption,
ReopenConversationResponse,
UnreadCountResponse,
)
from models.schema.auth import UserContext
admin_messages_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
) -> AdminConversationSummary:
"""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.ADMIN
and p.participant_id == current_user_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.ADMIN, 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
# Get vendor info if applicable
vendor_name = None
vendor_code = None
if conversation.vendor:
vendor_name = conversation.vendor.name
vendor_code = conversation.vendor.vendor_code
return AdminConversationSummary(
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,
vendor_name=vendor_name,
vendor_code=vendor_code,
)
# ============================================================================
# CONVERSATION LIST
# ============================================================================
@admin_messages_router.get("", response_model=AdminConversationListResponse)
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_admin: UserContext = Depends(get_current_admin_api),
) -> AdminConversationListResponse:
"""List conversations for admin (admin_vendor and admin_customer channels)."""
conversations, total, total_unread = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
conversation_type=conversation_type,
is_closed=is_closed,
skip=skip,
limit=limit,
)
return AdminConversationListResponse(
conversations=[
_enrich_conversation_summary(db, c, current_admin.id) for c in conversations
],
total=total,
total_unread=total_unread,
skip=skip,
limit=limit,
)
@admin_messages_router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> UnreadCountResponse:
"""Get total unread message count for header badge."""
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
return UnreadCountResponse(total_unread=count)
# ============================================================================
# RECIPIENTS
# ============================================================================
@admin_messages_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"),
vendor_id: int | None = Query(None, description="Filter by vendor"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RecipientListResponse:
"""Get list of available recipients for compose modal."""
if recipient_type == ParticipantType.VENDOR:
recipient_data, total = messaging_service.get_vendor_recipients(
db=db,
vendor_id=vendor_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
vendor_id=r["vendor_id"],
vendor_name=r.get("vendor_name"),
)
for r in recipient_data
]
elif recipient_type == ParticipantType.CUSTOMER:
recipient_data, total = messaging_service.get_customer_recipients(
db=db,
vendor_id=vendor_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
vendor_id=r["vendor_id"],
)
for r in recipient_data
]
else:
recipients = []
total = 0
return RecipientListResponse(recipients=recipients, total=total)
# ============================================================================
# CREATE CONVERSATION
# ============================================================================
@admin_messages_router.post("", response_model=ConversationDetailResponse)
def create_conversation(
data: ConversationCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> ConversationDetailResponse:
"""Create a new conversation."""
# Validate conversation type for admin
if data.conversation_type not in [
ConversationType.ADMIN_VENDOR,
ConversationType.ADMIN_CUSTOMER,
]:
raise InvalidConversationTypeException(
message="Admin can only create admin_vendor or admin_customer conversations",
allowed_types=["admin_vendor", "admin_customer"],
)
# Validate recipient type matches conversation type
if (
data.conversation_type == ConversationType.ADMIN_VENDOR
and data.recipient_type != ParticipantType.VENDOR
):
raise InvalidRecipientTypeException(
conversation_type="admin_vendor",
expected_recipient_type="vendor",
)
if (
data.conversation_type == ConversationType.ADMIN_CUSTOMER
and data.recipient_type != ParticipantType.CUSTOMER
):
raise InvalidRecipientTypeException(
conversation_type="admin_customer",
expected_recipient_type="customer",
)
# Create conversation
conversation = messaging_service.create_conversation(
db=db,
conversation_type=data.conversation_type,
subject=data.subject,
initiator_type=ParticipantType.ADMIN,
initiator_id=current_admin.id,
recipient_type=data.recipient_type,
recipient_id=data.recipient_id,
vendor_id=data.vendor_id,
initial_message=data.initial_message,
)
db.commit()
db.refresh(conversation)
logger.info(
f"Admin {current_admin.username} created conversation {conversation.id} "
f"with {data.recipient_type.value}:{data.recipient_id}"
)
# Return full detail response
return _build_conversation_detail(db, conversation, current_admin.id)
# ============================================================================
# CONVERSATION DETAIL
# ============================================================================
def _build_conversation_detail(
db: Session, conversation: Any, current_user_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.ADMIN
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,
)
@admin_messages_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_admin: UserContext = Depends(get_current_admin_api),
) -> ConversationDetailResponse:
"""Get conversation detail with messages."""
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Mark as read if requested
if mark_read:
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.ADMIN,
reader_id=current_admin.id,
)
db.commit()
return _build_conversation_detail(db, conversation, current_admin.id)
# ============================================================================
# SEND MESSAGE
# ============================================================================
@admin_messages_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_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Send a message in a conversation, optionally with attachments."""
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
if conversation.is_closed:
raise ConversationClosedException(conversation_id)
# 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 MessageAttachmentException(str(e))
# Send message
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.ADMIN,
sender_id=current_admin.id,
content=content,
attachments=attachments if attachments else None,
)
db.commit()
db.refresh(message)
logger.info(
f"Admin {current_admin.username} sent message {message.id} "
f"in conversation {conversation_id}"
)
return _enrich_message(db, message)
# ============================================================================
# CONVERSATION ACTIONS
# ============================================================================
@admin_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse)
def close_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> CloseConversationResponse:
"""Close a conversation."""
conversation = messaging_service.close_conversation(
db=db,
conversation_id=conversation_id,
closer_type=ParticipantType.ADMIN,
closer_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
db.commit()
logger.info(
f"Admin {current_admin.username} closed conversation {conversation_id}"
)
return CloseConversationResponse(
success=True,
message="Conversation closed",
conversation_id=conversation_id,
)
@admin_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse)
def reopen_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> ReopenConversationResponse:
"""Reopen a closed conversation."""
conversation = messaging_service.reopen_conversation(
db=db,
conversation_id=conversation_id,
opener_type=ParticipantType.ADMIN,
opener_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
db.commit()
logger.info(
f"Admin {current_admin.username} reopened conversation {conversation_id}"
)
return ReopenConversationResponse(
success=True,
message="Conversation reopened",
conversation_id=conversation_id,
)
@admin_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse)
def mark_read(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MarkReadResponse:
"""Mark conversation as read."""
success = messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.ADMIN,
reader_id=current_admin.id,
)
db.commit()
return MarkReadResponse(
success=success,
conversation_id=conversation_id,
unread_count=0,
)
class PreferencesUpdateResponse(BaseModel):
"""Response for preferences update."""
success: bool
@admin_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse)
def update_preferences(
conversation_id: int,
preferences: NotificationPreferencesUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> PreferencesUpdateResponse:
"""Update notification preferences for a conversation."""
success = messaging_service.update_notification_preferences(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
email_notifications=preferences.email_notifications,
muted=preferences.muted,
)
db.commit()
return PreferencesUpdateResponse(success=success)

View File

@@ -0,0 +1,327 @@
# app/modules/messaging/routes/api/admin_notifications.py
"""
Admin notifications and platform alerts endpoints.
Provides endpoints for:
- Viewing admin notifications
- Managing platform alerts
- System health monitoring
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.messaging.services.admin_notification_service import (
admin_notification_service,
platform_alert_service,
)
from models.schema.auth import UserContext
from models.schema.admin import (
AdminNotificationCreate,
AdminNotificationListResponse,
AdminNotificationResponse,
PlatformAlertCreate,
PlatformAlertListResponse,
PlatformAlertResolve,
PlatformAlertResponse,
)
from app.modules.messaging.schemas import (
AlertStatisticsResponse,
MessageResponse,
UnreadCountResponse,
)
admin_notifications_router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
# ============================================================================
# ADMIN NOTIFICATIONS
# ============================================================================
@admin_notifications_router.get("", response_model=AdminNotificationListResponse)
def get_notifications(
priority: str | None = Query(None, description="Filter by priority"),
notification_type: str | None = Query(None, description="Filter by type"),
is_read: bool | None = Query(None, description="Filter by read status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminNotificationListResponse:
"""Get admin notifications with filtering."""
notifications, total, unread_count = admin_notification_service.get_notifications(
db=db,
priority=priority,
is_read=is_read,
notification_type=notification_type,
skip=skip,
limit=limit,
)
return AdminNotificationListResponse(
notifications=[
AdminNotificationResponse(
id=n.id,
type=n.type,
priority=n.priority,
title=n.title,
message=n.message,
is_read=n.is_read,
read_at=n.read_at,
read_by_user_id=n.read_by_user_id,
action_required=n.action_required,
action_url=n.action_url,
metadata=n.notification_metadata,
created_at=n.created_at,
)
for n in notifications
],
total=total,
unread_count=unread_count,
skip=skip,
limit=limit,
)
@admin_notifications_router.post("", response_model=AdminNotificationResponse)
def create_notification(
notification_data: AdminNotificationCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminNotificationResponse:
"""Create a new admin notification (manual)."""
notification = admin_notification_service.create_from_schema(
db=db, data=notification_data
)
db.commit()
logger.info(f"Admin {current_admin.username} created notification: {notification.title}")
return AdminNotificationResponse(
id=notification.id,
type=notification.type,
priority=notification.priority,
title=notification.title,
message=notification.message,
is_read=notification.is_read,
read_at=notification.read_at,
read_by_user_id=notification.read_by_user_id,
action_required=notification.action_required,
action_url=notification.action_url,
metadata=notification.notification_metadata,
created_at=notification.created_at,
)
@admin_notifications_router.get("/recent")
def get_recent_notifications(
limit: int = Query(5, ge=1, le=10),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> dict:
"""Get recent unread notifications for header dropdown."""
notifications = admin_notification_service.get_recent_notifications(
db=db, limit=limit
)
unread_count = admin_notification_service.get_unread_count(db)
return {
"notifications": [
{
"id": n.id,
"type": n.type,
"priority": n.priority,
"title": n.title,
"message": n.message[:100] + "..." if len(n.message) > 100 else n.message,
"action_url": n.action_url,
"created_at": n.created_at.isoformat(),
}
for n in notifications
],
"unread_count": unread_count,
}
@admin_notifications_router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> UnreadCountResponse:
"""Get count of unread notifications."""
count = admin_notification_service.get_unread_count(db)
return UnreadCountResponse(unread_count=count)
@admin_notifications_router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Mark notification as read."""
notification = admin_notification_service.mark_as_read(
db=db, notification_id=notification_id, user_id=current_admin.id
)
db.commit()
if notification:
return MessageResponse(message="Notification marked as read")
return MessageResponse(message="Notification not found")
@admin_notifications_router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Mark all notifications as read."""
count = admin_notification_service.mark_all_as_read(
db=db, user_id=current_admin.id
)
db.commit()
return MessageResponse(message=f"Marked {count} notifications as read")
@admin_notifications_router.delete("/{notification_id}", response_model=MessageResponse)
def delete_notification(
notification_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Delete a notification."""
deleted = admin_notification_service.delete_notification(
db=db, notification_id=notification_id
)
db.commit()
if deleted:
return MessageResponse(message="Notification deleted")
return MessageResponse(message="Notification not found")
# ============================================================================
# PLATFORM ALERTS
# ============================================================================
@admin_notifications_router.get("/alerts", response_model=PlatformAlertListResponse)
def get_platform_alerts(
severity: str | None = Query(None, description="Filter by severity"),
alert_type: str | None = Query(None, description="Filter by alert type"),
is_resolved: bool | None = Query(None, description="Filter by resolution status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> PlatformAlertListResponse:
"""Get platform alerts with filtering."""
alerts, total, active_count, critical_count = platform_alert_service.get_alerts(
db=db,
severity=severity,
alert_type=alert_type,
is_resolved=is_resolved,
skip=skip,
limit=limit,
)
return PlatformAlertListResponse(
alerts=[
PlatformAlertResponse(
id=a.id,
alert_type=a.alert_type,
severity=a.severity,
title=a.title,
description=a.description,
affected_vendors=a.affected_vendors,
affected_systems=a.affected_systems,
is_resolved=a.is_resolved,
resolved_at=a.resolved_at,
resolved_by_user_id=a.resolved_by_user_id,
resolution_notes=a.resolution_notes,
auto_generated=a.auto_generated,
occurrence_count=a.occurrence_count,
first_occurred_at=a.first_occurred_at,
last_occurred_at=a.last_occurred_at,
created_at=a.created_at,
)
for a in alerts
],
total=total,
active_count=active_count,
critical_count=critical_count,
skip=skip,
limit=limit,
)
@admin_notifications_router.post("/alerts", response_model=PlatformAlertResponse)
def create_platform_alert(
alert_data: PlatformAlertCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> PlatformAlertResponse:
"""Create new platform alert (manual)."""
alert = platform_alert_service.create_from_schema(db=db, data=alert_data)
db.commit()
logger.info(f"Admin {current_admin.username} created alert: {alert.title}")
return PlatformAlertResponse(
id=alert.id,
alert_type=alert.alert_type,
severity=alert.severity,
title=alert.title,
description=alert.description,
affected_vendors=alert.affected_vendors,
affected_systems=alert.affected_systems,
is_resolved=alert.is_resolved,
resolved_at=alert.resolved_at,
resolved_by_user_id=alert.resolved_by_user_id,
resolution_notes=alert.resolution_notes,
auto_generated=alert.auto_generated,
occurrence_count=alert.occurrence_count,
first_occurred_at=alert.first_occurred_at,
last_occurred_at=alert.last_occurred_at,
created_at=alert.created_at,
)
@admin_notifications_router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
def resolve_platform_alert(
alert_id: int,
resolve_data: PlatformAlertResolve,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Resolve platform alert."""
alert = platform_alert_service.resolve_alert(
db=db,
alert_id=alert_id,
user_id=current_admin.id,
resolution_notes=resolve_data.resolution_notes,
)
db.commit()
if alert:
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
return MessageResponse(message="Alert resolved successfully")
return MessageResponse(message="Alert not found or already resolved")
@admin_notifications_router.get("/alerts/stats", response_model=AlertStatisticsResponse)
def get_alert_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AlertStatisticsResponse:
"""Get alert statistics for dashboard."""
stats = platform_alert_service.get_statistics(db)
return AlertStatisticsResponse(**stats)

View File

@@ -27,12 +27,12 @@ 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 (
from app.modules.messaging.exceptions import (
AttachmentNotFoundException,
ConversationClosedException,
ConversationNotFoundException,
VendorNotFoundException,
)
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.customers.schemas import CustomerContext
from app.modules.messaging.models.message import ConversationType, ParticipantType
from app.modules.messaging.schemas import (

View File

@@ -20,8 +20,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_email_settings_service import VendorEmailSettingsService
from app.services.subscription_service import subscription_service
from app.modules.cms.services.vendor_email_settings_service import VendorEmailSettingsService
from app.modules.billing.services.subscription_service import subscription_service
from models.schema.auth import UserContext
vendor_email_settings_router = APIRouter(prefix="/email-settings")

View File

@@ -17,9 +17,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.email_service import EmailService
from app.services.email_template_service import EmailTemplateService
from app.services.vendor_service import vendor_service
from app.modules.messaging.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
vendor_email_templates_router = APIRouter(prefix="/email-templates")

View File

@@ -20,15 +20,15 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import (
from app.modules.messaging.exceptions import (
ConversationClosedException,
ConversationNotFoundException,
InvalidConversationTypeException,
InvalidRecipientTypeException,
MessageAttachmentException,
)
from app.services.message_attachment_service import message_attachment_service
from app.services.messaging_service import messaging_service
from app.modules.messaging.services.message_attachment_service import message_attachment_service
from app.modules.messaging.services.messaging_service import messaging_service
from app.modules.messaging.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AttachmentResponse,

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.messaging.schemas import (
MessageResponse,

View File

@@ -1,4 +1,2 @@
# Page routes will be added here
# TODO: Add HTML page routes for admin/vendor dashboards
__all__ = []
# app/modules/messaging/routes/pages/__init__.py
"""Messaging module page routes."""

View File

@@ -0,0 +1,110 @@
# app/modules/messaging/routes/pages/admin.py
"""
Messaging Admin Page Routes (HTML rendering).
Admin pages for messaging management:
- Notifications
- Messages list
- Conversation detail
- Email templates
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# NOTIFICATIONS ROUTES
# ============================================================================
@router.get("/notifications", response_class=HTMLResponse, include_in_schema=False)
async def admin_notifications_page(
request: Request,
current_user: User = Depends(
require_menu_access("notifications", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render notifications management page.
Shows all admin notifications and platform alerts.
"""
return templates.TemplateResponse(
"messaging/admin/notifications.html",
get_admin_context(request, current_user),
)
# ============================================================================
# MESSAGING ROUTES
# ============================================================================
@router.get("/messages", response_class=HTMLResponse, include_in_schema=False)
async def admin_messages_page(
request: Request,
current_user: User = Depends(require_menu_access("messages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render messaging page.
Shows all conversations (admin_vendor and admin_customer channels).
"""
return templates.TemplateResponse(
"messaging/admin/messages.html",
get_admin_context(request, current_user),
)
@router.get(
"/messages/{conversation_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_conversation_detail_page(
request: Request,
conversation_id: int = Path(..., description="Conversation ID"),
current_user: User = Depends(require_menu_access("messages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render conversation detail page.
Shows the full conversation thread with messages.
"""
return templates.TemplateResponse(
"messaging/admin/messages.html",
get_admin_context(request, current_user, conversation_id=conversation_id),
)
# ============================================================================
# EMAIL TEMPLATES ROUTES
# ============================================================================
@router.get("/email-templates", response_class=HTMLResponse, include_in_schema=False)
async def admin_email_templates_page(
request: Request,
current_user: User = Depends(
require_menu_access("email-templates", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render email templates management page.
Shows all platform email templates with edit capabilities.
"""
return templates.TemplateResponse(
"messaging/admin/email-templates.html",
get_admin_context(request, current_user),
)

View File

@@ -0,0 +1,90 @@
# app/modules/messaging/routes/pages/storefront.py
"""
Messaging Storefront Page Routes (HTML rendering).
Storefront (customer shop) pages for messaging:
- Messages list
- Conversation detail
"""
import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_storefront_context
from app.modules.customers.models import Customer
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# CUSTOMER ACCOUNT - MESSAGES (Authenticated)
# ============================================================================
@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(
"[STOREFRONT] 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(
"messaging/storefront/messages.html",
get_storefront_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(
"[STOREFRONT] 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(
"messaging/storefront/messages.html",
get_storefront_context(
request, db=db, user=current_customer, conversation_id=conversation_id
),
)

View File

@@ -0,0 +1,94 @@
# app/modules/messaging/routes/pages/vendor.py
"""
Messaging Vendor Page Routes (HTML rendering).
Vendor pages for messaging management:
- Messages list
- Conversation detail
- Email templates
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# 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),
db: Session = Depends(get_db),
):
"""
Render messages page.
JavaScript loads conversations and messages via API.
"""
return templates.TemplateResponse(
"messaging/vendor/messages.html",
get_vendor_context(request, db, current_user, 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),
db: Session = Depends(get_db),
):
"""
Render message detail page.
Shows the full conversation thread.
"""
return templates.TemplateResponse(
"messaging/vendor/messages.html",
get_vendor_context(
request, db, current_user, vendor_code, conversation_id=conversation_id
),
)
# ============================================================================
# EMAIL TEMPLATES
# ============================================================================
@router.get(
"/{vendor_code}/email-templates",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_email_templates_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render vendor email templates customization page.
Allows vendors to override platform email templates.
"""
return templates.TemplateResponse(
"messaging/vendor/email-templates.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -24,6 +24,46 @@ from app.modules.messaging.services.admin_notification_service import (
AlertType,
Severity,
)
from app.modules.messaging.services.email_service import (
EmailService,
EmailProvider,
ResolvedTemplate,
BrandingContext,
send_email,
get_provider,
get_platform_provider,
get_vendor_provider,
get_platform_email_config,
# Provider classes
SMTPProvider,
SendGridProvider,
MailgunProvider,
SESProvider,
DebugProvider,
# Configurable provider classes
ConfigurableSMTPProvider,
ConfigurableSendGridProvider,
ConfigurableMailgunProvider,
ConfigurableSESProvider,
# Vendor provider classes
VendorSMTPProvider,
VendorSendGridProvider,
VendorMailgunProvider,
VendorSESProvider,
# Constants
PLATFORM_NAME,
PLATFORM_SUPPORT_EMAIL,
PLATFORM_DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES,
WHITELABEL_TIERS,
POWERED_BY_FOOTER_HTML,
POWERED_BY_FOOTER_TEXT,
)
from app.modules.messaging.services.email_template_service import (
EmailTemplateService,
TemplateData,
VendorOverrideData,
)
__all__ = [
"messaging_service",
@@ -39,4 +79,42 @@ __all__ = [
"Priority",
"AlertType",
"Severity",
# Email service
"EmailService",
"EmailProvider",
"ResolvedTemplate",
"BrandingContext",
"send_email",
"get_provider",
"get_platform_provider",
"get_vendor_provider",
"get_platform_email_config",
# Provider classes
"SMTPProvider",
"SendGridProvider",
"MailgunProvider",
"SESProvider",
"DebugProvider",
# Configurable provider classes
"ConfigurableSMTPProvider",
"ConfigurableSendGridProvider",
"ConfigurableMailgunProvider",
"ConfigurableSESProvider",
# Vendor provider classes
"VendorSMTPProvider",
"VendorSendGridProvider",
"VendorMailgunProvider",
"VendorSESProvider",
# Email constants
"PLATFORM_NAME",
"PLATFORM_SUPPORT_EMAIL",
"PLATFORM_DEFAULT_LANGUAGE",
"SUPPORTED_LANGUAGES",
"WHITELABEL_TIERS",
"POWERED_BY_FOOTER_HTML",
"POWERED_BY_FOOTER_TEXT",
# Email template service
"EmailTemplateService",
"TemplateData",
"VendorOverrideData",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,717 @@
# app/modules/messaging/services/email_template_service.py
"""
Email Template Service
Handles business logic for email template management:
- Platform template CRUD operations
- Vendor template override management
- Template preview and testing
- Email log queries
This service layer separates business logic from API endpoints
to follow the project's layered architecture.
"""
import logging
from dataclasses import dataclass
from typing import Any
from jinja2 import Template
from sqlalchemy.orm import Session
from app.exceptions.base import (
AuthorizationException,
ResourceNotFoundException,
ValidationException,
)
from models.database.email import EmailCategory, EmailLog, EmailTemplate
from models.database.vendor_email_template import VendorEmailTemplate
logger = logging.getLogger(__name__)
# Supported languages
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
@dataclass
class TemplateData:
"""Template data container."""
code: str
language: str
name: str
description: str | None
category: str
subject: str
body_html: str
body_text: str | None
variables: list[str]
required_variables: list[str]
is_platform_only: bool
@dataclass
class VendorOverrideData:
"""Vendor override data container."""
code: str
language: str
subject: str
body_html: str
body_text: str | None
name: str | None
updated_at: str | None
class EmailTemplateService:
"""Service for managing email templates."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# ADMIN OPERATIONS
# =========================================================================
def list_platform_templates(self) -> list[dict[str, Any]]:
"""
List all platform email templates grouped by code.
Returns:
List of template summaries with language availability.
"""
templates = (
self.db.query(EmailTemplate)
.order_by(EmailTemplate.category, EmailTemplate.code)
.all()
)
# Group by code
grouped: dict[str, dict] = {}
for template in templates:
if template.code not in grouped:
grouped[template.code] = {
"code": template.code,
"name": template.name,
"category": template.category,
"description": template.description,
"is_platform_only": template.is_platform_only,
"languages": [],
"variables": [],
}
grouped[template.code]["languages"].append(template.language)
if template.variables and not grouped[template.code]["variables"]:
try:
import json
grouped[template.code]["variables"] = json.loads(template.variables)
except (json.JSONDecodeError, TypeError):
pass
return list(grouped.values())
def get_template_categories(self) -> list[str]:
"""Get list of all template categories."""
return [cat.value for cat in EmailCategory]
def get_platform_template(self, code: str) -> dict[str, Any]:
"""
Get a platform template with all language versions.
Args:
code: Template code
Returns:
Template details with all language versions
Raises:
NotFoundError: If template not found
"""
templates = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.all()
)
if not templates:
raise ResourceNotFoundException(f"Template not found: {code}")
first = templates[0]
languages = {}
for t in templates:
languages[t.language] = {
"subject": t.subject,
"body_html": t.body_html,
"body_text": t.body_text,
}
return {
"code": code,
"name": first.name,
"description": first.description,
"category": first.category,
"is_platform_only": first.is_platform_only,
"variables": self._parse_variables(first.variables),
"required_variables": self._parse_required_variables(first.required_variables),
"languages": languages,
}
def get_platform_template_language(self, code: str, language: str) -> TemplateData:
"""
Get a specific language version of a platform template.
Args:
code: Template code
language: Language code
Returns:
Template data for the specific language
Raises:
NotFoundError: If template or language not found
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
return TemplateData(
code=template.code,
language=template.language,
name=template.name,
description=template.description,
category=template.category,
subject=template.subject,
body_html=template.body_html,
body_text=template.body_text,
variables=self._parse_variables(template.variables),
required_variables=self._parse_required_variables(template.required_variables),
is_platform_only=template.is_platform_only,
)
def update_platform_template(
self,
code: str,
language: str,
subject: str,
body_html: str,
body_text: str | None = None,
) -> None:
"""
Update a platform email template.
Args:
code: Template code
language: Language code
subject: New subject line
body_html: New HTML body
body_text: New plain text body (optional)
Raises:
NotFoundError: If template not found
ValidationError: If template syntax is invalid
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
# Validate Jinja2 syntax
self._validate_template_syntax(subject, body_html, body_text)
template.subject = subject
template.body_html = body_html
template.body_text = body_text
logger.info(f"Updated platform template: {code}/{language}")
def preview_template(
self,
code: str,
language: str,
variables: dict[str, Any],
) -> dict[str, str]:
"""
Preview a template with sample variables.
Args:
code: Template code
language: Language code
variables: Variables to render
Returns:
Rendered subject and body
Raises:
NotFoundError: If template not found
ValidationError: If rendering fails
"""
template = EmailTemplate.get_by_code_and_language(self.db, code, language)
if not template:
raise ResourceNotFoundException(f"Template not found: {code}/{language}")
try:
rendered_subject = Template(template.subject).render(variables)
rendered_html = Template(template.body_html).render(variables)
rendered_text = Template(template.body_text).render(variables) if template.body_text else None
except Exception as e:
raise ValidationException(f"Template rendering error: {str(e)}")
return {
"subject": rendered_subject,
"body_html": rendered_html,
"body_text": rendered_text,
}
def get_template_logs(
self,
code: str,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict], int]:
"""
Get email logs for a specific template.
Args:
code: Template code
limit: Max results
offset: Skip results
Returns:
Tuple of (logs list, total count)
"""
query = (
self.db.query(EmailLog)
.filter(EmailLog.template_code == code)
.order_by(EmailLog.created_at.desc())
)
total = query.count()
logs = query.offset(offset).limit(limit).all()
return (
[
{
"id": log.id,
"to_email": log.to_email,
"status": log.status,
"language": log.language,
"created_at": log.created_at.isoformat() if log.created_at else None,
"error_message": log.error_message,
}
for log in logs
],
total,
)
# =========================================================================
# VENDOR OPERATIONS
# =========================================================================
def list_overridable_templates(self, vendor_id: int) -> dict[str, Any]:
"""
List all templates that a vendor can customize.
Args:
vendor_id: Vendor ID
Returns:
Dict with templates list and supported languages
"""
# Get all overridable platform templates
platform_templates = EmailTemplate.get_overridable_templates(self.db)
# Get all vendor overrides
vendor_overrides = VendorEmailTemplate.get_all_overrides_for_vendor(
self.db, vendor_id
)
# Build override lookup
override_lookup = {}
for override in vendor_overrides:
key = (override.template_code, override.language)
override_lookup[key] = override
# Build response
templates = []
for template in platform_templates:
# Check which languages have overrides
override_languages = []
for lang in SUPPORTED_LANGUAGES:
if (template.code, lang) in override_lookup:
override_languages.append(lang)
templates.append({
"code": template.code,
"name": template.name,
"category": template.category,
"description": template.description,
"available_languages": self._get_template_languages(template.code),
"override_languages": override_languages,
"has_override": len(override_languages) > 0,
"variables": self._parse_required_variables(template.required_variables),
})
return {
"templates": templates,
"supported_languages": SUPPORTED_LANGUAGES,
}
def get_vendor_template(self, vendor_id: int, code: str) -> dict[str, Any]:
"""
Get a template with all language versions for a vendor.
Args:
vendor_id: Vendor ID
code: Template code
Returns:
Template details with vendor overrides status
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
"""
# Get platform template
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Get all language versions
platform_versions = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.all()
)
# Get vendor overrides
vendor_overrides = (
self.db.query(VendorEmailTemplate)
.filter(
VendorEmailTemplate.vendor_id == vendor_id,
VendorEmailTemplate.template_code == code,
)
.all()
)
override_lookup = {v.language: v for v in vendor_overrides}
platform_lookup = {t.language: t for t in platform_versions}
# Build language versions
languages = {}
for lang in SUPPORTED_LANGUAGES:
platform_ver = platform_lookup.get(lang)
override_ver = override_lookup.get(lang)
languages[lang] = {
"has_platform_template": platform_ver is not None,
"has_vendor_override": override_ver is not None,
"platform": {
"subject": platform_ver.subject,
"body_html": platform_ver.body_html,
"body_text": platform_ver.body_text,
} if platform_ver else None,
"vendor_override": {
"subject": override_ver.subject,
"body_html": override_ver.body_html,
"body_text": override_ver.body_text,
"name": override_ver.name,
"updated_at": override_ver.updated_at.isoformat() if override_ver else None,
} if override_ver else None,
}
return {
"code": code,
"name": platform_template.name,
"category": platform_template.category,
"description": platform_template.description,
"variables": self._parse_required_variables(platform_template.required_variables),
"languages": languages,
}
def get_vendor_template_language(
self,
vendor_id: int,
code: str,
language: str,
) -> dict[str, Any]:
"""
Get a specific language version for a vendor (override or platform).
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
Returns:
Template data with source indicator
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
ValidationError: If language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
# Check if template is overridable
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Check for vendor override
vendor_override = VendorEmailTemplate.get_override(
self.db, vendor_id, code, language
)
# Get platform version
platform_version = EmailTemplate.get_by_code_and_language(
self.db, code, language
)
if vendor_override:
return {
"code": code,
"language": language,
"source": "vendor_override",
"subject": vendor_override.subject,
"body_html": vendor_override.body_html,
"body_text": vendor_override.body_text,
"name": vendor_override.name,
"variables": self._parse_required_variables(platform_template.required_variables),
"platform_template": {
"subject": platform_version.subject,
"body_html": platform_version.body_html,
} if platform_version else None,
}
elif platform_version:
return {
"code": code,
"language": language,
"source": "platform",
"subject": platform_version.subject,
"body_html": platform_version.body_html,
"body_text": platform_version.body_text,
"name": platform_version.name,
"variables": self._parse_required_variables(platform_template.required_variables),
"platform_template": None,
}
else:
raise ResourceNotFoundException(f"No template found for language: {language}")
def create_or_update_vendor_override(
self,
vendor_id: int,
code: str,
language: str,
subject: str,
body_html: str,
body_text: str | None = None,
name: str | None = None,
) -> dict[str, Any]:
"""
Create or update a vendor template override.
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
subject: Custom subject
body_html: Custom HTML body
body_text: Custom plain text body
name: Custom template name
Returns:
Result with is_new indicator
Raises:
NotFoundError: If template not found
ForbiddenError: If template is platform-only
ValidationError: If syntax invalid or language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
# Check if template exists and is overridable
platform_template = (
self.db.query(EmailTemplate)
.filter(EmailTemplate.code == code)
.first()
)
if not platform_template:
raise ResourceNotFoundException(f"Template not found: {code}")
if platform_template.is_platform_only:
raise AuthorizationException("This is a platform-only template and cannot be customized")
# Validate template syntax
self._validate_template_syntax(subject, body_html, body_text)
# Create or update
override = VendorEmailTemplate.create_or_update(
db=self.db,
vendor_id=vendor_id,
template_code=code,
language=language,
subject=subject,
body_html=body_html,
body_text=body_text,
name=name,
)
logger.info(f"Vendor {vendor_id} updated template override: {code}/{language}")
return {
"message": "Template override saved",
"code": code,
"language": language,
"is_new": override.created_at == override.updated_at,
}
def delete_vendor_override(
self,
vendor_id: int,
code: str,
language: str,
) -> None:
"""
Delete a vendor template override.
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
Raises:
NotFoundError: If override not found
ValidationError: If language not supported
"""
if language not in SUPPORTED_LANGUAGES:
raise ValidationException(f"Unsupported language: {language}")
deleted = VendorEmailTemplate.delete_override(
self.db, vendor_id, code, language
)
if not deleted:
raise ResourceNotFoundException("No override found for this template and language")
logger.info(f"Vendor {vendor_id} deleted template override: {code}/{language}")
def preview_vendor_template(
self,
vendor_id: int,
code: str,
language: str,
variables: dict[str, Any],
) -> dict[str, Any]:
"""
Preview a vendor template (override or platform).
Args:
vendor_id: Vendor ID
code: Template code
language: Language code
variables: Variables to render
Returns:
Rendered template with source indicator
Raises:
NotFoundError: If template not found
ValidationError: If rendering fails
"""
# Get template content
vendor_override = VendorEmailTemplate.get_override(
self.db, vendor_id, code, language
)
platform_version = EmailTemplate.get_by_code_and_language(
self.db, code, language
)
if vendor_override:
subject = vendor_override.subject
body_html = vendor_override.body_html
body_text = vendor_override.body_text
source = "vendor_override"
elif platform_version:
subject = platform_version.subject
body_html = platform_version.body_html
body_text = platform_version.body_text
source = "platform"
else:
raise ResourceNotFoundException(f"No template found for language: {language}")
try:
rendered_subject = Template(subject).render(variables)
rendered_html = Template(body_html).render(variables)
rendered_text = Template(body_text).render(variables) if body_text else None
except Exception as e:
raise ValidationException(f"Template rendering error: {str(e)}")
return {
"source": source,
"language": language,
"subject": rendered_subject,
"body_html": rendered_html,
"body_text": rendered_text,
}
# =========================================================================
# HELPER METHODS
# =========================================================================
def _validate_template_syntax(
self,
subject: str,
body_html: str,
body_text: str | None,
) -> None:
"""Validate Jinja2 template syntax."""
try:
Template(subject).render({})
Template(body_html).render({})
if body_text:
Template(body_text).render({})
except Exception as e:
raise ValidationException(f"Invalid template syntax: {str(e)}")
def _parse_variables(self, variables_json: str | None) -> list[str]:
"""Parse variables JSON string to list."""
if not variables_json:
return []
try:
import json
return json.loads(variables_json)
except (json.JSONDecodeError, TypeError):
return []
def _parse_required_variables(self, required_vars: str | None) -> list[str]:
"""Parse required variables comma-separated string to list."""
if not required_vars:
return []
return [v.strip() for v in required_vars.split(",") if v.strip()]
def _get_template_languages(self, code: str) -> list[str]:
"""Get list of languages available for a template."""
templates = (
self.db.query(EmailTemplate.language)
.filter(EmailTemplate.code == code)
.all()
)
return [t.language for t in templates]

View File

@@ -14,7 +14,7 @@ from pathlib import Path
from fastapi import UploadFile
from sqlalchemy.orm import Session
from app.services.admin_settings_service import admin_settings_service
from app.modules.core.services.admin_settings_service import admin_settings_service
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,366 @@
{% extends "admin/base.html" %}
{% block title %}Email Templates{% endblock %}
{% block alpine_data %}emailTemplatesPage(){% endblock %}
{% block content %}
<div class="py-6">
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Email Templates
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage platform email templates. Vendors can override non-platform-only templates.
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
</div>
<div x-show="!loading" x-cloak>
<!-- Category Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<button
@click="selectedCategory = null"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === null,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== null
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
All Templates
</button>
<template x-for="cat in categories" :key="cat.code">
<button
@click="selectedCategory = cat.code"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === cat.code,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== cat.code
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
<span x-text="cat.name"></span>
</button>
</template>
</nav>
</div>
<!-- Templates List -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Template
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Category
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Languages
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in filteredTemplates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span x-html="$icon('mail', 'h-5 w-5 text-gray-400 mr-3')"></span>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="template.name"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full"
:class="getCategoryClass(template.category)"
x-text="template.category"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-1">
<template x-for="lang in template.languages" :key="lang">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded uppercase"
x-text="lang"></span>
</template>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span x-show="template.is_platform_only"
class="px-2 py-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded-full">
Platform Only
</span>
<span x-show="!template.is_platform_only"
class="px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
Overridable
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button @click="editTemplate(template)"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300 mr-3">
Edit
</button>
<button @click="previewTemplate(template)"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300">
Preview
</button>
</td>
</tr>
</template>
<tr x-show="filteredTemplates.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
No templates found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Edit Template Modal -->
<div x-show="showEditModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="closeEditModal()"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white" x-text="editingTemplate?.name || 'Edit Template'"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="editingTemplate?.code"></p>
</div>
<button @click="closeEditModal()" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
<!-- Language Tabs -->
<div class="mt-4 flex space-x-2">
<template x-for="lang in ['en', 'fr', 'de', 'lb']" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="{
'bg-purple-600 text-white': editLanguage === lang,
'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500': editLanguage !== lang
}"
class="px-3 py-1 text-sm font-medium rounded-md uppercase transition-colors">
<span x-text="lang"></span>
</button>
</template>
</div>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[60vh] overflow-y-auto">
<!-- Loading -->
<div x-show="loadingTemplate" class="flex justify-center py-8">
<span x-html="$icon('spinner', 'h-6 w-6 text-purple-600')"></span>
</div>
<div x-show="!loadingTemplate" class="space-y-4">
<!-- 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="editForm.subject"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }}
</p>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Body
</label>
<textarea x-model="editForm.body_html"
rows="12"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text Body
</label>
<textarea x-model="editForm.body_text"
rows="6"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Variables Reference -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables</h4>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editForm.variables || []" :key="variable">
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs font-mono"
x-text="'{{ ' + variable + ' }}'"></span>
</template>
<span x-show="!editForm.variables || editForm.variables.length === 0"
class="text-gray-500 dark:text-gray-400 text-sm">No variables defined</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<div>
<button @click="sendTestEmail()"
:disabled="sendingTest"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-html="$icon('mail', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test Email'"></span>
</button>
</div>
<div class="flex space-x-3">
<button @click="closeEditModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="saveTemplate()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div x-show="showPreviewModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showPreviewModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Email Preview</h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="previewData?.subject"></p>
</div>
<button @click="showPreviewModal = false" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
</div>
<!-- Body -->
<div class="p-6 max-h-[70vh] overflow-y-auto bg-gray-100 dark:bg-gray-900">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<iframe :srcdoc="previewData?.body_html"
class="w-full h-96 border-0"
sandbox="allow-same-origin"></iframe>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end">
<button @click="showPreviewModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Test Email Modal -->
<div x-show="showTestEmailModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showTestEmailModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-md sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Send Test Email</h3>
</div>
<!-- Body -->
<div class="px-6 py-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send to Email
</label>
<input type="email"
x-model="testEmailAddress"
placeholder="your@email.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end space-x-3">
<button @click="showTestEmailModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="confirmSendTestEmail()"
:disabled="!testEmailAddress || sendingTest"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test'"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,336 @@
{# app/templates/admin/messages.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import form_modal %}
{% block title %}Messages{% endblock %}
{% block alpine_data %}adminMessages({{ conversation_id or 'null' }}){% endblock %}
{% block content %}
{% call page_header_flex(title='Messages') %}
<button @click="showComposeModal = true"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
New Conversation
</button>
{% endcall %}
{{ 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="admin_vendor">Vendors</option>
<option value="admin_customer">Customers</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</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' ? 'Vendor' : '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>
<!-- Pagination -->
<div x-show="totalConversations > limit" class="p-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400" x-text="`${skip + 1}-${Math.min(skip + limit, totalConversations)} of ${totalConversations}`"></span>
<div class="flex gap-1">
<button @click="page--; loadConversations()" :disabled="page <= 1"
class="px-2 py-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50">
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<button @click="page++; loadConversations()" :disabled="page * limit >= totalConversations"
class="px-2 py-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50">
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</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</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>
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-500 dark:text-gray-400">
with <span class="font-medium" x-text="getOtherParticipantName()"></span>
</span>
<span x-show="selectedConversation.vendor_name"
class="text-xs text-gray-400">
(<span x-text="selectedConversation.vendor_name"></span>)
</span>
</div>
</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-if="loadingMessages">
<div class="flex items-center justify-center py-8">
<span x-html="$icon('spinner', 'w-6 h-6 animate-spin text-purple-500')"></span>
</div>
</template>
<template x-for="msg in selectedConversation.messages" :key="msg.id">
<div class="flex"
:class="msg.sender_type === 'admin' ? '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 === 'admin'
? '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 === 'admin' ? '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 === 'admin' ? '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... (Cmd/Ctrl+Enter to send)"
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 class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
<input type="file" multiple @change="handleFileSelect" class="hidden" x-ref="fileInput">
<span x-html="$icon('paper-clip', 'w-5 h-5')"></span>
<span>Attach files</span>
</label>
<template x-if="attachedFiles.length > 0">
<div class="flex items-center gap-2 text-sm text-gray-500">
<span x-text="attachedFiles.length + ' file(s)'"></span>
<button type="button" @click="attachedFiles = []" class="text-red-500 hover:text-red-700">
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
</div>
<button type="submit"
:disabled="!replyContent.trim() && attachedFiles.length === 0 || 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>
<!-- Closed message -->
<template x-if="selectedConversation.is_closed">
<div class="border-t border-gray-200 dark:border-gray-700 p-4 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">This conversation is closed. Reopen it to send messages.</p>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Compose Modal -->
{% call form_modal('composeModal', 'New Conversation', show_var='showComposeModal', submit_action='createConversation()', submit_text='Start Conversation', loading_var='creatingConversation', loading_text='Creating...') %}
<!-- Recipient Type -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send to</label>
<select
x-model="compose.recipientType"
@change="compose.recipientId = null; loadRecipients()"
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 type...</option>
<option value="vendor">Vendor</option>
<option value="customer">Customer</option>
</select>
</div>
<!-- Recipient -->
<div x-show="compose.recipientType">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient</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 recipient...</option>
<template x-for="r in recipients" :key="r.id">
<option :value="r.id" x-text="r.name + (r.vendor_name ? ' (' + r.vendor_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>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/messages.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,361 @@
{# app/templates/admin/notifications.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Notifications{% endblock %}
{% block alpine_data %}adminNotifications(){% endblock %}
{% block content %}
{{ page_header('Notifications & Alerts') }}
{{ loading_state('Loading notifications...') }}
{{ error_state('Error loading notifications') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Unread Notifications -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Unread
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.unread_count || 0">
0
</p>
</div>
</div>
<!-- Card: Active Alerts -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Alerts
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.active_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Critical -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Critical
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.critical_alerts || 0">
0
</p>
</div>
</div>
<!-- Card: Resolved Today -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Resolved Today
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="alertStats.resolved_today || 0">
0
</p>
</div>
</div>
</div>
<!-- Tabs -->
<div x-show="!loading" class="mb-6">
<div class="flex border-b border-gray-200 dark:border-gray-700">
<button
@click="activeTab = 'notifications'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'notifications'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Notifications
<span x-show="stats.unread_count > 0"
class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full dark:bg-red-600 dark:text-white"
x-text="stats.unread_count"></span>
</button>
<button
@click="activeTab = 'alerts'; loadAlerts()"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'alerts'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
Platform Alerts
<span x-show="alertStats.active_alerts > 0"
class="ml-2 px-2 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full dark:bg-orange-600 dark:text-white"
x-text="alertStats.active_alerts"></span>
</button>
</div>
</div>
<!-- Notifications Tab -->
<div x-show="!loading && activeTab === 'notifications'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center gap-4">
<select
x-model="filters.priority"
@change="page = 1; loadNotifications()"
class="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 Priorities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
<select
x-model="filters.is_read"
@change="page = 1; loadNotifications()"
class="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">Unread</option>
<option value="true">Read</option>
</select>
</div>
<button
x-show="stats.unread_count > 0"
@click="markAllAsRead()"
class="px-4 py-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400"
>
Mark all as read
</button>
</div>
<!-- Notifications List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<template x-if="loadingNotifications && notifications.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')"></span>
<p>Loading notifications...</p>
</div>
</template>
<template x-if="!loadingNotifications && notifications.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('bell', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No notifications</p>
<p class="text-sm mt-1">You're all caught up!</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="notif in notifications" :key="notif.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="notif.is_read ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Priority indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': notif.priority === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': notif.priority === 'high',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': notif.priority === 'normal',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300': notif.priority === 'low'
}">
<span x-html="getNotificationIcon(notif.type)"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="notif.title"></p>
<span class="text-xs text-gray-400" x-text="formatDate(notif.created_at)"></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="notif.message"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': notif.priority === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': notif.priority === 'high',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': notif.priority === 'normal',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': notif.priority === 'low'
}"
x-text="notif.priority"></span>
<span class="text-xs text-gray-500" x-text="notif.type.replace('_', ' ')"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="notif.action_url">
<a :href="notif.action_url"
class="px-3 py-1 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
View
</a>
</template>
<template x-if="!notif.is_read">
<button @click="markAsRead(notif)"
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Mark read
</button>
</template>
<button @click="deleteNotification(notif.id)"
class="p-1 text-gray-400 hover:text-red-500 transition-colors">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</div>
</li>
</template>
</ul>
<!-- Pagination -->
<div x-show="stats.total > limit" class="flex items-center justify-between px-4 py-3 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, stats.total)"></span> of <span x-text="stats.total"></span>
</span>
<div class="flex items-center gap-2">
<button
@click="page--; loadNotifications()"
:disabled="page <= 1"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<button
@click="page++; loadNotifications()"
:disabled="page * limit >= stats.total"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Alerts Tab -->
<div x-show="!loading && activeTab === 'alerts'" class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<select
x-model="alertFilters.severity"
@change="alertPage = 1; loadAlerts()"
class="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 Severities</option>
<option value="critical">Critical</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select
x-model="alertFilters.is_resolved"
@change="alertPage = 1; loadAlerts()"
class="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">Active</option>
<option value="true">Resolved</option>
</select>
</div>
<!-- Alerts List -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden">
<template x-if="loadingAlerts && alerts.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')"></span>
<p>Loading alerts...</p>
</div>
</template>
<template x-if="!loadingAlerts && alerts.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield-check', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium">No alerts</p>
<p class="text-sm mt-1">All systems are running smoothly</p>
</div>
</template>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="alert in alerts" :key="alert.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="alert.is_resolved ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Severity indicator -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="{
'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300': alert.severity === 'critical',
'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300': alert.severity === 'error',
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-300': alert.severity === 'warning',
'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300': alert.severity === 'info'
}">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5')"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="alert.title"></p>
<template x-if="alert.occurrence_count > 1">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300"
x-text="alert.occurrence_count + 'x'"></span>
</template>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="alert.description"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded uppercase"
:class="{
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': alert.severity === 'critical',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': alert.severity === 'error',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': alert.severity === 'warning',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': alert.severity === 'info'
}"
x-text="alert.severity"></span>
<span class="text-xs text-gray-500" x-text="alert.alert_type"></span>
<span class="text-xs text-gray-400" x-text="'Last: ' + formatDate(alert.last_occurred_at)"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="!alert.is_resolved">
<button @click="resolveAlert(alert)"
class="px-3 py-1 text-xs font-medium text-green-600 bg-green-100 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-300">
Resolve
</button>
</template>
<template x-if="alert.is_resolved">
<span class="px-3 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded dark:bg-gray-700">
Resolved
</span>
</template>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='admin/js/notifications.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,520 @@
{# app/modules/messaging/templates/messaging/storefront/messages.html #}
{% extends "storefront/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)">
<span class="w-4 h-4 mr-2" x-html="$icon('home', 'w-4 h-4')"></span>
My Account
</a>
</li>
<li>
<div class="flex items-center">
<span class="w-6 h-6 text-gray-400" x-html="$icon('chevron-right', 'w-6 h-6')"></span>
<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">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('spinner', 'h-8 w-8 animate-spin')"></span>
</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">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('chat-bubble-left', 'h-12 w-12 mx-auto')"></span>
<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 -->
{# noqa: FE-001 - Custom pagination with currentPage/totalPages vars (not pagination.page/pagination.total) #}
<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">
<span class="w-5 h-5" x-html="$icon('chevron-left', 'w-5 h-5')"></span>
</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">
<span class="w-4 h-4" x-html="$icon('paperclip', 'w-4 h-4')"></span>
<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">
<span class="w-6 h-6" x-html="$icon('paperclip', 'w-6 h-6')"></span>
<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">
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
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">
<span class="w-4 h-4" x-html="$icon('x-mark', 'w-4 h-4')"></span>
</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 token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
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}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
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 token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
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 token = localStorage.getItem('customer_token');
if (!token) return;
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
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 token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
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',
headers: {
'Authorization': `Bearer ${token}`
},
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 %}

View File

@@ -0,0 +1,330 @@
{# app/templates/vendor/email-templates.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_dialog %}
{% block title %}Email Templates{% endblock %}
{% block alpine_data %}vendorEmailTemplates(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Email Templates', subtitle='Customize email templates sent to your customers') %}
{% endcall %}
{{ loading_state('Loading email templates...') }}
{{ error_state('Error loading templates') }}
<!-- Main Content -->
<div x-show="!loading && !error" class="space-y-6">
<!-- Info Banner -->
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-3">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
Customize how emails appear to your customers. Platform templates are used by default,
and you can override them with your own versions. Some templates (billing, subscriptions)
are platform-only and cannot be customized.
</p>
</div>
</div>
</div>
<!-- Templates Table -->
{# noqa: FE-005 - Table has custom header section and styling not compatible with table_wrapper #}
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Available Templates</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Click a template to customize it</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Template</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Languages</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in templates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-4">
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="template.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></p>
</td>
<td class="px-4 py-4">
<span
:class="getCategoryClass(template.category)"
class="px-2 py-1 text-xs font-medium rounded-full"
x-text="template.category"
></span>
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<span
:class="template.override_languages.includes(lang)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
class="px-2 py-0.5 text-xs font-medium rounded uppercase"
x-text="lang"
></span>
</template>
</div>
</td>
<td class="px-4 py-4">
<template x-if="template.has_override">
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900/30 dark:text-green-400">
<span x-html="$icon('check-circle', 'w-3 h-3')"></span>
Customized
</span>
</template>
<template x-if="!template.has_override">
<span class="px-2 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400">
Platform Default
</span>
</template>
</td>
<td class="px-4 py-4 text-right">
<button
@click="editTemplate(template)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg dark:text-purple-400 dark:hover:bg-purple-900/20"
>
Customize
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="templates.length === 0">
<div class="p-8 text-center">
<span x-html="$icon('mail', 'w-12 h-12 mx-auto text-gray-400 dark:text-gray-500')"></span>
<p class="mt-2 text-gray-500 dark:text-gray-400">No customizable templates available</p>
</div>
</template>
</div>
</div>
<!-- Edit Template Modal -->
{% call modal_dialog(
show_var="showEditModal",
title_var="editingTemplate ? 'Customize: ' + editingTemplate.name : 'Edit Template'",
size="4xl"
) %}
<template x-if="editingTemplate">
<div class="space-y-6">
<!-- Language Tabs -->
<div class="border-b dark:border-gray-700">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="editLanguage === lang
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="px-4 py-2 text-sm font-medium border-b-2 uppercase"
x-text="lang"
></button>
</template>
</div>
</div>
<!-- Loading -->
<div x-show="loadingTemplate" class="flex items-center justify-center py-8">
<span x-html="$icon('loading', 'w-8 h-8 animate-spin text-purple-600')"></span>
</div>
<!-- Edit Form -->
<div x-show="!loadingTemplate" class="space-y-4">
<!-- Source Indicator -->
<div class="flex items-center gap-2 text-sm">
<template x-if="templateSource === 'vendor_override'">
<span class="text-green-600 dark:text-green-400">Using your customized version</span>
</template>
<template x-if="templateSource === 'platform'">
<span class="text-gray-500 dark:text-gray-400">Using platform default - edit to create your version</span>
</template>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject Line
</label>
<input
type="text"
x-model="editForm.subject"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Email subject..."
/>
</div>
<!-- Variables Info -->
<div x-show="editingTemplate.variables?.length > 0" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables:</p>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editingTemplate.variables" :key="variable">
<code class="px-2 py-0.5 text-xs bg-white dark:bg-gray-600 rounded border dark:border-gray-500" x-text="'{{ ' + variable + ' }}'"></code>
</template>
</div>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Content
</label>
<textarea
x-model="editForm.body_html"
rows="12"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="<html>...</html>"
></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text (Optional)
</label>
<textarea
x-model="editForm.body_text"
rows="4"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Plain text fallback..."
></textarea>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t dark:border-gray-700">
<div>
<!-- Revert to Default Button -->
<template x-if="templateSource === 'vendor_override'">
<button
@click="revertToDefault()"
:disabled="reverting"
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg dark:text-red-400 dark:hover:bg-red-900/20"
>
<span x-show="!reverting">Revert to Platform Default</span>
<span x-show="reverting">Reverting...</span>
</button>
</template>
</div>
<div class="flex items-center gap-3">
<button
@click="previewTemplate()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Preview
</button>
<button
@click="sendTestEmail()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Send Test
</button>
<button
@click="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="saveTemplate()"
:disabled="saving"
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="!saving">Save Override</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
</template>
{% endcall %}
<!-- Preview Modal -->
{% call modal_dialog(
show_var="showPreviewModal",
title="Email Preview",
size="4xl"
) %}
<template x-if="previewData">
<div class="space-y-4">
<div class="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<p class="text-sm"><strong>Subject:</strong> <span x-text="previewData.subject"></span></p>
</div>
<div class="border dark:border-gray-700 rounded-lg overflow-hidden">
<iframe
:srcdoc="previewData.body_html"
class="w-full h-96 bg-white"
sandbox="allow-same-origin"
></iframe>
</div>
<div class="flex justify-end">
<button
@click="showPreviewModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Close
</button>
</div>
</div>
</template>
{% endcall %}
<!-- Test Email Modal -->
{% call modal_dialog(
show_var="showTestEmailModal",
title="Send Test Email",
size="md"
) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send test email to:
</label>
<input
type="email"
x-model="testEmailAddress"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="your@email.com"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
A test email will be sent using sample data for template variables.
</p>
<div class="flex justify-end gap-3">
<button
@click="showTestEmailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="confirmSendTestEmail()"
:disabled="sendingTest || !testEmailAddress"
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="!sendingTest">Send Test</span>
<span x-show="sendingTest">Sending...</span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,279 @@
{# 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 %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Messages{% endblock %}
{% block alpine_data %}vendorMessages({{ conversation_id or 'null' }}){% endblock %}
{% block content %}
{{ page_header('Messages', action_label='New Conversation', action_icon='plus', action_onclick='showComposeModal = 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 -->
{% call modal_simple('composeMessageModal', 'New Conversation', show_var='showComposeModal', size='md') %}
<form @submit.prevent="createConversation()" class="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 border-t dark:border-gray-700">
<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>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/messages.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{# app/templates/vendor/notifications.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/pagination.html' import pagination_simple %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Notifications{% endblock %}
{% block alpine_data %}vendorNotifications(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Notifications', subtitle='Stay updated on your store activity') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loadingNotifications', onclick='loadNotifications()', variant='secondary') }}
<button
@click="openSettingsModal()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings
</button>
</div>
{% endcall %}
{{ loading_state('Loading notifications...') }}
{{ error_state('Error loading notifications') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Unread Notifications -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Unread</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.unread_count">0</p>
</div>
</div>
<!-- Total Notifications -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('inbox', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
</div>
</div>
<!-- Quick Actions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex-1">
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Quick Actions</p>
<button
x-show="stats.unread_count > 0"
@click="markAllAsRead()"
class="text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400"
>
Mark all as read
</button>
<span x-show="stats.unread_count === 0" class="text-sm text-gray-500 dark:text-gray-400">
All caught up!
</span>
</div>
</div>
</div>
<!-- Filters -->
<div x-show="!loading && !error" class="flex flex-wrap items-center gap-4 px-4 py-3 mb-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<select
x-model="filters.is_read"
@change="page = 1; loadNotifications()"
class="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 Notifications</option>
<option value="false">Unread Only</option>
<option value="true">Read Only</option>
</select>
</div>
<!-- Notifications List -->
<div x-show="!loading && !error" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Loading state for list -->
<template x-if="loadingNotifications && notifications.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 notifications...</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!loadingNotifications && notifications.length === 0">
<div class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('bell', 'w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600')"></span>
<p class="font-medium">No notifications</p>
<p class="text-sm mt-1">You're all caught up!</p>
</div>
</template>
<!-- Notifications list -->
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="notif in notifications" :key="notif.id">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="notif.is_read ? 'opacity-60' : ''">
<div class="flex items-start px-4 py-4">
<!-- Icon -->
<div class="flex-shrink-0 mr-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full"
:class="getPriorityClass(notif.priority)">
<span x-html="$icon(getNotificationIcon(notif.type), 'w-5 h-5')"></span>
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100" x-text="notif.title"></p>
<span class="text-xs text-gray-400" x-text="formatDate(notif.created_at)"></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="notif.message"></p>
<div class="flex items-center gap-4 mt-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded capitalize"
:class="getPriorityClass(notif.priority)"
x-text="notif.priority || 'normal'"></span>
<span class="text-xs text-gray-500" x-text="(notif.type || '').replace('_', ' ')"></span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<template x-if="notif.action_url">
<a :href="notif.action_url"
class="px-3 py-1 text-xs font-medium text-purple-600 bg-purple-100 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
View
</a>
</template>
<template x-if="!notif.is_read">
<button @click="markAsRead(notif)"
class="px-3 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Mark read
</button>
</template>
<button @click="deleteNotification(notif.id)"
class="p-1 text-gray-400 hover:text-red-500 transition-colors">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</div>
</li>
</template>
</ul>
<!-- Pagination -->
<div x-show="stats.total > limit" class="flex items-center justify-between px-4 py-3 border-t dark:border-gray-700">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="skip + 1"></span>-<span x-text="Math.min(skip + limit, stats.total)"></span> of <span x-text="stats.total"></span>
</span>
<div class="flex items-center gap-2">
<button
@click="prevPage()"
:disabled="page <= 1"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="`${page} / ${totalPages}`"></span>
<button
@click="nextPage()"
:disabled="page >= totalPages"
class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
<!-- Settings Modal -->
{% call modal_simple('notificationSettingsModal', 'Notification Settings', show_var='showSettingsModal', size='md') %}
<div class="space-y-4">
<!-- Email Notifications -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">Email Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Receive notifications via email</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="settingsForm.email_notifications" class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
</label>
</div>
<!-- In-App Notifications -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">In-App Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Show notifications in the dashboard</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="settingsForm.in_app_notifications" class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
</label>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
Note: Full notification settings management coming soon.
</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t dark:border-gray-700">
<button @click="showSettingsModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Cancel
</button>
<button
@click="saveSettings()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Save Settings
</button>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('messaging_static', path='vendor/js/notifications.js') }}"></script>
{% endblock %}