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:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
26
app/modules/messaging/routes/api/admin.py
Normal file
26
app/modules/messaging/routes/api/admin.py
Normal 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"])
|
||||
356
app/modules/messaging/routes/api/admin_email_templates.py
Normal file
356
app/modules/messaging/routes/api/admin_email_templates.py
Normal 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"})
|
||||
624
app/modules/messaging/routes/api/admin_messages.py
Normal file
624
app/modules/messaging/routes/api/admin_messages.py
Normal 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)
|
||||
327
app/modules/messaging/routes/api/admin_notifications.py
Normal file
327
app/modules/messaging/routes/api/admin_notifications.py
Normal 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)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
110
app/modules/messaging/routes/pages/admin.py
Normal file
110
app/modules/messaging/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
90
app/modules/messaging/routes/pages/storefront.py
Normal file
90
app/modules/messaging/routes/pages/storefront.py
Normal 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
|
||||
),
|
||||
)
|
||||
94
app/modules/messaging/routes/pages/vendor.py
Normal file
94
app/modules/messaging/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
1549
app/modules/messaging/services/email_service.py
Normal file
1549
app/modules/messaging/services/email_service.py
Normal file
File diff suppressed because it is too large
Load Diff
717
app/modules/messaging/services/email_template_service.py
Normal file
717
app/modules/messaging/services/email_template_service.py
Normal 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]
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
336
app/modules/messaging/templates/messaging/admin/messages.html
Normal file
336
app/modules/messaging/templates/messaging/admin/messages.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
330
app/modules/messaging/templates/messaging/vendor/email-templates.html
vendored
Normal file
330
app/modules/messaging/templates/messaging/vendor/email-templates.html
vendored
Normal 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 %}
|
||||
279
app/modules/messaging/templates/messaging/vendor/messages.html
vendored
Normal file
279
app/modules/messaging/templates/messaging/vendor/messages.html
vendored
Normal 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 %}
|
||||
230
app/modules/messaging/templates/messaging/vendor/notifications.html
vendored
Normal file
230
app/modules/messaging/templates/messaging/vendor/notifications.html
vendored
Normal 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 %}
|
||||
Reference in New Issue
Block a user