feat: add email settings with database overrides for admin and vendor

Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration

Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+

Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers

Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 22:23:47 +01:00
parent ad28a8a9a3
commit 36603178c3
51 changed files with 4959 additions and 1141 deletions

View File

@@ -6,14 +6,17 @@ Provides endpoints for:
- Viewing all platform settings
- Creating/updating settings
- Managing configuration by category
- Email configuration status and testing
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.config import settings as app_settings
from app.core.database import get_db
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
@@ -286,3 +289,416 @@ def delete_setting(
db.commit()
return {"message": message}
# ============================================================================
# EMAIL CONFIGURATION ENDPOINTS
# ============================================================================
# Email setting keys stored in admin_settings table
EMAIL_SETTING_KEYS = {
"email_provider": "smtp",
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_password": "",
"smtp_use_tls": "true",
"smtp_use_ssl": "false",
"sendgrid_api_key": "",
"mailgun_api_key": "",
"mailgun_domain": "",
"aws_access_key_id": "",
"aws_secret_access_key": "",
"aws_region": "eu-west-1",
"email_enabled": "true",
"email_debug": "false",
}
def get_email_setting(db: Session, key: str) -> str | None:
"""Get email setting from database, returns None if not set."""
setting = admin_settings_service.get_setting_by_key(db, key)
return setting.value if setting else None
def get_effective_email_config(db: Session) -> dict:
"""
Get effective email configuration.
Priority: Database settings > Environment variables
"""
config = {}
# Provider
db_provider = get_email_setting(db, "email_provider")
config["provider"] = db_provider if db_provider else app_settings.email_provider
# From settings
db_from_email = get_email_setting(db, "email_from_address")
config["from_email"] = db_from_email if db_from_email else app_settings.email_from_address
db_from_name = get_email_setting(db, "email_from_name")
config["from_name"] = db_from_name if db_from_name else app_settings.email_from_name
db_reply_to = get_email_setting(db, "email_reply_to")
config["reply_to"] = db_reply_to if db_reply_to else app_settings.email_reply_to
# SMTP settings
db_smtp_host = get_email_setting(db, "smtp_host")
config["smtp_host"] = db_smtp_host if db_smtp_host else app_settings.smtp_host
db_smtp_port = get_email_setting(db, "smtp_port")
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else app_settings.smtp_port
db_smtp_user = get_email_setting(db, "smtp_user")
config["smtp_user"] = db_smtp_user if db_smtp_user else app_settings.smtp_user
db_smtp_password = get_email_setting(db, "smtp_password")
config["smtp_password"] = db_smtp_password if db_smtp_password else app_settings.smtp_password
db_smtp_use_tls = get_email_setting(db, "smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else app_settings.smtp_use_tls
db_smtp_use_ssl = get_email_setting(db, "smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else app_settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_email_setting(db, "sendgrid_api_key")
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else app_settings.sendgrid_api_key
# Mailgun
db_mailgun_key = get_email_setting(db, "mailgun_api_key")
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else app_settings.mailgun_api_key
db_mailgun_domain = get_email_setting(db, "mailgun_domain")
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else app_settings.mailgun_domain
# AWS SES
db_aws_key = get_email_setting(db, "aws_access_key_id")
config["aws_access_key_id"] = db_aws_key if db_aws_key else app_settings.aws_access_key_id
db_aws_secret = get_email_setting(db, "aws_secret_access_key")
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else app_settings.aws_secret_access_key
db_aws_region = get_email_setting(db, "aws_region")
config["aws_region"] = db_aws_region if db_aws_region else app_settings.aws_region
# Behavior
db_enabled = get_email_setting(db, "email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else app_settings.email_enabled
db_debug = get_email_setting(db, "email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else app_settings.email_debug
# Track source for each field (DB override or .env)
config["_sources"] = {}
for key in ["provider", "from_email", "from_name", "smtp_host", "smtp_port"]:
db_key = "email_provider" if key == "provider" else ("email_from_address" if key == "from_email" else ("email_from_name" if key == "from_name" else key))
config["_sources"][key] = "database" if get_email_setting(db, db_key) else "env"
return config
class EmailStatusResponse(BaseModel):
"""Platform email configuration status."""
provider: str
from_email: str
from_name: str
reply_to: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
mailgun_domain: str | None = None
aws_region: str | None = None
debug: bool
enabled: bool
is_configured: bool
has_db_overrides: bool = False
class EmailSettingsUpdate(BaseModel):
"""Update email settings."""
provider: str | None = None
from_email: EmailStr | None = None
from_name: str | None = None
reply_to: EmailStr | None = None
# SMTP
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool | None = None
smtp_use_ssl: bool | None = None
# SendGrid
sendgrid_api_key: str | None = None
# Mailgun
mailgun_api_key: str | None = None
mailgun_domain: str | None = None
# AWS SES
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region: str | None = None
# Behavior
enabled: bool | None = None
debug: bool | None = None
class TestEmailRequest(BaseModel):
"""Request body for test email."""
to_email: EmailStr
class TestEmailResponse(BaseModel):
"""Response for test email."""
success: bool
message: str
@router.get("/email/status", response_model=EmailStatusResponse)
def get_email_status(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> EmailStatusResponse:
"""
Get platform email configuration status.
Returns the effective email configuration (DB overrides > .env).
Sensitive values (passwords, API keys) are NOT exposed.
"""
config = get_effective_email_config(db)
provider = config["provider"].lower()
# Determine if email is configured based on provider
is_configured = False
if provider == "smtp":
is_configured = bool(config["smtp_host"] and config["smtp_host"] != "localhost")
elif provider == "sendgrid":
is_configured = bool(config["sendgrid_api_key"])
elif provider == "mailgun":
is_configured = bool(config["mailgun_api_key"] and config["mailgun_domain"])
elif provider == "ses":
is_configured = bool(config["aws_access_key_id"] and config["aws_secret_access_key"])
# Check if any DB overrides exist
has_db_overrides = any(v == "database" for v in config["_sources"].values())
return EmailStatusResponse(
provider=provider,
from_email=config["from_email"],
from_name=config["from_name"],
reply_to=config["reply_to"] or None,
smtp_host=config["smtp_host"] if provider == "smtp" else None,
smtp_port=config["smtp_port"] if provider == "smtp" else None,
smtp_user=config["smtp_user"] if provider == "smtp" else None,
mailgun_domain=config["mailgun_domain"] if provider == "mailgun" else None,
aws_region=config["aws_region"] if provider == "ses" else None,
debug=config["debug"],
enabled=config["enabled"],
is_configured=is_configured,
has_db_overrides=has_db_overrides,
)
@router.put("/email/settings")
def update_email_settings(
settings_update: EmailSettingsUpdate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Update platform email settings.
Settings are stored in the database and override .env values.
Only non-null values are updated.
"""
from models.schema.admin import AdminSettingCreate
updated_keys = []
# Map request fields to database keys
field_mappings = {
"provider": ("email_provider", "string"),
"from_email": ("email_from_address", "string"),
"from_name": ("email_from_name", "string"),
"reply_to": ("email_reply_to", "string"),
"smtp_host": ("smtp_host", "string"),
"smtp_port": ("smtp_port", "integer"),
"smtp_user": ("smtp_user", "string"),
"smtp_password": ("smtp_password", "string"),
"smtp_use_tls": ("smtp_use_tls", "boolean"),
"smtp_use_ssl": ("smtp_use_ssl", "boolean"),
"sendgrid_api_key": ("sendgrid_api_key", "string"),
"mailgun_api_key": ("mailgun_api_key", "string"),
"mailgun_domain": ("mailgun_domain", "string"),
"aws_access_key_id": ("aws_access_key_id", "string"),
"aws_secret_access_key": ("aws_secret_access_key", "string"),
"aws_region": ("aws_region", "string"),
"enabled": ("email_enabled", "boolean"),
"debug": ("email_debug", "boolean"),
}
# Sensitive fields that should be marked as encrypted
sensitive_keys = {
"smtp_password", "sendgrid_api_key", "mailgun_api_key",
"aws_access_key_id", "aws_secret_access_key"
}
for field, (db_key, value_type) in field_mappings.items():
value = getattr(settings_update, field, None)
if value is not None:
# Convert value to string for storage
if value_type == "boolean":
str_value = "true" if value else "false"
elif value_type == "integer":
str_value = str(value)
else:
str_value = str(value)
# Create or update setting
setting_data = AdminSettingCreate(
key=db_key,
value=str_value,
value_type=value_type,
category="email",
description=f"Email setting: {field}",
is_encrypted=db_key in sensitive_keys,
is_public=False,
)
admin_settings_service.upsert_setting(db, setting_data, current_admin.id)
updated_keys.append(field)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_email_settings",
target_type="email_settings",
target_id="platform",
details={"updated_keys": updated_keys},
)
db.commit()
logger.info(f"Email settings updated by admin {current_admin.id}: {updated_keys}")
return {
"success": True,
"message": f"Updated {len(updated_keys)} email setting(s)",
"updated_keys": updated_keys,
}
@router.delete("/email/settings")
def reset_email_settings(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Reset email settings to use .env values.
Deletes all email settings from the database, reverting to .env configuration.
"""
deleted_count = 0
for key in EMAIL_SETTING_KEYS:
setting = admin_settings_service.get_setting_by_key(db, key)
if setting:
db.delete(setting)
deleted_count += 1
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="reset_email_settings",
target_type="email_settings",
target_id="platform",
details={"deleted_count": deleted_count},
)
db.commit()
logger.info(f"Email settings reset by admin {current_admin.id}, deleted {deleted_count} settings")
return {
"success": True,
"message": f"Reset {deleted_count} email setting(s) to .env defaults",
}
@router.post("/email/test", response_model=TestEmailResponse)
def send_test_email(
request: TestEmailRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> TestEmailResponse:
"""
Send a test email using the platform email configuration.
This tests the email provider configuration from environment variables.
"""
from app.services.email_service import EmailService
try:
email_service = EmailService(db)
# Send test email using platform configuration
success = email_service.send_raw(
to_email=request.to_email,
to_name=None,
subject="Wizamart Platform - Test Email",
body_html="""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Test Email from Wizamart</h2>
<p>This is a test email to verify your platform email configuration.</p>
<p>If you received this email, your email settings are working correctly!</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: {provider}<br>
From: {from_email}
</p>
</body>
</html>
""".format(
provider=app_settings.email_provider,
from_email=app_settings.email_from_address,
),
body_text=f"Test email from Wizamart platform.\n\nProvider: {app_settings.email_provider}\nFrom: {app_settings.email_from_address}",
is_platform_email=True,
)
if success:
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="send_test_email",
target_type="email",
target_id=request.to_email,
details={"provider": app_settings.email_provider},
)
db.commit()
return TestEmailResponse(
success=True,
message=f"Test email sent to {request.to_email}",
)
else:
return TestEmailResponse(
success=False,
message="Failed to send test email. Check server logs for details.",
)
except Exception as e:
logger.error(f"Failed to send test email: {e}")
return TestEmailResponse(
success=False,
message=f"Error sending test email: {str(e)}",
)

View File

@@ -20,6 +20,7 @@ from . import (
content_pages,
customers,
dashboard,
email_settings,
email_templates,
features,
info,
@@ -61,6 +62,7 @@ router.include_router(dashboard.router, tags=["vendor-dashboard"])
router.include_router(profile.router, tags=["vendor-profile"])
router.include_router(settings.router, tags=["vendor-settings"])
router.include_router(email_templates.router, tags=["vendor-email-templates"])
router.include_router(email_settings.router, tags=["vendor-email-settings"])
router.include_router(onboarding.router, tags=["vendor-onboarding"])
# Business operations (with prefixes: /products/*, /orders/*, etc.)

View File

@@ -2,6 +2,9 @@
"""
Vendor Content Pages API
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
@@ -15,11 +18,10 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service
from models.database.user import User
router = APIRouter()
router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__)
@@ -111,11 +113,8 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -132,11 +131,8 @@ def list_vendor_overrides(
Shows what the vendor has customized.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -154,13 +150,10 @@ def get_page(
Returns vendor override if exists, otherwise platform default.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.vendor_id,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
@@ -178,15 +171,12 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.vendor_id,
vendor_id=current_user.token_vendor_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
@@ -214,14 +204,11 @@ def update_vendor_page(
Can only update pages owned by this vendor.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.vendor_id,
vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
@@ -251,9 +238,6 @@ def delete_vendor_page(
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()

225
app/api/v1/vendor/email_settings.py vendored Normal file
View File

@@ -0,0 +1,225 @@
# app/api/v1/vendor/email_settings.py
"""
Vendor email settings API endpoints.
Allows vendors to configure their email sending settings:
- SMTP configuration (all tiers)
- Advanced providers: SendGrid, Mailgun, SES (Business+ tier)
- Sender identity (from_email, from_name, reply_to)
- Signature/footer customization
- Configuration verification via test email
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr, Field
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 NotFoundError, ValidationError, AuthorizationError
from app.services.vendor_email_settings_service import VendorEmailSettingsService
from app.services.subscription_service import subscription_service
from models.database.user import User
router = APIRouter(prefix="/email-settings")
logger = logging.getLogger(__name__)
# =============================================================================
# SCHEMAS
# =============================================================================
class EmailSettingsUpdate(BaseModel):
"""Schema for creating/updating email settings."""
# Sender Identity (Required)
from_email: EmailStr = Field(..., description="Sender email address")
from_name: str = Field(..., min_length=1, max_length=100, description="Sender name")
reply_to_email: EmailStr | None = Field(None, description="Reply-to email address")
# Signature (Optional)
signature_text: str | None = Field(None, description="Plain text signature")
signature_html: str | None = Field(None, description="HTML signature/footer")
# Provider
provider: str = Field("smtp", description="Email provider: smtp, sendgrid, mailgun, ses")
# SMTP Settings
smtp_host: str | None = Field(None, description="SMTP server hostname")
smtp_port: int | None = Field(587, ge=1, le=65535, description="SMTP server port")
smtp_username: str | None = Field(None, description="SMTP username")
smtp_password: str | None = Field(None, description="SMTP password")
smtp_use_tls: bool = Field(True, description="Use STARTTLS")
smtp_use_ssl: bool = Field(False, description="Use SSL/TLS (port 465)")
# SendGrid
sendgrid_api_key: str | None = Field(None, description="SendGrid API key")
# Mailgun
mailgun_api_key: str | None = Field(None, description="Mailgun API key")
mailgun_domain: str | None = Field(None, description="Mailgun sending domain")
# SES
ses_access_key_id: str | None = Field(None, description="AWS access key ID")
ses_secret_access_key: str | None = Field(None, description="AWS secret access key")
ses_region: str | None = Field("eu-west-1", description="AWS region")
class VerifyEmailRequest(BaseModel):
"""Schema for verifying email settings."""
test_email: EmailStr = Field(..., description="Email address to send test email to")
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
def get_email_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current email settings for the vendor.
Returns settings with sensitive fields masked.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
settings = service.get_settings(vendor_id)
if not settings:
return {
"configured": False,
"settings": None,
"message": "Email settings not configured. Configure SMTP to send emails to customers.",
}
return {
"configured": settings.is_configured,
"verified": settings.is_verified,
"settings": settings.to_dict(),
}
@router.get("/status")
def get_email_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get email configuration status.
Used by frontend to show warning banner if not configured.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
return service.get_status(vendor_id)
@router.get("/providers")
def get_available_providers(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get available email providers for current tier.
Returns list of providers with availability status.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Get vendor's current tier
tier = subscription_service.get_current_tier(db, vendor_id)
return {
"providers": service.get_available_providers(tier),
"current_tier": tier.value if tier else None,
}
@router.put("")
def update_email_settings(
data: EmailSettingsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create or update email settings.
Premium providers (SendGrid, Mailgun, SES) require Business+ tier.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Get vendor's current tier for validation
tier = subscription_service.get_current_tier(db, vendor_id)
try:
settings = service.create_or_update(
vendor_id=vendor_id,
data=data.model_dump(exclude_unset=True),
current_tier=tier,
)
return {
"success": True,
"message": "Email settings updated successfully",
"settings": settings.to_dict(),
}
except AuthorizationError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/verify")
def verify_email_settings(
data: VerifyEmailRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Verify email settings by sending a test email.
Sends a test email to the provided address and updates verification status.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
try:
result = service.verify_settings(vendor_id, data.test_email)
if result["success"]:
return result
else:
raise HTTPException(status_code=400, detail=result["message"])
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("")
def delete_email_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete email settings.
Warning: This will disable email sending for the vendor.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
if service.delete(vendor_id):
return {"success": True, "message": "Email settings deleted"}
else:
raise HTTPException(status_code=404, detail="Email settings not found")

View File

@@ -52,6 +52,20 @@ PLATFORM_SUPPORT_EMAIL = "support@wizamart.com"
PLATFORM_DEFAULT_LANGUAGE = "en"
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
# Tiers that get white-label (no "Powered by Wizamart" footer)
WHITELABEL_TIERS = {"business", "enterprise"}
# Powered by Wizamart footer (added for Essential/Professional tiers)
POWERED_BY_FOOTER_HTML = """
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Powered by <a href="https://wizamart.com" style="color: #6b46c1; text-decoration: none;">Wizamart</a>
</p>
</div>
"""
POWERED_BY_FOOTER_TEXT = "\n\n---\nPowered by Wizamart - https://wizamart.com"
@dataclass
class ResolvedTemplate:
@@ -340,7 +354,582 @@ class DebugProvider(EmailProvider):
# =============================================================================
# EMAIL SERVICE
# PLATFORM CONFIG HELPERS (DB overrides .env)
# =============================================================================
def get_platform_email_config(db: Session) -> dict:
"""
Get effective platform email configuration.
Priority: Database settings > Environment variables (.env)
Returns:
Dictionary with all email configuration values
"""
from models.database.admin import AdminSetting
def get_db_setting(key: str) -> str | None:
setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
return setting.value if setting else None
config = {}
# Provider
db_provider = get_db_setting("email_provider")
config["provider"] = db_provider if db_provider else settings.email_provider
# From settings
db_from_email = get_db_setting("email_from_address")
config["from_email"] = db_from_email if db_from_email else settings.email_from_address
db_from_name = get_db_setting("email_from_name")
config["from_name"] = db_from_name if db_from_name else settings.email_from_name
db_reply_to = get_db_setting("email_reply_to")
config["reply_to"] = db_reply_to if db_reply_to else settings.email_reply_to
# SMTP settings
db_smtp_host = get_db_setting("smtp_host")
config["smtp_host"] = db_smtp_host if db_smtp_host else settings.smtp_host
db_smtp_port = get_db_setting("smtp_port")
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else settings.smtp_port
db_smtp_user = get_db_setting("smtp_user")
config["smtp_user"] = db_smtp_user if db_smtp_user else settings.smtp_user
db_smtp_password = get_db_setting("smtp_password")
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
db_smtp_use_tls = get_db_setting("smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_db_setting("sendgrid_api_key")
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else settings.sendgrid_api_key
# Mailgun
db_mailgun_key = get_db_setting("mailgun_api_key")
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else settings.mailgun_api_key
db_mailgun_domain = get_db_setting("mailgun_domain")
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else settings.mailgun_domain
# AWS SES
db_aws_key = get_db_setting("aws_access_key_id")
config["aws_access_key_id"] = db_aws_key if db_aws_key else settings.aws_access_key_id
db_aws_secret = get_db_setting("aws_secret_access_key")
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else settings.aws_secret_access_key
db_aws_region = get_db_setting("aws_region")
config["aws_region"] = db_aws_region if db_aws_region else settings.aws_region
# Behavior
db_enabled = get_db_setting("email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
db_debug = get_db_setting("email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
return config
# =============================================================================
# CONFIGURABLE PLATFORM PROVIDERS (use config dict instead of global settings)
# =============================================================================
class ConfigurableSMTPProvider(EmailProvider):
"""SMTP provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
if reply_to:
msg["Reply-To"] = reply_to
if body_text:
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
if self.config.get("smtp_use_ssl"):
server = smtplib.SMTP_SSL(self.config["smtp_host"], self.config["smtp_port"])
else:
server = smtplib.SMTP(self.config["smtp_host"], self.config["smtp_port"])
try:
if self.config.get("smtp_use_tls") and not self.config.get("smtp_use_ssl"):
server.starttls()
if self.config.get("smtp_user") and self.config.get("smtp_password"):
server.login(self.config["smtp_user"], self.config["smtp_password"])
server.sendmail(from_email, [to_email], msg.as_string())
return True, None, None
finally:
server.quit()
except Exception as e:
logger.error(f"Configurable SMTP send error: {e}")
return False, None, str(e)
class ConfigurableSendGridProvider(EmailProvider):
"""SendGrid provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content
message = Mail(
from_email=Email(from_email, from_name),
to_emails=To(to_email, to_name),
subject=subject,
)
message.add_content(Content("text/html", body_html))
if body_text:
message.add_content(Content("text/plain", body_text))
if reply_to:
message.reply_to = Email(reply_to)
sg = SendGridAPIClient(self.config["sendgrid_api_key"])
response = sg.send(message)
if response.status_code in (200, 201, 202):
message_id = response.headers.get("X-Message-Id")
return True, message_id, None
else:
return False, None, f"SendGrid error: {response.status_code}"
except ImportError:
return False, None, "SendGrid library not installed"
except Exception as e:
logger.error(f"Configurable SendGrid send error: {e}")
return False, None, str(e)
class ConfigurableMailgunProvider(EmailProvider):
"""Mailgun provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import requests
from_str = f"{from_name} <{from_email}>" if from_name else from_email
to_str = f"{to_name} <{to_email}>" if to_name else to_email
data = {
"from": from_str,
"to": to_str,
"subject": subject,
"html": body_html,
}
if body_text:
data["text"] = body_text
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{self.config['mailgun_domain']}/messages",
auth=("api", self.config["mailgun_api_key"]),
data=data,
timeout=30,
)
if response.status_code == 200:
result = response.json()
return True, result.get("id"), None
else:
return False, None, f"Mailgun error: {response.status_code} - {response.text}"
except Exception as e:
logger.error(f"Configurable Mailgun send error: {e}")
return False, None, str(e)
class ConfigurableSESProvider(EmailProvider):
"""Amazon SES provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import boto3
ses = boto3.client(
"ses",
region_name=self.config["aws_region"],
aws_access_key_id=self.config["aws_access_key_id"],
aws_secret_access_key=self.config["aws_secret_access_key"],
)
from_str = f"{from_name} <{from_email}>" if from_name else from_email
body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
if body_text:
body["Text"] = {"Charset": "UTF-8", "Data": body_text}
kwargs = {
"Source": from_str,
"Destination": {"ToAddresses": [to_email]},
"Message": {
"Subject": {"Charset": "UTF-8", "Data": subject},
"Body": body,
},
}
if reply_to:
kwargs["ReplyToAddresses"] = [reply_to]
response = ses.send_email(**kwargs)
return True, response.get("MessageId"), None
except ImportError:
return False, None, "boto3 library not installed"
except Exception as e:
logger.error(f"Configurable SES send error: {e}")
return False, None, str(e)
def get_platform_provider(db: Session) -> EmailProvider:
"""
Get the configured email provider using effective platform config.
Uses database settings if available, otherwise falls back to .env.
"""
config = get_platform_email_config(db)
if config.get("debug"):
return DebugProvider()
provider_map = {
"smtp": ConfigurableSMTPProvider,
"sendgrid": ConfigurableSendGridProvider,
"mailgun": ConfigurableMailgunProvider,
"ses": ConfigurableSESProvider,
}
provider_name = config.get("provider", "smtp").lower()
provider_class = provider_map.get(provider_name)
if not provider_class:
logger.warning(f"Unknown email provider: {provider_name}, using SMTP")
return ConfigurableSMTPProvider(config)
return provider_class(config)
# =============================================================================
# VENDOR EMAIL PROVIDERS
# =============================================================================
class VendorSMTPProvider(EmailProvider):
"""SMTP provider using vendor-specific settings."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
if reply_to:
msg["Reply-To"] = reply_to
if body_text:
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
# Use vendor's SMTP settings
if self.settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(self.settings.smtp_host, self.settings.smtp_port)
else:
server = smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port)
try:
if self.settings.smtp_use_tls and not self.settings.smtp_use_ssl:
server.starttls()
if self.settings.smtp_username and self.settings.smtp_password:
server.login(self.settings.smtp_username, self.settings.smtp_password)
server.sendmail(from_email, [to_email], msg.as_string())
return True, None, None
finally:
server.quit()
except Exception as e:
logger.error(f"Vendor SMTP send error: {e}")
return False, None, str(e)
class VendorSendGridProvider(EmailProvider):
"""SendGrid provider using vendor-specific API key."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content
message = Mail(
from_email=Email(from_email, from_name),
to_emails=To(to_email, to_name),
subject=subject,
)
message.add_content(Content("text/html", body_html))
if body_text:
message.add_content(Content("text/plain", body_text))
if reply_to:
message.reply_to = Email(reply_to)
sg = SendGridAPIClient(self.settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code in (200, 201, 202):
message_id = response.headers.get("X-Message-Id")
return True, message_id, None
else:
return False, None, f"SendGrid error: {response.status_code}"
except ImportError:
return False, None, "SendGrid library not installed"
except Exception as e:
logger.error(f"Vendor SendGrid send error: {e}")
return False, None, str(e)
class VendorMailgunProvider(EmailProvider):
"""Mailgun provider using vendor-specific settings."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import requests
from_str = f"{from_name} <{from_email}>" if from_name else from_email
to_str = f"{to_name} <{to_email}>" if to_name else to_email
data = {
"from": from_str,
"to": to_str,
"subject": subject,
"html": body_html,
}
if body_text:
data["text"] = body_text
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{self.settings.mailgun_domain}/messages",
auth=("api", self.settings.mailgun_api_key),
data=data,
timeout=30,
)
if response.status_code == 200:
result = response.json()
return True, result.get("id"), None
else:
return False, None, f"Mailgun error: {response.status_code} - {response.text}"
except Exception as e:
logger.error(f"Vendor Mailgun send error: {e}")
return False, None, str(e)
class VendorSESProvider(EmailProvider):
"""Amazon SES provider using vendor-specific credentials."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import boto3
ses = boto3.client(
"ses",
region_name=self.settings.ses_region,
aws_access_key_id=self.settings.ses_access_key_id,
aws_secret_access_key=self.settings.ses_secret_access_key,
)
from_str = f"{from_name} <{from_email}>" if from_name else from_email
body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
if body_text:
body["Text"] = {"Charset": "UTF-8", "Data": body_text}
kwargs = {
"Source": from_str,
"Destination": {"ToAddresses": [to_email]},
"Message": {
"Subject": {"Charset": "UTF-8", "Data": subject},
"Body": body,
},
}
if reply_to:
kwargs["ReplyToAddresses"] = [reply_to]
response = ses.send_email(**kwargs)
return True, response.get("MessageId"), None
except ImportError:
return False, None, "boto3 library not installed"
except Exception as e:
logger.error(f"Vendor SES send error: {e}")
return False, None, str(e)
def get_vendor_provider(vendor_settings) -> EmailProvider | None:
"""
Create an email provider instance using vendor's settings.
Args:
vendor_settings: VendorEmailSettings model instance
Returns:
EmailProvider instance or None if not configured
"""
if not vendor_settings or not vendor_settings.is_configured:
return None
provider_map = {
"smtp": VendorSMTPProvider,
"sendgrid": VendorSendGridProvider,
"mailgun": VendorMailgunProvider,
"ses": VendorSESProvider,
}
provider_class = provider_map.get(vendor_settings.provider)
if not provider_class:
logger.warning(f"Unknown vendor email provider: {vendor_settings.provider}")
return None
return provider_class(vendor_settings)
# =============================================================================
# PLATFORM EMAIL PROVIDER
# =============================================================================
@@ -387,15 +976,24 @@ class EmailService:
subject="Hello",
body_html="<h1>Hello</h1>",
)
Platform email configuration is loaded from:
1. Database (admin_settings table) - if settings exist
2. Environment variables (.env) - fallback
"""
def __init__(self, db: Session):
self.db = db
self.provider = get_provider()
# Use configurable provider that checks DB first, then .env
self.provider = get_platform_provider(db)
# Cache the platform config for use in send_raw
self._platform_config = get_platform_email_config(db)
self.jinja_env = Environment(loader=BaseLoader())
# Cache vendor and feature data to avoid repeated queries
self._vendor_cache: dict[int, Any] = {}
self._feature_cache: dict[int, set[str]] = {}
self._vendor_email_settings_cache: dict[int, Any] = {}
self._vendor_tier_cache: dict[int, str | None] = {}
def _get_vendor(self, vendor_id: int):
"""Get vendor with caching."""
@@ -419,6 +1017,76 @@ class EmailService:
return feature_code in self._feature_cache[vendor_id]
def _get_vendor_email_settings(self, vendor_id: int):
"""Get vendor email settings with caching."""
if vendor_id not in self._vendor_email_settings_cache:
from models.database.vendor_email_settings import VendorEmailSettings
self._vendor_email_settings_cache[vendor_id] = (
self.db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_id)
.first()
)
return self._vendor_email_settings_cache[vendor_id]
def _get_vendor_tier(self, vendor_id: int) -> str | None:
"""Get vendor's subscription tier with caching."""
if vendor_id not in self._vendor_tier_cache:
from app.services.subscription_service import subscription_service
tier = subscription_service.get_current_tier(self.db, vendor_id)
self._vendor_tier_cache[vendor_id] = tier.value if tier else None
return self._vendor_tier_cache[vendor_id]
def _should_add_powered_by_footer(self, vendor_id: int | None) -> bool:
"""
Check if "Powered by Wizamart" footer should be added.
Footer is added for Essential and Professional tiers.
Business and Enterprise tiers get white-label (no footer).
"""
if not vendor_id:
return False # Platform emails don't get the footer
tier = self._get_vendor_tier(vendor_id)
if not tier:
return True # No tier = show footer (shouldn't happen normally)
return tier.lower() not in WHITELABEL_TIERS
def _inject_powered_by_footer(
self,
body_html: str,
body_text: str | None,
vendor_id: int | None,
) -> tuple[str, str | None]:
"""
Inject "Powered by Wizamart" footer if needed based on tier.
Returns:
Tuple of (modified_html, modified_text)
"""
if not self._should_add_powered_by_footer(vendor_id):
return body_html, body_text
# Inject footer before closing </body> tag if present, otherwise append
if "</body>" in body_html.lower():
# Find </body> case-insensitively and inject before it
import re
body_html = re.sub(
r"(</body>)",
f"{POWERED_BY_FOOTER_HTML}\\1",
body_html,
flags=re.IGNORECASE,
)
else:
body_html += POWERED_BY_FOOTER_HTML
if body_text:
body_text += POWERED_BY_FOOTER_TEXT
return body_html, body_text
def resolve_language(
self,
explicit_language: str | None = None,
@@ -721,16 +1389,55 @@ class EmailService:
related_type: str | None = None,
related_id: int | None = None,
extra_data: str | None = None,
is_platform_email: bool = False,
) -> EmailLog:
"""
Send a raw email without using a template.
For vendor emails (when vendor_id is provided and is_platform_email=False):
- Uses vendor's SMTP/provider settings if configured
- Uses vendor's from_email, from_name, reply_to
- Adds "Powered by Wizamart" footer for Essential/Professional tiers
For platform emails (is_platform_email=True or no vendor_id):
- Uses platform's email settings from config
- No "Powered by Wizamart" footer
Args:
is_platform_email: If True, always use platform settings (for billing, etc.)
Returns:
EmailLog record
"""
from_email = from_email or settings.email_from_address
from_name = from_name or settings.email_from_name
reply_to = reply_to or settings.email_reply_to or None
# Determine which provider and settings to use
vendor_settings = None
vendor_provider = None
provider_name = self._platform_config.get("provider", settings.email_provider)
if vendor_id and not is_platform_email:
vendor_settings = self._get_vendor_email_settings(vendor_id)
if vendor_settings and vendor_settings.is_configured:
vendor_provider = get_vendor_provider(vendor_settings)
if vendor_provider:
# Use vendor's email identity
from_email = from_email or vendor_settings.from_email
from_name = from_name or vendor_settings.from_name
reply_to = reply_to or vendor_settings.reply_to_email
provider_name = f"vendor_{vendor_settings.provider}"
logger.debug(f"Using vendor email provider: {vendor_settings.provider}")
# Fall back to platform settings if no vendor provider
# Uses DB config if available, otherwise .env
if not vendor_provider:
from_email = from_email or self._platform_config.get("from_email", settings.email_from_address)
from_name = from_name or self._platform_config.get("from_name", settings.email_from_name)
reply_to = reply_to or self._platform_config.get("reply_to") or settings.email_reply_to or None
# Inject "Powered by Wizamart" footer for non-whitelabel tiers
if vendor_id and not is_platform_email:
body_html, body_text = self._inject_powered_by_footer(
body_html, body_text, vendor_id
)
# Create log entry
log = EmailLog(
@@ -745,7 +1452,7 @@ class EmailService:
from_name=from_name,
reply_to=reply_to,
status=EmailStatus.PENDING.value,
provider=settings.email_provider,
provider=provider_name,
vendor_id=vendor_id,
user_id=user_id,
related_type=related_type,
@@ -755,16 +1462,20 @@ class EmailService:
self.db.add(log)
self.db.flush()
# Check if emails are disabled
if not settings.email_enabled:
# Check if emails are disabled (uses DB config if available)
email_enabled = self._platform_config.get("enabled", settings.email_enabled)
if not email_enabled:
log.status = EmailStatus.FAILED.value
log.error_message = "Email sending is disabled"
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
logger.info(f"Email sending disabled, skipping: {to_email}")
return log
# Use vendor provider if available, otherwise platform provider
provider_to_use = vendor_provider or self.provider
# Send email
success, message_id, error = self.provider.send(
success, message_id, error = provider_to_use.send(
to_email=to_email,
to_name=to_name,
subject=subject,
@@ -777,7 +1488,7 @@ class EmailService:
if success:
log.mark_sent(message_id)
logger.info(f"Email sent to {to_email}: {subject}")
logger.info(f"Email sent to {to_email}: {subject} (via {provider_name})")
else:
log.mark_failed(error or "Unknown error")
logger.error(f"Email failed to {to_email}: {error}")

View File

@@ -0,0 +1,444 @@
# app/services/vendor_email_settings_service.py
"""
Vendor Email Settings Service.
Handles CRUD operations for vendor email configuration:
- SMTP settings
- Advanced providers (SendGrid, Mailgun, SES) - tier-gated
- Sender identity (from_email, from_name, reply_to)
- Signature/footer customization
- Configuration verification via test email
"""
import logging
import smtplib
from datetime import UTC, datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from sqlalchemy.orm import Session
from app.exceptions import NotFoundError, ValidationError, AuthorizationError
from models.database import (
Vendor,
VendorEmailSettings,
EmailProvider,
PREMIUM_EMAIL_PROVIDERS,
VendorSubscription,
TierCode,
)
logger = logging.getLogger(__name__)
# Tiers that allow premium email providers
PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
class VendorEmailSettingsService:
"""Service for managing vendor email settings."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# READ OPERATIONS
# =========================================================================
def get_settings(self, vendor_id: int) -> VendorEmailSettings | None:
"""Get email settings for a vendor."""
return (
self.db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_id)
.first()
)
def get_settings_or_404(self, vendor_id: int) -> VendorEmailSettings:
"""Get email settings or raise 404."""
settings = self.get_settings(vendor_id)
if not settings:
raise NotFoundError(
f"Email settings not found for vendor {vendor_id}. "
"Configure email settings to send emails."
)
return settings
def is_configured(self, vendor_id: int) -> bool:
"""Check if vendor has configured email settings."""
settings = self.get_settings(vendor_id)
return settings is not None and settings.is_configured
def get_status(self, vendor_id: int) -> dict:
"""
Get email configuration status for a vendor.
Returns:
dict with is_configured, is_verified, provider, etc.
"""
settings = self.get_settings(vendor_id)
if not settings:
return {
"is_configured": False,
"is_verified": False,
"provider": None,
"from_email": None,
"from_name": None,
"message": "Email settings not configured. Configure SMTP to send emails.",
}
return {
"is_configured": settings.is_configured,
"is_verified": settings.is_verified,
"provider": settings.provider,
"from_email": settings.from_email,
"from_name": settings.from_name,
"last_verified_at": settings.last_verified_at.isoformat() if settings.last_verified_at else None,
"verification_error": settings.verification_error,
"message": self._get_status_message(settings),
}
def _get_status_message(self, settings: VendorEmailSettings) -> str:
"""Generate a human-readable status message."""
if not settings.is_configured:
return "Complete your email configuration to send emails."
if not settings.is_verified:
return "Email configured but not verified. Send a test email to verify."
return "Email settings configured and verified."
# =========================================================================
# WRITE OPERATIONS
# =========================================================================
def create_or_update(
self,
vendor_id: int,
data: dict,
current_tier: TierCode | None = None,
) -> VendorEmailSettings:
"""
Create or update vendor email settings.
Args:
vendor_id: Vendor ID
data: Settings data (from_email, from_name, smtp_*, etc.)
current_tier: Vendor's current subscription tier (for premium provider validation)
Returns:
Updated VendorEmailSettings
"""
# Validate premium provider access
provider = data.get("provider", "smtp")
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
if current_tier not in PREMIUM_TIERS:
raise AuthorizationError(
f"Provider '{provider}' requires Business or Enterprise tier. "
"Upgrade your plan to use advanced email providers."
)
settings = self.get_settings(vendor_id)
if not settings:
settings = VendorEmailSettings(vendor_id=vendor_id)
self.db.add(settings)
# Update fields
for field in [
"from_email",
"from_name",
"reply_to_email",
"signature_text",
"signature_html",
"provider",
# SMTP
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_use_tls",
"smtp_use_ssl",
# SendGrid
"sendgrid_api_key",
# Mailgun
"mailgun_api_key",
"mailgun_domain",
# SES
"ses_access_key_id",
"ses_secret_access_key",
"ses_region",
]:
if field in data and data[field] is not None:
# Don't overwrite passwords/keys with empty strings
if field.endswith(("_password", "_key", "_access_key")) and data[field] == "":
continue
setattr(settings, field, data[field])
# Update configuration status
settings.update_configuration_status()
# Reset verification if provider/credentials changed
if any(
f in data
for f in ["provider", "smtp_host", "smtp_password", "sendgrid_api_key", "mailgun_api_key", "ses_access_key_id"]
):
settings.is_verified = False
settings.verification_error = None
self.db.commit()
self.db.refresh(settings)
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
return settings
def delete(self, vendor_id: int) -> bool:
"""Delete email settings for a vendor."""
settings = self.get_settings(vendor_id)
if settings:
self.db.delete(settings)
self.db.commit()
logger.info(f"Deleted email settings for vendor {vendor_id}")
return True
return False
# =========================================================================
# VERIFICATION
# =========================================================================
def verify_settings(self, vendor_id: int, test_email: str) -> dict:
"""
Verify email settings by sending a test email.
Args:
vendor_id: Vendor ID
test_email: Email address to send test email to
Returns:
dict with success status and message
"""
settings = self.get_settings_or_404(vendor_id)
if not settings.is_fully_configured():
raise ValidationError("Email settings incomplete. Configure all required fields first.")
try:
# Send test email based on provider
if settings.provider == EmailProvider.SMTP.value:
self._send_smtp_test(settings, test_email)
elif settings.provider == EmailProvider.SENDGRID.value:
self._send_sendgrid_test(settings, test_email)
elif settings.provider == EmailProvider.MAILGUN.value:
self._send_mailgun_test(settings, test_email)
elif settings.provider == EmailProvider.SES.value:
self._send_ses_test(settings, test_email)
else:
raise ValidationError(f"Unknown provider: {settings.provider}")
# Mark as verified
settings.mark_verified()
self.db.commit()
logger.info(f"Email settings verified for vendor {vendor_id}")
return {
"success": True,
"message": f"Test email sent successfully to {test_email}",
}
except Exception as e:
error_msg = str(e)
settings.mark_verification_failed(error_msg)
self.db.commit()
logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}")
return {
"success": False,
"message": f"Failed to send test email: {error_msg}",
}
def _send_smtp_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SMTP."""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Wizamart Email Configuration Test"
msg["From"] = f"{settings.from_name} <{settings.from_email}>"
msg["To"] = to_email
text_content = (
"This is a test email from Wizamart.\n\n"
"Your email settings are configured correctly!\n\n"
f"Provider: SMTP\n"
f"Host: {settings.smtp_host}\n"
)
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your email settings are configured correctly!
</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: SMTP<br>
Host: {settings.smtp_host}<br>
Sent at: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
</p>
</body>
</html>
"""
msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html"))
# Connect and send
if settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port)
else:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port)
if settings.smtp_use_tls:
server.starttls()
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.from_email, to_email, msg.as_string())
server.quit()
def _send_sendgrid_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SendGrid."""
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
except ImportError:
raise ValidationError("SendGrid library not installed. Contact support.")
message = Mail(
from_email=(settings.from_email, settings.from_name),
to_emails=to_email,
subject="Wizamart Email Configuration Test",
html_content=f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your SendGrid settings are configured correctly!
</p>
</body>
</html>
""",
)
sg = SendGridAPIClient(settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code >= 400:
raise Exception(f"SendGrid error: {response.status_code}")
def _send_mailgun_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Mailgun."""
import requests
response = requests.post(
f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages",
auth=("api", settings.mailgun_api_key),
data={
"from": f"{settings.from_name} <{settings.from_email}>",
"to": to_email,
"subject": "Wizamart Email Configuration Test",
"html": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Mailgun settings are configured correctly!
</p>
</body>
</html>
""",
},
timeout=30,
)
if response.status_code >= 400:
raise Exception(f"Mailgun error: {response.text}")
def _send_ses_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Amazon SES."""
try:
import boto3
except ImportError:
raise ValidationError("boto3 library not installed. Contact support.")
client = boto3.client(
"ses",
region_name=settings.ses_region,
aws_access_key_id=settings.ses_access_key_id,
aws_secret_access_key=settings.ses_secret_access_key,
)
client.send_email(
Source=f"{settings.from_name} <{settings.from_email}>",
Destination={"ToAddresses": [to_email]},
Message={
"Subject": {"Data": "Wizamart Email Configuration Test"},
"Body": {
"Html": {
"Data": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Amazon SES settings are configured correctly!
</p>
</body>
</html>
"""
}
},
},
)
# =========================================================================
# TIER HELPERS
# =========================================================================
def get_available_providers(self, tier: TierCode | None) -> list[dict]:
"""
Get list of available email providers for a tier.
Returns list of providers with availability status.
"""
providers = [
{
"code": EmailProvider.SMTP.value,
"name": "SMTP",
"description": "Standard SMTP email server",
"available": True,
"tier_required": None,
},
{
"code": EmailProvider.SENDGRID.value,
"name": "SendGrid",
"description": "SendGrid email delivery platform",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.MAILGUN.value,
"name": "Mailgun",
"description": "Mailgun email API",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.SES.value,
"name": "Amazon SES",
"description": "Amazon Simple Email Service",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
]
return providers
# Module-level service factory
def get_vendor_email_settings_service(db: Session) -> VendorEmailSettingsService:
"""Factory function to get a VendorEmailSettingsService instance."""
return VendorEmailSettingsService(db)

View File

@@ -20,6 +20,7 @@
{% call tabs_nav() %}
{{ tab_button('display', 'Display', icon='view-grid') }}
{{ tab_button('logging', 'Logging', icon='document-text') }}
{{ tab_button('email', 'Email', icon='envelope') }}
{{ tab_button('shipping', 'Shipping', icon='truck') }}
{{ tab_button('system', 'System', icon='cog') }}
{{ tab_button('security', 'Security', icon='shield-check') }}
@@ -218,6 +219,349 @@
</div>
</div>
<!-- Email Settings Tab -->
<div x-show="activeTab === 'email'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Platform Email Configuration
</h3>
<!-- Edit/Cancel buttons -->
<div class="flex items-center gap-2">
<template x-if="!emailEditMode">
<button
@click="enableEmailEditing()"
class="px-4 py-2 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-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit Settings
</button>
</template>
<template x-if="emailEditMode">
<button
@click="cancelEmailEditing()"
class="px-4 py-2 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-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
</template>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure the platform's email settings for system emails (billing, subscriptions, admin notifications).
Vendor emails use each vendor's own email settings.
</p>
<!-- Current Status -->
<div class="mb-6 p-4 rounded-lg" :class="emailSettings.is_configured ? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' : 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'">
<div class="flex items-center justify-between">
<div class="flex items-center">
<template x-if="emailSettings.is_configured">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400 mr-2')"></span>
<span class="text-green-800 dark:text-green-300 font-medium">Email configured</span>
</div>
</template>
<template x-if="!emailSettings.is_configured">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-2')"></span>
<span class="text-yellow-800 dark:text-yellow-300 font-medium">Email not fully configured</span>
</div>
</template>
</div>
<template x-if="emailSettings.has_db_overrides">
<span class="px-2 py-1 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">
Database overrides active
</span>
</template>
</div>
</div>
<!-- ===== READ-ONLY VIEW ===== -->
<template x-if="!emailEditMode">
<div>
<!-- Provider Selection (Read-only) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Provider
</label>
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
<template x-for="provider in ['smtp', 'sendgrid', 'mailgun', 'ses', 'debug']" :key="provider">
<div
class="p-3 border-2 rounded-lg text-center"
:class="emailSettings.provider === provider
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-600'"
>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="provider === 'ses' ? 'Amazon SES' : provider"></div>
<template x-if="emailSettings.provider === provider">
<span x-html="$icon('check-circle', 'w-4 h-4 text-purple-600 dark:text-purple-400 mx-auto mt-1')"></span>
</template>
</div>
</template>
</div>
</div>
<!-- Current Settings (Read-only) -->
<div class="space-y-4">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 border-b dark:border-gray-600 pb-2">
Current Configuration
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Email</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.from_email || 'Not configured'"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Name</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.from_name || 'Not configured'"></div>
</div>
<template x-if="emailSettings.provider === 'smtp'">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.smtp_host || 'Not configured'"></div>
</div>
</template>
<template x-if="emailSettings.provider === 'smtp'">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.smtp_port || 'Not configured'"></div>
</div>
</template>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Debug Mode</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm" :class="emailSettings.debug ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-700 dark:text-gray-300'" x-text="emailSettings.debug ? 'Enabled (emails logged, not sent)' : 'Disabled'"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Sending</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm" :class="emailSettings.enabled ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="emailSettings.enabled ? 'Enabled' : 'Disabled'"></div>
</div>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<p>Settings can be configured via environment variables (.env) or overridden in the database using the Edit button above. Database settings take priority over .env values.</p>
</div>
</div>
</div>
<!-- Reset to .env button (only show if DB overrides exist) -->
<template x-if="emailSettings.has_db_overrides">
<div class="mt-4">
<button
@click="resetEmailSettings()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-white dark:bg-gray-700 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
>
<span x-html="$icon('refresh', 'w-4 h-4 inline mr-1')"></span>
Reset to .env Defaults
</button>
</div>
</template>
</div>
</template>
<!-- ===== EDIT MODE ===== -->
<template x-if="emailEditMode">
<div>
<!-- Provider Selection (Editable) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Provider
</label>
<select
x-model="emailForm.provider"
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
>
<option value="smtp">SMTP</option>
<option value="sendgrid">SendGrid</option>
<option value="mailgun">Mailgun</option>
<option value="ses">Amazon SES</option>
</select>
</div>
<!-- Common Settings -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Email *</label>
<input
type="email"
x-model="emailForm.from_email"
placeholder="noreply@yourplatform.com"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Name *</label>
<input
type="text"
x-model="emailForm.from_name"
placeholder="Your Platform"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reply-To Email</label>
<input
type="email"
x-model="emailForm.reply_to"
placeholder="support@yourplatform.com"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
</div>
<!-- SMTP Settings -->
<template x-if="emailForm.provider === 'smtp'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">SMTP Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<input type="text" x-model="emailForm.smtp_host" placeholder="smtp.example.com" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<input type="number" x-model="emailForm.smtp_port" placeholder="587" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
<input type="text" x-model="emailForm.smtp_user" placeholder="username" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
<input type="password" x-model="emailForm.smtp_password" placeholder="Enter new password" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.smtp_use_tls" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use TLS</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.smtp_use_ssl" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use SSL</span>
</label>
</div>
</div>
</div>
</template>
<!-- SendGrid Settings -->
<template x-if="emailForm.provider === 'sendgrid'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">SendGrid Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<input type="password" x-model="emailForm.sendgrid_api_key" placeholder="Enter API key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
</div>
</template>
<!-- Mailgun Settings -->
<template x-if="emailForm.provider === 'mailgun'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Mailgun Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<input type="password" x-model="emailForm.mailgun_api_key" placeholder="Enter API key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Domain</label>
<input type="text" x-model="emailForm.mailgun_domain" placeholder="mg.yourdomain.com" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
</div>
</div>
</template>
<!-- SES Settings -->
<template x-if="emailForm.provider === 'ses'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Amazon SES Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Access Key ID</label>
<input type="password" x-model="emailForm.aws_access_key_id" placeholder="Enter access key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Secret Access Key</label>
<input type="password" x-model="emailForm.aws_secret_access_key" placeholder="Enter secret key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Region</label>
<select x-model="emailForm.aws_region" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
</select>
</div>
</div>
</div>
</template>
<!-- Behavior Settings -->
<div class="flex items-center gap-6 mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.enabled" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Enable email sending</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.debug" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Debug mode (log only, don't send)</span>
</label>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveEmailSettings()"
:disabled="saving"
class="px-6 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Email Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</template>
<!-- Test Email -->
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Send Test Email</h4>
<div class="flex items-center gap-3">
<input
type="email"
x-model="testEmailAddress"
placeholder="test@example.com"
class="flex-1 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<button
@click="sendTestEmail()"
:disabled="!testEmailAddress || sendingTestEmail"
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"
>
<span x-show="!sendingTestEmail">Send Test</span>
<span x-show="sendingTestEmail">Sending...</span>
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Send a test email to verify the platform email configuration is working.
</p>
</div>
</div>
</div>
<!-- Shipping Settings Tab -->
<div x-show="activeTab === 'shipping'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">

View File

@@ -335,3 +335,38 @@
<span>Upgrade to <span x-text="$store.upgrade.nextTier?.name"></span></span>
</a>
{% endmacro %}
{# =============================================================================
Email Settings Warning
Shows warning banner when vendor email settings are not configured.
This banner appears at the top of vendor pages until email is configured.
Usage:
{{ email_settings_warning() }}
============================================================================= #}
{% macro email_settings_warning() %}
<div x-data="emailSettingsWarning()"
x-show="showWarning"
x-cloak
class="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<div>
<p class="font-medium text-yellow-800 dark:text-yellow-300">Email not configured</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400">
Configure your email settings to send order confirmations and customer notifications.
</p>
</div>
</div>
<a :href="`/vendor/${vendorCode}/settings?tab=email`"
class="ml-4 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-200 rounded-lg hover:bg-yellow-300 dark:bg-yellow-800 dark:text-yellow-200 dark:hover:bg-yellow-700 whitespace-nowrap">
Configure Email
</a>
</div>
</div>
{% endmacro %}

View File

@@ -99,6 +99,7 @@
</div>
<!-- Add/Edit Address Modal -->
{# noqa: FE-004 - Complex form modal with dynamic title and extensive form fields not suited for form_modal macro #}
<div x-show="showAddressModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"

View File

@@ -1,5 +1,6 @@
{# app/templates/shop/account/dashboard.html #}
{% extends "shop/base.html" %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}My Account - {{ vendor.name }}{% endblock %}
@@ -117,75 +118,16 @@
</div>
<!-- Logout Confirmation Modal -->
<div x-show="showLogoutModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<!-- Background overlay -->
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay backdrop -->
<div x-show="showLogoutModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showLogoutModal = false"
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
aria-hidden="true">
</div>
<!-- Center modal -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal panel -->
<div x-show="showLogoutModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<!-- Icon -->
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
<span class="h-6 w-6 text-red-600 dark:text-red-400" x-html="$icon('exclamation-triangle', 'h-6 w-6')"></span>
</div>
<!-- Content -->
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
Logout Confirmation
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to logout? You'll need to sign in again to access your account.
</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button @click="confirmLogout()"
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm transition-colors">
Logout
</button>
<button @click="showLogoutModal = false"
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm transition-colors">
Cancel
</button>
</div>
</div>
</div>
</div>
{{ confirm_modal(
id='logoutModal',
title='Logout Confirmation',
message="Are you sure you want to logout? You'll need to sign in again to access your account.",
confirm_action='confirmLogout()',
show_var='showLogoutModal',
confirm_text='Logout',
cancel_text='Cancel',
variant='danger'
) }}
{% endblock %}
{% block extra_scripts %}

View File

@@ -114,6 +114,7 @@
</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"

View File

@@ -71,6 +71,7 @@
</p>
{# Quantity Controls #}
{# noqa: FE-008 - Custom quantity stepper with async updateQuantity() per-item and :value binding #}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<button

View File

@@ -129,6 +129,7 @@
{# Add to Cart Section #}
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
{# Quantity Selector #}
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
<div class="mb-4">
<label class="block font-semibold text-lg mb-2">Quantity:</label>
<div class="flex items-center gap-2">

View File

@@ -1,16 +1,14 @@
{# app/templates/vendor/billing.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Billing & Subscription{% endblock %}
{% block alpine_data %}vendorBilling(){% endblock %}
{% block content %}
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Billing & Subscription
</h2>
</div>
{{ page_header('Billing & Subscription') }}
<!-- Success/Cancel Messages -->
<template x-if="showSuccessMessage">
@@ -260,61 +258,43 @@
</template>
<!-- Tiers Modal -->
<div x-show="showTiersModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showTiersModal = false">
<div class="w-full max-w-4xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Choose Your Plan</h3>
<button @click="showTiersModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<template x-for="tier in tiers" :key="tier.code">
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<template x-if="tier.is_current">
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
</template>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
<span class="text-sm font-normal text-gray-500">/mo</span>
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
</li>
</ul>
<button @click="selectTier(tier)"
:disabled="tier.is_current"
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
</button>
</div>
<div class="p-6">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<template x-for="tier in tiers" :key="tier.code">
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<template x-if="tier.is_current">
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
</template>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
<span class="text-sm font-normal text-gray-500">/mo</span>
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
</li>
</ul>
<button @click="selectTier(tier)"
:disabled="tier.is_current"
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
</button>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
{% endcall %}
<!-- Add-ons Modal -->
<div x-show="showAddonsModal"

View File

@@ -3,6 +3,8 @@
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Customers{% endblock %}
@@ -98,133 +100,123 @@
</div>
<!-- Customers Table -->
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="customer in customers" :key="customer.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Customer Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(customer)"></span>
</div>
<div>
<p class="font-semibold" x-text="`${customer.first_name || ''} ${customer.last_name || ''}`.trim() || 'Unknown'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="customer.phone || ''"></p>
</div>
</div>
</td>
<!-- Email -->
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
<!-- Orders -->
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewCustomer(customer)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="viewCustomerOrders(customer)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="View Orders"
>
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
</button>
<button
@click="messageCustomer(customer)"
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
title="Send Message"
>
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="customers.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No customers found</p>
<p class="text-sm">Customers will appear here when they make purchases</p>
<div x-show="!loading && !error" class="mb-8">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="customer in customers" :key="customer.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Customer Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(customer)"></span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<p class="font-semibold" x-text="`${customer.first_name || ''} ${customer.last_name || ''}`.trim() || 'Unknown'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="customer.phone || ''"></p>
</div>
</div>
</td>
<!-- Email -->
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
<!-- Orders -->
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewCustomer(customer)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="viewCustomerOrders(customer)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="View Orders"
>
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
</button>
<button
@click="messageCustomer(customer)"
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
title="Send Message"
>
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="customers.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No customers found</p>
<p class="text-sm">Customers will appear here when they make purchases</p>
</div>
</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Pagination -->
{{ pagination(show_condition="!loading && pagination.total > 0") }}
<!-- Customer Detail Modal -->
<div x-show="showDetailModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div class="w-full max-w-lg bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showDetailModal = false">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Customer Details</h3>
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
{% call modal_simple('customerDetailModal', 'Customer Details', show_var='showDetailModal', size='md') %}
<div x-show="selectedCustomer">
<div class="flex items-center mb-4">
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(selectedCustomer)"></span>
</div>
<div class="p-4" x-show="selectedCustomer">
<div class="flex items-center mb-4">
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(selectedCustomer)"></span>
</div>
<div>
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim() || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCustomer?.email"></p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Phone</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.phone || '-'"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Joined</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
</div>
</div>
<div>
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim() || 'Unknown'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCustomer?.email"></p>
</div>
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Send Message
</button>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Phone</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.phone || '-'"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Joined</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-4 mt-4 border-t dark:border-gray-700">
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Send Message
</button>
</div>
{% endcall %}
<!-- Customer Orders Modal -->
<div x-show="showOrdersModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">

View File

@@ -8,7 +8,12 @@
{% block alpine_data %}vendorDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
{% block content %}
<!-- Email Settings Warning -->
{{ email_settings_warning() }}
<!-- Limit Warnings -->
{{ limit_warning("orders") }}
{{ limit_warning("products") }}

View File

@@ -34,6 +34,7 @@
</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>

View File

@@ -4,6 +4,7 @@
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Inventory{% endblock %}
@@ -154,99 +155,97 @@
</div>
<!-- Inventory Table -->
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@click="toggleSelectAll()"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">SKU</th>
<th class="px-4 py-3">Location</th>
<th class="px-4 py-3">Quantity</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="item in inventory" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(item.id)}">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(item.id)"
@click="toggleSelect(item.id)"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<!-- Product -->
<td class="px-4 py-3">
<div class="text-sm">
<p class="font-semibold" x-text="item.product_name || 'Unknown Product'"></p>
</div>
</td>
<!-- SKU -->
<td class="px-4 py-3 text-sm font-mono" x-text="item.product_sku || '-'"></td>
<!-- Location -->
<td class="px-4 py-3 text-sm" x-text="item.location || 'Default'"></td>
<!-- Quantity -->
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(item.quantity)"></td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStockStatus(item) === 'ok',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStockStatus(item) === 'low',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStockStatus(item) === 'out'
}"
>
<span x-text="getStockStatus(item) === 'out' ? 'Out of Stock' : (getStockStatus(item) === 'low' ? 'Low Stock' : 'In Stock')"></span>
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="openAdjustModal(item)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Adjust Stock"
>
<span x-html="$icon('plus-minus', 'w-5 h-5')"></span>
</button>
<button
@click="openSetModal(item)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="Set Quantity"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="inventory.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('archive', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No inventory found</p>
<p class="text-sm">Add products and set their stock levels</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div x-show="!loading && !error" class="mb-8">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@click="toggleSelectAll()"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">SKU</th>
<th class="px-4 py-3">Location</th>
<th class="px-4 py-3">Quantity</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="item in inventory" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(item.id)}">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(item.id)"
@click="toggleSelect(item.id)"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<!-- Product -->
<td class="px-4 py-3">
<div class="text-sm">
<p class="font-semibold" x-text="item.product_name || 'Unknown Product'"></p>
</div>
</td>
<!-- SKU -->
<td class="px-4 py-3 text-sm font-mono" x-text="item.product_sku || '-'"></td>
<!-- Location -->
<td class="px-4 py-3 text-sm" x-text="item.location || 'Default'"></td>
<!-- Quantity -->
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(item.quantity)"></td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStockStatus(item) === 'ok',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStockStatus(item) === 'low',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStockStatus(item) === 'out'
}"
>
<span x-text="getStockStatus(item) === 'out' ? 'Out of Stock' : (getStockStatus(item) === 'low' ? 'Low Stock' : 'In Stock')"></span>
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="openAdjustModal(item)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Adjust Stock"
>
<span x-html="$icon('plus-minus', 'w-5 h-5')"></span>
</button>
<button
@click="openSetModal(item)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="Set Quantity"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="inventory.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('archive', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No inventory found</p>
<p class="text-sm">Add products and set their stock levels</p>
</div>
</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Pagination -->
@@ -263,6 +262,7 @@
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Adjustment (+ or -)</label>
{# noqa: FE-008 - Adjustment input accepts +/- values, not a quantity stepper #}
<input
type="number"
x-model.number="adjustForm.quantity"

View File

@@ -1,6 +1,10 @@
{# app/templates/vendor/invoices.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, simple_pagination %}
{% from 'shared/macros/modals.html' import form_modal %}
{% block title %}Invoices{% endblock %}
{% block alpine_data %}vendorInvoices(){% endblock %}
@@ -11,36 +15,18 @@
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Invoices
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Create and manage invoices for your orders
</p>
</div>
<div class="flex gap-2">
<button
@click="openCreateModal()"
:disabled="!hasSettings"
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 disabled:opacity-50 disabled:cursor-not-allowed"
:title="!hasSettings ? 'Configure invoice settings first' : 'Create new invoice'"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Invoice
</button>
<button
@click="refreshData()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
{% call page_header_flex(title='Invoices', subtitle='Create and manage invoices for your orders') %}
<button
@click="openCreateModal()"
:disabled="!hasSettings"
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 disabled:opacity-50 disabled:cursor-not-allowed"
:title="!hasSettings ? 'Configure invoice settings first' : 'Create new invoice'"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Invoice
</button>
{{ refresh_button(loading_var='loading', onclick='refreshData()', variant='secondary') }}
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
@@ -54,6 +40,7 @@
</div>
<!-- Error Message -->
{# noqa: FE-003 - Uses dismissible close button not supported by error_state macro #}
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
@@ -169,20 +156,9 @@
</div>
<!-- Invoices Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Invoice #</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{% call table_wrapper() %}
{{ table_header(['Invoice #', 'Customer', 'Date', 'Amount', 'Status', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading && invoices.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
@@ -269,41 +245,11 @@
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="totalInvoices > perPage" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((page - 1) * perPage) + 1" class="mx-1"></span>-<span x-text="Math.min(page * perPage, totalInvoices)" class="mx-1"></span> of <span x-text="totalInvoices" class="mx-1"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li>
<button
@click="page--; loadInvoices()"
:disabled="page <= 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button
@click="page++; loadInvoices()"
:disabled="page * perPage >= totalInvoices"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>
</tbody>
{% endcall %}
<!-- Pagination -->
{{ simple_pagination(page_var='page', total_var='totalInvoices', limit_var='perPage', on_change='loadInvoices()') }}
</div>
<!-- Settings Tab -->
@@ -522,81 +468,34 @@
</div>
<!-- Create Invoice Modal -->
<div
x-show="showCreateModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false"
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Create Invoice</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<form @submit.prevent="createInvoice()">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Order ID <span class="text-red-500">*</span>
</label>
<input
type="number"
x-model="createForm.order_id"
required
placeholder="Enter order ID"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter the order ID to generate an invoice for
</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Notes (Optional)
</label>
<textarea
x-model="createForm.notes"
rows="3"
placeholder="Any additional notes for the invoice..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="creatingInvoice"
class="flex items-center 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="creatingInvoice" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="creatingInvoice ? 'Creating...' : 'Create Invoice'"></span>
</button>
</div>
</form>
{% call form_modal('createInvoiceModal', 'Create Invoice', show_var='showCreateModal', submit_action='createInvoice()', submit_text='Create Invoice', loading_var='creatingInvoice', loading_text='Creating...', size='sm') %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Order ID <span class="text-red-500">*</span>
</label>
{# noqa: FE-008 - Order ID is a reference field, not a quantity stepper #}
<input
type="number"
x-model="createForm.order_id"
required
placeholder="Enter order ID"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter the order ID to generate an invoice for
</p>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Notes (Optional)
</label>
<textarea
x-model="createForm.notes"
rows="3"
placeholder="Any additional notes for the invoice..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
></textarea>
</div>
{% endcall %}
{% endblock %}

View File

@@ -1,5 +1,8 @@
{# app/templates/vendor/letzshop.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import form_modal %}
{% block title %}Letzshop Orders{% endblock %}
@@ -11,36 +14,26 @@
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Letzshop Orders
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage orders from Letzshop marketplace
</p>
</div>
<div class="flex gap-2">
<button
@click="importOrders()"
:disabled="!status.is_configured || importing"
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Importing...' : 'Import Orders'"></span>
</button>
<button
@click="refreshData()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
{% call page_header_flex(title='Letzshop Orders', subtitle='Manage orders from Letzshop marketplace') %}
<button
@click="importOrders()"
:disabled="!status.is_configured || importing"
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Importing...' : 'Import Orders'"></span>
</button>
<button
@click="refreshData()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
@@ -53,6 +46,7 @@
</button>
</div>
{# noqa: FE-003 - Custom dismissible error with dark mode support not available in error_state macro #}
<!-- Error Message -->
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
@@ -167,19 +161,8 @@
</div>
<!-- Orders Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
{% call table_wrapper() %}
{{ table_header(['Order', 'Customer', 'Total', 'Status', 'Date', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading && orders.length === 0">
<tr>
@@ -268,39 +251,38 @@
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="totalOrders > limit" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalOrders)"></span> of <span x-text="totalOrders"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li>
<button
@click="page--; loadOrders()"
:disabled="page <= 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button
@click="page++; loadOrders()"
:disabled="page * limit >= totalOrders"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
{% endcall %}
{# noqa: FE-001 - Uses flat variables (page, limit, totalOrders) instead of pagination object expected by macro #}
<!-- Pagination -->
<div x-show="totalOrders > limit" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalOrders)"></span> of <span x-text="totalOrders"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li>
<button
@click="page--; loadOrders()"
:disabled="page <= 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button
@click="page++; loadOrders()"
:disabled="page * limit >= totalOrders"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>
@@ -582,88 +564,40 @@
</div>
<!-- Tracking Modal -->
<div
x-show="showTrackingModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showTrackingModal = false"
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Set Tracking Information</h3>
<button @click="showTrackingModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<form @submit.prevent="submitTracking()">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Tracking Number <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="trackingForm.tracking_number"
required
placeholder="1Z999AA10123456784"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Carrier <span class="text-red-500">*</span>
</label>
<select
x-model="trackingForm.tracking_carrier"
required
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">Select carrier...</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="post_lu">Post Luxembourg</option>
<option value="dpd">DPD</option>
<option value="gls">GLS</option>
<option value="other">Other</option>
</select>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showTrackingModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="submittingTracking"
class="flex items-center 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="submittingTracking" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submittingTracking ? 'Saving...' : 'Save Tracking'"></span>
</button>
</div>
</form>
{% call form_modal('trackingModal', 'Set Tracking Information', show_var='showTrackingModal', submit_action='submitTracking()', submit_text='Save Tracking', loading_var='submittingTracking', loading_text='Saving...', size='sm') %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Tracking Number <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="trackingForm.tracking_number"
required
placeholder="1Z999AA10123456784"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Carrier <span class="text-red-500">*</span>
</label>
<select
x-model="trackingForm.tracking_carrier"
required
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">Select carrier...</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="post_lu">Post Luxembourg</option>
<option value="dpd">DPD</option>
<option value="gls">GLS</option>
<option value="other">Other</option>
</select>
</div>
{% endcall %}
<!-- Order Details Modal -->
<div

View File

@@ -1,6 +1,10 @@
{# app/templates/vendor/marketplace.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, simple_pagination %}
{% from 'shared/macros/modals.html' import job_details_modal %}
{% block title %}Marketplace Import{% endblock %}
@@ -12,25 +16,9 @@
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Marketplace Import
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Import products from Letzshop marketplace CSV feeds
</p>
</div>
<button
@click="refreshJobs()"
:disabled="loading"
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
{% call page_header_flex(title='Marketplace Import', subtitle='Import products from Letzshop marketplace CSV feeds') %}
{{ refresh_button(loading_var='loading', onclick='refreshJobs()') }}
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
@@ -41,13 +29,7 @@
</div>
<!-- Error Message -->
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
{{ error_state(title='Error', error_var='error', show_condition='error') }}
<!-- Import Form Card -->
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
@@ -194,88 +176,77 @@
</div>
<!-- Jobs Table -->
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Job ID</th>
<th class="px-4 py-3">Marketplace</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Progress</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Actions</th>
<div x-show="!loading && jobs.length > 0">
{% call table_wrapper() %}
{{ table_header(['Job ID', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm">
#<span x-text="job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.marketplace"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
}"
x-text="job.status.replace('_', ' ').toUpperCase()">
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
</div>
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
<span x-text="job.error_count"></span> errors
</div>
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
Total: <span x-text="job.total_processed"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="calculateDuration(job)"></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="viewJobDetails(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button
x-show="job.status === 'processing' || job.status === 'pending'"
@click="refreshJobStatus(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Refresh Status"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm">
#<span x-text="job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.marketplace"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
}"
x-text="job.status.replace('_', ' ').toUpperCase()">
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
</div>
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
<span x-text="job.error_count"></span> errors
</div>
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
Total: <span x-text="job.total_processed"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="calculateDuration(job)"></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="viewJobDetails(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button
x-show="job.status === 'processing' || job.status === 'pending'"
@click="refreshJobStatus(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Refresh Status"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</tbody>
{% endcall %}
</div>
<!-- Pagination -->
{# noqa: FE-001 - Custom pagination with text buttons and totalJobs variable #}
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-400">
@@ -304,6 +275,7 @@
</div>
</div>
{# noqa: FE-004 - Custom modal with different field names (imported_count vs imported) #}
<!-- Job Details Modal -->
<div x-show="showJobModal"
x-cloak

View File

@@ -2,6 +2,7 @@
{% 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 %}
@@ -218,72 +219,59 @@
</div>
<!-- Compose Modal -->
<div x-show="showComposeModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center"
@keydown.escape.window="showComposeModal = false">
<div class="absolute inset-0 bg-black bg-opacity-50" @click="showComposeModal = false"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">New Conversation</h3>
<button @click="showComposeModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
<form @submit.prevent="createConversation()" class="p-6 space-y-4">
<!-- Customer -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer</label>
<select
x-model="compose.recipientId"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Select customer...</option>
<template x-for="r in recipients" :key="r.id">
<option :value="r.id" x-text="r.name + ' - ' + (r.email || '')"></option>
</template>
</select>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
<input
type="text"
x-model="compose.subject"
placeholder="What is this about?"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
<!-- Message -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
<textarea
x-model="compose.message"
rows="4"
placeholder="Type your message..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 resize-none"
></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showComposeModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
:disabled="!compose.recipientId || !compose.subject.trim() || creatingConversation"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="!creatingConversation">Start Conversation</span>
<span x-show="creatingConversation">Creating...</span>
</button>
</div>
</form>
{% 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>
</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 %}

View File

@@ -3,6 +3,7 @@
{% 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 %}
@@ -180,56 +181,48 @@
</div>
<!-- Settings Modal -->
<div x-show="showSettingsModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div class="w-full max-w-md bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showSettingsModal = false">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Notification Settings</h3>
<button @click="showSettingsModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 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 p-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>
{% 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 %}

View File

@@ -31,6 +31,7 @@
</button>
<!-- Language Selector -->
{# noqa: FE-006 - Custom language selector with flags, not suited for dropdown macro #}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
@@ -280,6 +281,7 @@
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.preorder_days')"></label>
{# noqa: FE-008 - Simple number input, not a quantity stepper pattern #}
<input type="number" x-model="formData.preorder_days" min="0" max="30"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="t('step3.preorder_days_help')"></p>

View File

@@ -4,6 +4,7 @@
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% from 'shared/macros/tables.html' import table_wrapper %}
{% block title %}Orders{% endblock %}
@@ -160,105 +161,103 @@
</div>
<!-- Orders Table -->
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@click="toggleSelectAll()"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th class="px-4 py-3">Order #</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="order in orders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(order.id)}">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(order.id)"
@click="toggleSelect(order.id)"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<!-- Order Number -->
<td class="px-4 py-3">
<span class="font-mono font-semibold" x-text="order.order_number || `#${order.id}`"></span>
</td>
<!-- Customer -->
<td class="px-4 py-3">
<div class="text-sm">
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="order.customer_email || ''"></p>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)"></td>
<!-- Total -->
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(order.total)"></td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStatusColor(order.status) === 'yellow',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': getStatusColor(order.status) === 'blue',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': getStatusColor(order.status) === 'orange',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStatusColor(order.status) === 'green',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStatusColor(order.status) === 'red',
'text-indigo-700 bg-indigo-100 dark:bg-indigo-700 dark:text-indigo-100': getStatusColor(order.status) === 'indigo',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order.status) === 'gray'
}"
x-text="getStatusLabel(order.status)"
></span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewOrder(order)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="openStatusModal(order)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Update Status"
>
<span x-html="$icon('pencil-square', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="orders.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('document-text', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No orders found</p>
<p class="text-sm">Orders will appear here when customers make purchases</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div x-show="!loading && !error" class="mb-8">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@click="toggleSelectAll()"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th class="px-4 py-3">Order #</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="order in orders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(order.id)}">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(order.id)"
@click="toggleSelect(order.id)"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<!-- Order Number -->
<td class="px-4 py-3">
<span class="font-mono font-semibold" x-text="order.order_number || `#${order.id}`"></span>
</td>
<!-- Customer -->
<td class="px-4 py-3">
<div class="text-sm">
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="order.customer_email || ''"></p>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)"></td>
<!-- Total -->
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(order.total)"></td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStatusColor(order.status) === 'yellow',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': getStatusColor(order.status) === 'blue',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': getStatusColor(order.status) === 'orange',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStatusColor(order.status) === 'green',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStatusColor(order.status) === 'red',
'text-indigo-700 bg-indigo-100 dark:bg-indigo-700 dark:text-indigo-100': getStatusColor(order.status) === 'indigo',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order.status) === 'gray'
}"
x-text="getStatusLabel(order.status)"
></span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewOrder(order)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="openStatusModal(order)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Update Status"
>
<span x-html="$icon('pencil-square', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="orders.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('document-text', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No orders found</p>
<p class="text-sm">Orders will appear here when customers make purchases</p>
</div>
</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Pagination -->

View File

@@ -27,6 +27,7 @@
{{ tab_button('marketplace', 'Marketplace', tab_var='activeSection', icon='shopping-cart') }}
{{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }}
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
{{ tab_button('email', 'Email', tab_var='activeSection', icon='envelope') }}
{{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
@@ -591,6 +592,7 @@
</div>
<!-- Boost Sort -->
{# noqa: FE-008 - Decimal input with 0.1 step and custom @input handler, not suited for number_stepper #}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Boost Sort Priority
@@ -803,6 +805,398 @@
</div>
</div>
<!-- Email Settings -->
<div x-show="activeSection === 'email'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Email Settings</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Configure your email sending settings for customer communications</p>
</div>
<div class="p-4">
<!-- Loading state for email settings -->
<div x-show="emailSettingsLoading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
<div x-show="!emailSettingsLoading" class="space-y-6">
<!-- Configuration Status Banner -->
<template x-if="!emailSettings?.is_configured">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-start gap-3">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="font-medium text-yellow-800 dark:text-yellow-300">Email not configured</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
Configure your SMTP settings to send emails to your customers (order confirmations, shipping updates, etc.)
</p>
</div>
</div>
</div>
</template>
<template x-if="emailSettings?.is_configured && !emailSettings?.is_verified">
<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="font-medium text-blue-800 dark:text-blue-300">Email configured but not verified</p>
<p class="text-sm text-blue-700 dark:text-blue-400 mt-1">
Send a test email to verify your settings are working correctly.
</p>
</div>
</div>
</div>
</template>
<template x-if="emailSettings?.is_configured && emailSettings?.is_verified">
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div class="flex items-start gap-3">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="font-medium text-green-800 dark:text-green-300">Email configured and verified</p>
<p class="text-sm text-green-700 dark:text-green-400 mt-1">
Your email settings are ready. Emails will be sent from <span class="font-medium" x-text="emailForm.from_email"></span>
</p>
</div>
</div>
</div>
</template>
<!-- Sender Identity -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Sender Identity</h4>
<div class="space-y-4">
<!-- From Email -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
From Email <span class="text-red-500">*</span>
</label>
<input
type="email"
x-model="emailForm.from_email"
@input="markEmailChanged()"
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="orders@yourstore.com"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Email address that customers will see in their inbox
</p>
</div>
<!-- From Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
From Name <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.from_name"
@input="markEmailChanged()"
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 Store Name"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Name that appears as the sender (e.g., "Your Store Name")
</p>
</div>
<!-- Reply-To Email -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reply-To Email
</label>
<input
type="email"
x-model="emailForm.reply_to_email"
@input="markEmailChanged()"
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="support@yourstore.com"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Optional: Where replies should go (defaults to From Email)
</p>
</div>
</div>
</div>
<!-- Email Provider -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Email Provider</h4>
<div class="space-y-4">
<!-- Provider Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Provider
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<template x-for="provider in emailProviders" :key="provider.code">
<button
type="button"
@click="provider.available ? (emailForm.provider = provider.code, markEmailChanged()) : null"
:class="emailForm.provider === provider.code
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: provider.available
? 'border-gray-200 dark:border-gray-600 hover:border-purple-300'
: 'border-gray-200 dark:border-gray-600 opacity-50 cursor-not-allowed'"
class="relative p-3 border-2 rounded-lg transition-colors"
>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="provider.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="provider.description"></div>
<template x-if="!provider.available">
<div class="absolute top-1 right-1">
<span class="px-1.5 py-0.5 text-xs font-medium text-purple-600 bg-purple-100 rounded dark:bg-purple-900/50 dark:text-purple-400">
Business+
</span>
</div>
</template>
<template x-if="emailForm.provider === provider.code">
<div class="absolute top-1 right-1">
<span x-html="$icon('check-circle', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
</div>
</template>
</button>
</template>
</div>
</div>
<!-- SMTP Settings -->
<template x-if="emailForm.provider === 'smtp'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- SMTP Host -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Host <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.smtp_host"
@input="markEmailChanged()"
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="smtp.yourprovider.com"
/>
</div>
<!-- SMTP Port -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Port <span class="text-red-500">*</span>
</label>
<input
type="number"
x-model.number="emailForm.smtp_port"
@input="markEmailChanged()"
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="587"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- SMTP Username -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Username <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.smtp_username"
@input="markEmailChanged()"
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-username"
/>
</div>
<!-- SMTP Password -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Password <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.smtp_password"
@input="markEmailChanged()"
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="emailSettings?.smtp_password_set ? '••••••••' : 'Enter password'"
/>
</div>
</div>
<!-- TLS/SSL Options -->
<div class="flex items-center gap-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="emailForm.smtp_use_tls"
@change="markEmailChanged()"
class="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use TLS (STARTTLS)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="emailForm.smtp_use_ssl"
@change="markEmailChanged()"
class="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use SSL (port 465)</span>
</label>
</div>
</div>
</template>
<!-- SendGrid Settings -->
<template x-if="emailForm.provider === 'sendgrid'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SendGrid API Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.sendgrid_api_key"
@input="markEmailChanged()"
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="emailSettings?.sendgrid_api_key_set ? '••••••••' : 'SG.xxxxx'"
/>
</div>
</div>
</template>
<!-- Mailgun Settings -->
<template x-if="emailForm.provider === 'mailgun'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mailgun API Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.mailgun_api_key"
@input="markEmailChanged()"
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="emailSettings?.mailgun_api_key_set ? '••••••••' : 'key-xxxxx'"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mailgun Domain <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.mailgun_domain"
@input="markEmailChanged()"
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="mg.yourdomain.com"
/>
</div>
</div>
</div>
</template>
<!-- SES Settings -->
<template x-if="emailForm.provider === 'ses'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Access Key ID <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.ses_access_key_id"
@input="markEmailChanged()"
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="AKIAIOSFODNN7EXAMPLE"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Secret Access Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.ses_secret_access_key"
@input="markEmailChanged()"
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="emailSettings?.ses_access_key_id_set ? '••••••••' : 'Enter secret'"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Region
</label>
<select
x-model="emailForm.ses_region"
@change="markEmailChanged()"
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"
>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
</select>
</div>
</div>
</template>
</div>
</div>
<!-- Signature (Optional) -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Email Signature (Optional)</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Plain Text Signature
</label>
<textarea
x-model="emailForm.signature_text"
@input="markEmailChanged()"
rows="3"
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="Best regards,&#10;The Your Store Team"
></textarea>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-4 pt-4 border-t dark:border-gray-600">
<!-- Test Email -->
<div class="flex items-center gap-2">
<input
type="email"
x-model="testEmailAddress"
class="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="test@example.com"
/>
<button
@click="sendTestEmail()"
:disabled="!emailSettings?.is_configured || sendingTestEmail || !testEmailAddress"
class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 disabled:opacity-50 dark:bg-purple-900/30 dark:text-purple-400"
>
<span x-show="!sendingTestEmail">Send Test</span>
<span x-show="sendingTestEmail">Sending...</span>
</button>
</div>
<!-- Save Button -->
<button
@click="saveEmailSettings()"
:disabled="saving || !hasEmailChanges"
class="px-6 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 Email Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
</div>
<!-- Domains Settings -->
<div x-show="activeSection === 'domains'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">

View File

@@ -3,6 +3,7 @@
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% from 'shared/macros/tables.html' import table_wrapper %}
{% block title %}Team{% endblock %}
@@ -64,100 +65,98 @@
</div>
<!-- Team Members Table -->
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Member</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="member in members" :key="member.user_id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Member Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(member)"></span>
</div>
<div>
<p class="font-semibold" x-text="`${member.first_name || ''} ${member.last_name || ''}`.trim() || member.email"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"></p>
</div>
</div>
</td>
<!-- Role -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100"
x-text="getRoleName(member)"
></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': member.is_active && !member.invitation_pending,
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': member.invitation_pending,
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !member.is_active
}"
x-text="member.invitation_pending ? 'Pending' : (member.is_active ? 'Active' : 'Inactive')"
></span>
</td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(member.joined_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- Edit button - not for owners -->
<button
@click="openEditModal(member)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Edit"
x-show="!member.is_owner"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
<!-- Owner badge -->
<span x-show="member.is_owner" class="text-xs text-gray-400 dark:text-gray-500 italic">Owner</span>
<!-- Remove button - not for owners -->
<button
@click="confirmRemove(member)"
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
title="Remove"
x-show="!member.is_owner"
>
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="members.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No team members yet</p>
<p class="text-sm">Invite your first team member to get started</p>
<button
@click="openInviteModal()"
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Invite Member
</button>
<div x-show="!loading && !error" class="mb-8">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Member</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="member in members" :key="member.user_id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Member Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(member)"></span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<p class="font-semibold" x-text="`${member.first_name || ''} ${member.last_name || ''}`.trim() || member.email"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"></p>
</div>
</div>
</td>
<!-- Role -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100"
x-text="getRoleName(member)"
></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span
:class="{
'px-2 py-1 font-semibold leading-tight rounded-full': true,
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': member.is_active && !member.invitation_pending,
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': member.invitation_pending,
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !member.is_active
}"
x-text="member.invitation_pending ? 'Pending' : (member.is_active ? 'Active' : 'Inactive')"
></span>
</td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(member.joined_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- Edit button - not for owners -->
<button
@click="openEditModal(member)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Edit"
x-show="!member.is_owner"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
<!-- Owner badge -->
<span x-show="member.is_owner" class="text-xs text-gray-400 dark:text-gray-500 italic">Owner</span>
<!-- Remove button - not for owners -->
<button
@click="confirmRemove(member)"
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
title="Remove"
x-show="!member.is_owner"
>
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="members.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No team members yet</p>
<p class="text-sm">Invite your first team member to get started</p>
<button
@click="openInviteModal()"
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Invite Member
</button>
</div>
</td>
</tr>
</tbody>
{% endcall %}
</div>
<!-- Invite Modal -->