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

@@ -97,21 +97,30 @@ migrate-status:
init-prod:
@echo "🔧 Initializing production database..."
@echo ""
@echo "Step 1/4: Creating admin user and platform alerts..."
@echo "Step 1/5: Creating admin user and platform settings..."
$(PYTHON) scripts/init_production.py
@echo ""
@echo "Step 2/4: Initializing log settings..."
@echo "Step 2/5: Initializing log settings..."
$(PYTHON) scripts/init_log_settings.py
@echo ""
@echo "Step 3/4: Creating default CMS content pages..."
@echo "Step 3/5: Creating default CMS content pages..."
$(PYTHON) scripts/create_default_content_pages.py
@echo ""
@echo "Step 4/4: Creating platform pages and landing..."
@echo "Step 4/5: Creating platform pages and landing..."
$(PYTHON) scripts/create_platform_pages.py
@echo ""
@echo "Step 5/5: Seeding email templates..."
$(PYTHON) scripts/seed_email_templates.py
@echo ""
@echo "✅ Production initialization completed"
@echo "✨ Platform is ready for production OR development"
# First-time installation - Complete setup with configuration validation
install:
@echo "🚀 WIZAMART PLATFORM INSTALLATION"
@echo "=================================="
$(PYTHON) scripts/install.py
# Demo data seeding - Cross-platform using Python to set environment
seed-demo:
@echo "🎪 Seeding demo data (normal mode)..."
@@ -423,7 +432,8 @@ help:
@echo " migrate-up - Apply pending migrations"
@echo " migrate-down - Rollback last migration"
@echo " migrate-status - Show migration status"
@echo " init-prod - Initialize platform (admin, logging, CMS, pages)"
@echo " install - First-time setup (validates config + migrate + init)"
@echo " init-prod - Initialize platform (admin, CMS, pages, emails)"
@echo " seed-demo - Seed demo data (3 companies + vendors)"
@echo " seed-demo-minimal - Seed minimal demo (1 company + vendor)"
@echo " seed-demo-reset - DELETE ALL demo data and reseed"
@@ -483,13 +493,23 @@ help-db:
@echo " migrate-down - Rollback last migration"
@echo " migrate-status - Show current status and history"
@echo ""
@echo "FIRST-TIME INSTALLATION:"
@echo "──────────────────────────────────────────────────────────"
@echo " install - Complete installation wizard:"
@echo " - Validates .env configuration"
@echo " - Checks Stripe, Email, Security settings"
@echo " - Runs database migrations"
@echo " - Initializes all platform data"
@echo " - Provides configuration report"
@echo ""
@echo "PLATFORM INITIALIZATION (Production + Development):"
@echo "──────────────────────────────────────────────────────────"
@echo " init-prod - Complete platform setup (4 steps):"
@echo " 1. Create admin user + alerts"
@echo " init-prod - Complete platform setup (5 steps):"
@echo " 1. Create admin user + settings"
@echo " 2. Initialize log settings"
@echo " 3. Create CMS defaults"
@echo " 4. Create platform pages"
@echo " 5. Seed email templates"
@echo ""
@echo "DEMO DATA (Development Only - NEVER in production):"
@echo "──────────────────────────────────────────────────────────"
@@ -510,17 +530,18 @@ help-db:
@echo ""
@echo "TYPICAL FIRST-TIME SETUP (Development):"
@echo "──────────────────────────────────────────────────────────"
@echo " 1. make migrate-up # Apply database schema"
@echo " 2. make init-prod # Initialize platform (admin, CMS, logging, pages)"
@echo " 3. make seed-demo # Add demo data (companies, vendors, products)"
@echo " 1. cp .env.example .env # Configure environment"
@echo " 2. make install # Validates config + initializes platform"
@echo " 3. make seed-demo # Add demo data (optional)"
@echo " 4. make dev # Start development server"
@echo ""
@echo " OR simply: make db-setup # Does all the above!"
@echo ""
@echo "PRODUCTION SETUP:"
@echo "──────────────────────────────────────────────────────────"
@echo " 1. Set ENV=production or ENVIRONMENT=production"
@echo " 2. make migrate-up # Apply database schema"
@echo " 3. make init-prod # Initialize platform (with .env credentials)"
@echo " 4. Create companies via admin panel"
@echo " 5. DO NOT run seed-demo in production!"
@echo " 1. Configure .env with production values:"
@echo " - DATABASE_URL (PostgreSQL recommended)"
@echo " - JWT_SECRET_KEY (use: openssl rand -hex 32)"
@echo " - STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY"
@echo " - Email provider settings (SMTP/SendGrid/Mailgun/SES)"
@echo " - ADMIN_PASSWORD (strong password)"
@echo " 2. make install # Validates + initializes"
@echo " 3. DO NOT run seed-demo in production!"

123
TODO-VENDOR-SECTION Normal file
View File

@@ -0,0 +1,123 @@
> can you please do a 360 analysis for settings, what else is missing and should be editable / viewable by vendors to properly manage their operations and storefront
● Explore(Analyze vendor settings comprehensively)
 Done (38 tool uses · 66.4k tokens · 1m 18s)
● 360° Vendor Settings Analysis
Current Settings Page Structure
| Section | Status |
|---------------|------------------------------|
| General | ✅ Basic (subdomain, status) |
| Localization | ✅ Complete |
| Marketplace | ⚠️ Partial (only CSV URLs) |
| Notifications | ❌ Display-only placeholder |
---
What's Missing (by Priority)
Priority 1: Existing Data Not Exposed
| Category | Fields in DB | Current UI |
|------------------------|-----------------------------------------------------------------------|------------|
| Business Profile | name, description, contact_email, phone, website, address, tax_number | ❌ None |
| Letzshop Feed Settings | default_tax_rate, boost_sort, delivery_method, preorder_days | ❌ None |
| Letzshop Sync Settings | auto_sync, sync_interval, test_mode, carriers | ❌ None |
| Invoice Settings | company legal info, VAT, bank details, invoice numbering | ❌ None |
| Theme/Branding | colors, fonts, logos, layout, custom CSS, social links | ❌ None |
| Custom Domains | domain, SSL status, verification | ❌ None |
Priority 2: Needs Database Models
| Category | Description |
|----------------------|---------------------------------------------|
| Shipping/Fulfillment | Zones, rates, carriers, return policies |
| Order Preferences | Auto-confirm, auto-ship, order numbering |
| SEO/Marketing | Google Analytics, Meta Pixel, schema markup |
| Legal/Compliance | Terms, Privacy, GDPR, cookie consent |
| Security | 2FA, API key rotation, IP whitelist |
---
Proposed Settings Page Restructure
Settings
├── General
│ ├── Store Name & Description
│ ├── Subdomain
│ └── Store Status (view only)
├── Business Info
│ ├── Contact Email, Phone
│ ├── Website
│ ├── Business Address
│ └── Tax Number
├── Localization ✅ (done)
│ ├── Currency (view only)
│ ├── Number Format (locale)
│ ├── Dashboard Language
│ ├── Content Language
│ └── Storefront Languages
├── Invoice & Billing
│ ├── Company Legal Name & Address
│ ├── VAT Registration
│ ├── Bank Details (IBAN, BIC)
│ ├── Invoice Numbering
│ └── Payment Terms
├── Branding & Theme
│ ├── Logo Upload (light/dark)
│ ├── Favicon & Banner
│ ├── Color Scheme
│ ├── Font Selection
│ ├── Layout Style
│ └── Social Links
├── Letzshop Integration
│ ├── CSV Feed URLs ✅
│ ├── Feed Settings (tax, delivery, preorder)
│ ├── API Credentials
│ ├── Sync Settings (auto, interval, test mode)
│ └── Carrier Configuration
├── Domains
│ ├── Custom Domain Setup
│ ├── SSL Status
│ └── Verification
├── Notifications
│ ├── Email Notifications
│ ├── Order Alerts
│ └── Marketing Emails
└── Security (future)
├── API Keys
├── Two-Factor Auth
└── Session Management
---
Quick Wins (Can Add Now)
1. Business Info section - Fields exist in Vendor model, just need UI
2. Invoice Settings section - VendorInvoiceSettings model exists
3. Letzshop Feed Settings - Fields exist: letzshop_default_tax_rate, letzshop_delivery_method, etc.
4. Theme/Branding - VendorTheme model exists with colors, fonts, logos
---
Would you like me to implement any of these sections? I'd suggest starting with:
1. Business Info (quick, uses existing fields)
2. Invoice Settings (model exists, high business value)
3. Letzshop Feed Settings (completes marketplace section)
-------->
> let's tackle all of that this seems great. a few comments from my side:
1/ vendor business details should be the one at company level unless overriden (please have a look at current architecture)
2/ email templates is a complex feature because it has to be similar to what we have done at CMS level: admin will have some platform default ones (in multiple languages) and the vendor can override them (but not create any new ones cause it
won't be supported unlike CMS pages where he can create pretty much anything - btw let s make a note that number of pages should be defined in tiers)
3/ custom domain setup: admin should be contacted to setup. same for SSL. custom emails. (this should be readonly for now)
4/ API keys: stripe keys should be there
5/ sections in settings page are not displayed properly: general , localization etc take 2/3 of the screen size

View File

@@ -0,0 +1,102 @@
# alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
"""Add vendor email settings table.
Revision ID: v0a1b2c3d4e5
Revises: u9c0d1e2f3g4
Create Date: 2026-01-05
Changes:
- Create vendor_email_settings table for vendor SMTP/email provider configuration
- Vendors must configure this to send transactional emails
- Premium providers (SendGrid, Mailgun, SES) are tier-gated (Business+)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "v0a1b2c3d4e5"
down_revision = "u9c0d1e2f3g4"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create vendor_email_settings table
op.create_table(
"vendor_email_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vendor_id", sa.Integer(), nullable=False),
# Sender Identity
sa.Column("from_email", sa.String(255), nullable=False),
sa.Column("from_name", sa.String(100), nullable=False),
sa.Column("reply_to_email", sa.String(255), nullable=True),
# Signature/Footer
sa.Column("signature_text", sa.Text(), nullable=True),
sa.Column("signature_html", sa.Text(), nullable=True),
# Provider Configuration
sa.Column("provider", sa.String(20), nullable=False, default="smtp"),
# SMTP Settings
sa.Column("smtp_host", sa.String(255), nullable=True),
sa.Column("smtp_port", sa.Integer(), nullable=True, default=587),
sa.Column("smtp_username", sa.String(255), nullable=True),
sa.Column("smtp_password", sa.String(500), nullable=True),
sa.Column("smtp_use_tls", sa.Boolean(), nullable=False, default=True),
sa.Column("smtp_use_ssl", sa.Boolean(), nullable=False, default=False),
# SendGrid Settings
sa.Column("sendgrid_api_key", sa.String(500), nullable=True),
# Mailgun Settings
sa.Column("mailgun_api_key", sa.String(500), nullable=True),
sa.Column("mailgun_domain", sa.String(255), nullable=True),
# Amazon SES Settings
sa.Column("ses_access_key_id", sa.String(100), nullable=True),
sa.Column("ses_secret_access_key", sa.String(500), nullable=True),
sa.Column("ses_region", sa.String(50), nullable=True, default="eu-west-1"),
# Status & Verification
sa.Column("is_configured", sa.Boolean(), nullable=False, default=False),
sa.Column("is_verified", sa.Boolean(), nullable=False, default=False),
sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("verification_error", sa.Text(), nullable=True),
# Timestamps
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["vendor_id"],
["vendors.id"],
name="fk_vendor_email_settings_vendor_id",
ondelete="CASCADE",
),
sa.UniqueConstraint("vendor_id", name="uq_vendor_email_settings_vendor_id"),
)
# Create indexes
op.create_index(
"ix_vendor_email_settings_id",
"vendor_email_settings",
["id"],
unique=False,
)
op.create_index(
"ix_vendor_email_settings_vendor_id",
"vendor_email_settings",
["vendor_id"],
unique=True,
)
op.create_index(
"idx_vendor_email_settings_configured",
"vendor_email_settings",
["vendor_id", "is_configured"],
)
def downgrade() -> None:
# Drop indexes
op.drop_index("idx_vendor_email_settings_configured", table_name="vendor_email_settings")
op.drop_index("ix_vendor_email_settings_vendor_id", table_name="vendor_email_settings")
op.drop_index("ix_vendor_email_settings_id", table_name="vendor_email_settings")
# Drop table
op.drop_table("vendor_email_settings")

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,23 +258,7 @@
</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>
</button>
</div>
<div class="p-6">
{% 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}"
@@ -312,9 +294,7 @@
</div>
</template>
</div>
</div>
</div>
</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,9 +100,8 @@
</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">
<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>
@@ -170,23 +171,15 @@
</td>
</tr>
</tbody>
</table>
</div>
{% 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>
</div>
<div class="p-4" x-show="selectedCustomer">
{% 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>
@@ -215,7 +208,7 @@
</div>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
<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>
@@ -223,8 +216,7 @@
Send Message
</button>
</div>
</div>
</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,9 +155,8 @@
</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">
<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">
@@ -245,8 +245,7 @@
</td>
</tr>
</tbody>
</table>
</div>
{% 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,16 +15,7 @@
{% 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">
{% call page_header_flex(title='Invoices', subtitle='Create and manage invoices for your orders') %}
<button
@click="openCreateModal()"
:disabled="!hasSettings"
@@ -30,17 +25,8 @@
<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>
{{ 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,19 +156,8 @@
</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>
{% 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>
@@ -270,40 +246,10 @@
</tr>
</template>
</tbody>
</table>
</div>
{% endcall %}
<!-- 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>
{{ simple_pagination(page_var='page', total_var='totalInvoices', limit_var='perPage', on_change='loadInvoices()') }}
</div>
<!-- Settings Tab -->
@@ -522,39 +468,12 @@
</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()">
{% 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"
@@ -578,25 +497,5 @@
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>
</div>
</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,16 +14,7 @@
{% 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">
{% call page_header_flex(title='Letzshop Orders', subtitle='Manage orders from Letzshop marketplace') %}
<button
@click="importOrders()"
:disabled="!status.is_configured || importing"
@@ -39,8 +33,7 @@
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
{% 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,8 +251,8 @@
</tr>
</template>
</tbody>
</table>
</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">
@@ -302,7 +285,6 @@
</span>
</div>
</div>
</div>
<!-- Export Tab -->
<div x-show="activeTab === 'export'" x-transition>
@@ -582,35 +564,7 @@
</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()">
{% 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>
@@ -624,7 +578,7 @@
/>
</div>
<div class="mb-6">
<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>
@@ -643,27 +597,7 @@
<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>
</div>
</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,20 +176,9 @@
</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>
</tr>
</thead>
<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">
@@ -271,11 +242,11 @@
</tr>
</template>
</tbody>
</table>
</div>
{% 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,20 +219,8 @@
</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">
{% 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>
@@ -269,7 +258,7 @@
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<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
@@ -282,8 +271,7 @@
</button>
</div>
</form>
</div>
</div>
{% 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,15 +181,8 @@
</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">
{% 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>
@@ -217,7 +211,7 @@
Note: Full notification settings management coming soon.
</p>
</div>
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
<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>
@@ -228,8 +222,7 @@
Save Settings
</button>
</div>
</div>
</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,9 +161,8 @@
</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">
<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">
@@ -257,8 +257,7 @@
</td>
</tr>
</tbody>
</table>
</div>
{% 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,9 +65,8 @@
</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">
<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>
@@ -156,8 +156,7 @@
</td>
</tr>
</tbody>
</table>
</div>
{% endcall %}
</div>
<!-- Invite Modal -->

View File

@@ -21,6 +21,7 @@ from .customer import Customer, CustomerAddress
from .password_reset_token import PasswordResetToken
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
from .vendor_email_template import VendorEmailTemplate
from .vendor_email_settings import EmailProvider, VendorEmailSettings, PREMIUM_EMAIL_PROVIDERS
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
from .inventory import Inventory
from .inventory_transaction import InventoryTransaction, TransactionType
@@ -114,6 +115,9 @@ __all__ = [
"EmailStatus",
"EmailTemplate",
"VendorEmailTemplate",
"VendorEmailSettings",
"EmailProvider",
"PREMIUM_EMAIL_PROVIDERS",
# Features
"Feature",
"FeatureCategory",

View File

@@ -177,6 +177,14 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Email settings (one-to-one) - vendor SMTP/provider configuration
email_settings = relationship(
"VendorEmailSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",

View File

@@ -0,0 +1,255 @@
# models/database/vendor_email_settings.py
"""
Vendor Email Settings model for vendor-specific email configuration.
This model stores vendor SMTP/email provider settings, enabling vendors to:
- Send emails from their own domain/email address
- Use their own SMTP server or email provider (tier-gated)
- Customize sender name, reply-to address, and signature
Architecture:
- Vendors MUST configure email settings to send transactional emails
- Platform emails (billing, subscription) still use platform settings
- Advanced providers (SendGrid, Mailgun, SES) are tier-gated (Business+)
- "Powered by Wizamart" footer is added for Essential/Professional tiers
"""
import enum
from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class EmailProvider(str, enum.Enum):
"""Supported email providers."""
SMTP = "smtp" # Standard SMTP (all tiers)
SENDGRID = "sendgrid" # SendGrid API (Business+ tier)
MAILGUN = "mailgun" # Mailgun API (Business+ tier)
SES = "ses" # Amazon SES (Business+ tier)
# Providers that require Business+ tier
PREMIUM_EMAIL_PROVIDERS = {
EmailProvider.SENDGRID,
EmailProvider.MAILGUN,
EmailProvider.SES,
}
class VendorEmailSettings(Base, TimestampMixin):
"""
Vendor email configuration for sending transactional emails.
This is a one-to-one relationship with Vendor.
Vendors must configure this to send emails to their customers.
"""
__tablename__ = "vendor_email_settings"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
# =========================================================================
# Sender Identity (Required)
# =========================================================================
from_email = Column(String(255), nullable=False) # e.g., orders@vendorshop.lu
from_name = Column(String(100), nullable=False) # e.g., "VendorShop"
reply_to_email = Column(String(255), nullable=True) # Optional reply-to address
# =========================================================================
# Email Signature/Footer (Optional)
# =========================================================================
signature_text = Column(Text, nullable=True) # Plain text signature
signature_html = Column(Text, nullable=True) # HTML signature (footer)
# =========================================================================
# Provider Configuration
# =========================================================================
provider = Column(
String(20),
default=EmailProvider.SMTP.value,
nullable=False,
)
# =========================================================================
# SMTP Settings (used when provider=smtp)
# =========================================================================
smtp_host = Column(String(255), nullable=True)
smtp_port = Column(Integer, nullable=True, default=587)
smtp_username = Column(String(255), nullable=True)
smtp_password = Column(String(500), nullable=True) # Encrypted at rest
smtp_use_tls = Column(Boolean, default=True, nullable=False)
smtp_use_ssl = Column(Boolean, default=False, nullable=False) # For port 465
# =========================================================================
# SendGrid Settings (used when provider=sendgrid, Business+ tier)
# =========================================================================
sendgrid_api_key = Column(String(500), nullable=True) # Encrypted at rest
# =========================================================================
# Mailgun Settings (used when provider=mailgun, Business+ tier)
# =========================================================================
mailgun_api_key = Column(String(500), nullable=True) # Encrypted at rest
mailgun_domain = Column(String(255), nullable=True)
# =========================================================================
# Amazon SES Settings (used when provider=ses, Business+ tier)
# =========================================================================
ses_access_key_id = Column(String(100), nullable=True)
ses_secret_access_key = Column(String(500), nullable=True) # Encrypted at rest
ses_region = Column(String(50), nullable=True, default="eu-west-1")
# =========================================================================
# Status & Verification
# =========================================================================
is_configured = Column(Boolean, default=False, nullable=False) # Has complete config
is_verified = Column(Boolean, default=False, nullable=False) # Test email succeeded
last_verified_at = Column(DateTime(timezone=True), nullable=True)
verification_error = Column(Text, nullable=True) # Last verification error message
# =========================================================================
# Relationship
# =========================================================================
vendor = relationship("Vendor", back_populates="email_settings")
# =========================================================================
# Indexes
# =========================================================================
__table_args__ = (
Index("idx_vendor_email_settings_configured", "vendor_id", "is_configured"),
)
def __repr__(self) -> str:
return f"<VendorEmailSettings(vendor_id={self.vendor_id}, provider='{self.provider}', from='{self.from_email}')>"
# =========================================================================
# Helper Methods
# =========================================================================
def is_smtp_configured(self) -> bool:
"""Check if SMTP settings are complete."""
if self.provider != EmailProvider.SMTP.value:
return False
return bool(
self.smtp_host
and self.smtp_port
and self.smtp_username
and self.smtp_password
)
def is_sendgrid_configured(self) -> bool:
"""Check if SendGrid settings are complete."""
if self.provider != EmailProvider.SENDGRID.value:
return False
return bool(self.sendgrid_api_key)
def is_mailgun_configured(self) -> bool:
"""Check if Mailgun settings are complete."""
if self.provider != EmailProvider.MAILGUN.value:
return False
return bool(self.mailgun_api_key and self.mailgun_domain)
def is_ses_configured(self) -> bool:
"""Check if Amazon SES settings are complete."""
if self.provider != EmailProvider.SES.value:
return False
return bool(
self.ses_access_key_id
and self.ses_secret_access_key
and self.ses_region
)
def is_provider_configured(self) -> bool:
"""Check if the current provider is fully configured."""
provider_checks = {
EmailProvider.SMTP.value: self.is_smtp_configured,
EmailProvider.SENDGRID.value: self.is_sendgrid_configured,
EmailProvider.MAILGUN.value: self.is_mailgun_configured,
EmailProvider.SES.value: self.is_ses_configured,
}
check_fn = provider_checks.get(self.provider)
return check_fn() if check_fn else False
def is_fully_configured(self) -> bool:
"""Check if email settings are fully configured (identity + provider)."""
return bool(
self.from_email
and self.from_name
and self.is_provider_configured()
)
def update_configuration_status(self) -> None:
"""Update the is_configured flag based on current settings."""
self.is_configured = self.is_fully_configured()
def mark_verified(self) -> None:
"""Mark settings as verified (test email succeeded)."""
self.is_verified = True
self.last_verified_at = datetime.now(UTC)
self.verification_error = None
def mark_verification_failed(self, error: str) -> None:
"""Mark settings as verification failed."""
self.is_verified = False
self.verification_error = error
def requires_premium_tier(self) -> bool:
"""Check if current provider requires Business+ tier."""
return self.provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]
def to_dict(self) -> dict:
"""Convert to dictionary for API responses (excludes sensitive data)."""
return {
"id": self.id,
"vendor_id": self.vendor_id,
"from_email": self.from_email,
"from_name": self.from_name,
"reply_to_email": self.reply_to_email,
"signature_text": self.signature_text,
"signature_html": self.signature_html,
"provider": self.provider,
# SMTP (mask password)
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"smtp_username": self.smtp_username,
"smtp_password_set": bool(self.smtp_password),
"smtp_use_tls": self.smtp_use_tls,
"smtp_use_ssl": self.smtp_use_ssl,
# SendGrid (mask API key)
"sendgrid_api_key_set": bool(self.sendgrid_api_key),
# Mailgun (mask API key)
"mailgun_api_key_set": bool(self.mailgun_api_key),
"mailgun_domain": self.mailgun_domain,
# SES (mask credentials)
"ses_access_key_id_set": bool(self.ses_access_key_id),
"ses_region": self.ses_region,
# Status
"is_configured": self.is_configured,
"is_verified": self.is_verified,
"last_verified_at": self.last_verified_at.isoformat() if self.last_verified_at else None,
"verification_error": self.verification_error,
"requires_premium_tier": self.requires_premium_tier(),
# Timestamps
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

549
scripts/install.py Executable file
View File

@@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""
Wizamart Platform Installation Script
This script handles first-time installation of the Wizamart platform:
1. Validates Python version and dependencies
2. Checks environment configuration (.env file)
3. Validates required settings for production
4. Runs database migrations
5. Initializes essential data (admin, settings, CMS, email templates)
6. Provides a configuration status report
Usage:
make install
# or directly:
python scripts/install.py
This script is idempotent - safe to run multiple times.
"""
import os
import subprocess
import sys
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# =============================================================================
# CONSOLE OUTPUT HELPERS
# =============================================================================
class Colors:
"""ANSI color codes for terminal output."""
HEADER = "\033[95m"
BLUE = "\033[94m"
CYAN = "\033[96m"
GREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
def print_header(text: str):
"""Print a bold header."""
print(f"\n{Colors.BOLD}{Colors.HEADER}{'=' * 70}{Colors.ENDC}")
print(f"{Colors.BOLD}{Colors.HEADER} {text}{Colors.ENDC}")
print(f"{Colors.BOLD}{Colors.HEADER}{'=' * 70}{Colors.ENDC}")
def print_section(text: str):
"""Print a section header."""
print(f"\n{Colors.BOLD}{Colors.CYAN}[*] {text}{Colors.ENDC}")
def print_step(step: int, total: int, text: str):
"""Print a step indicator."""
print(f"\n{Colors.BLUE}[{step}/{total}] {text}{Colors.ENDC}")
def print_success(text: str):
"""Print success message."""
print(f" {Colors.GREEN}{Colors.ENDC} {text}")
def print_warning(text: str):
"""Print warning message."""
print(f" {Colors.WARNING}{Colors.ENDC} {text}")
def print_error(text: str):
"""Print error message."""
print(f" {Colors.FAIL}{Colors.ENDC} {text}")
def print_info(text: str):
"""Print info message."""
print(f" {Colors.DIM}{Colors.ENDC} {text}")
# =============================================================================
# VALIDATION FUNCTIONS
# =============================================================================
def check_python_version() -> bool:
"""Check if Python version is supported."""
major, minor = sys.version_info[:2]
if major < 3 or (major == 3 and minor < 11):
print_error(f"Python 3.11+ required. Found: {major}.{minor}")
return False
print_success(f"Python version: {major}.{minor}")
return True
def check_env_file() -> tuple[bool, dict]:
"""Check if .env file exists and load it."""
env_path = project_root / ".env"
env_example_path = project_root / ".env.example"
if not env_path.exists():
if env_example_path.exists():
print_warning(".env file not found")
print_info("Copy .env.example to .env and configure it:")
print_info(" cp .env.example .env")
return False, {}
else:
print_warning("Neither .env nor .env.example found")
return False, {}
print_success(".env file found")
# Load .env manually to check values
env_vars = {}
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
# Remove quotes if present
value = value.strip().strip("'\"")
env_vars[key.strip()] = value
return True, env_vars
def validate_configuration(env_vars: dict) -> dict:
"""
Validate configuration and return status for each category.
Returns dict with categories and their status:
{
"category": {
"status": "ok" | "warning" | "missing",
"message": "...",
"items": [...]
}
}
"""
results = {}
# -------------------------------------------------------------------------
# Database
# -------------------------------------------------------------------------
db_url = env_vars.get("DATABASE_URL", "")
if db_url and "sqlite" not in db_url.lower():
results["database"] = {
"status": "ok",
"message": "Production database configured",
"items": [f"URL: {db_url[:50]}..."]
}
elif db_url and "sqlite" in db_url.lower():
results["database"] = {
"status": "warning",
"message": "SQLite database (OK for development)",
"items": ["Consider PostgreSQL for production"]
}
else:
results["database"] = {
"status": "ok",
"message": "Using default SQLite database",
"items": ["Configure DATABASE_URL for production"]
}
# -------------------------------------------------------------------------
# Security (JWT)
# -------------------------------------------------------------------------
jwt_key = env_vars.get("JWT_SECRET_KEY", "")
if jwt_key and jwt_key != "change-this-in-production" and len(jwt_key) >= 32:
results["security"] = {
"status": "ok",
"message": "JWT secret configured",
"items": ["Secret key is set and sufficiently long"]
}
elif jwt_key and jwt_key == "change-this-in-production":
results["security"] = {
"status": "warning",
"message": "Using default JWT secret",
"items": [
"CRITICAL: Change JWT_SECRET_KEY for production!",
"Use: openssl rand -hex 32"
]
}
else:
results["security"] = {
"status": "warning",
"message": "JWT secret not explicitly set",
"items": ["Set JWT_SECRET_KEY in .env"]
}
# -------------------------------------------------------------------------
# Admin Credentials
# -------------------------------------------------------------------------
admin_pass = env_vars.get("ADMIN_PASSWORD", "admin123")
admin_email = env_vars.get("ADMIN_EMAIL", "admin@wizamart.com")
if admin_pass != "admin123":
results["admin"] = {
"status": "ok",
"message": "Admin credentials configured",
"items": [f"Email: {admin_email}"]
}
else:
results["admin"] = {
"status": "warning",
"message": "Using default admin password",
"items": [
"Set ADMIN_PASSWORD in .env",
"Change immediately after first login"
]
}
# -------------------------------------------------------------------------
# Stripe Billing
# -------------------------------------------------------------------------
stripe_secret = env_vars.get("STRIPE_SECRET_KEY", "")
stripe_pub = env_vars.get("STRIPE_PUBLISHABLE_KEY", "")
stripe_webhook = env_vars.get("STRIPE_WEBHOOK_SECRET", "")
if stripe_secret and stripe_pub:
if stripe_secret.startswith("sk_live_"):
results["stripe"] = {
"status": "ok",
"message": "Stripe LIVE mode configured",
"items": [
"Live secret key set",
f"Webhook secret: {'configured' if stripe_webhook else 'NOT SET'}"
]
}
elif stripe_secret.startswith("sk_test_"):
results["stripe"] = {
"status": "warning",
"message": "Stripe TEST mode configured",
"items": [
"Using test keys (OK for development)",
"Switch to live keys for production"
]
}
else:
results["stripe"] = {
"status": "warning",
"message": "Stripe keys set but format unclear",
"items": ["Verify STRIPE_SECRET_KEY format"]
}
else:
results["stripe"] = {
"status": "missing",
"message": "Stripe not configured",
"items": [
"Set STRIPE_SECRET_KEY",
"Set STRIPE_PUBLISHABLE_KEY",
"Set STRIPE_WEBHOOK_SECRET",
"Billing features will not work!"
]
}
# -------------------------------------------------------------------------
# Email Configuration
# -------------------------------------------------------------------------
email_provider = env_vars.get("EMAIL_PROVIDER", "smtp")
email_enabled = env_vars.get("EMAIL_ENABLED", "true").lower() == "true"
email_debug = env_vars.get("EMAIL_DEBUG", "false").lower() == "true"
if not email_enabled:
results["email"] = {
"status": "warning",
"message": "Email disabled",
"items": ["EMAIL_ENABLED=false - no emails will be sent"]
}
elif email_debug:
results["email"] = {
"status": "warning",
"message": "Email in debug mode",
"items": ["EMAIL_DEBUG=true - emails logged, not sent"]
}
elif email_provider == "smtp":
smtp_host = env_vars.get("SMTP_HOST", "localhost")
smtp_user = env_vars.get("SMTP_USER", "")
if smtp_host != "localhost" and smtp_user:
results["email"] = {
"status": "ok",
"message": f"SMTP configured ({smtp_host})",
"items": [f"User: {smtp_user}"]
}
else:
results["email"] = {
"status": "warning",
"message": "SMTP using defaults",
"items": [
"Configure SMTP_HOST, SMTP_USER, SMTP_PASSWORD",
"Or use EMAIL_DEBUG=true for development"
]
}
elif email_provider == "sendgrid":
api_key = env_vars.get("SENDGRID_API_KEY", "")
if api_key:
results["email"] = {
"status": "ok",
"message": "SendGrid configured",
"items": ["API key set"]
}
else:
results["email"] = {
"status": "missing",
"message": "SendGrid selected but not configured",
"items": ["Set SENDGRID_API_KEY"]
}
elif email_provider == "mailgun":
api_key = env_vars.get("MAILGUN_API_KEY", "")
domain = env_vars.get("MAILGUN_DOMAIN", "")
if api_key and domain:
results["email"] = {
"status": "ok",
"message": f"Mailgun configured ({domain})",
"items": ["API key and domain set"]
}
else:
results["email"] = {
"status": "missing",
"message": "Mailgun selected but not configured",
"items": ["Set MAILGUN_API_KEY and MAILGUN_DOMAIN"]
}
elif email_provider == "ses":
access_key = env_vars.get("AWS_ACCESS_KEY_ID", "")
secret_key = env_vars.get("AWS_SECRET_ACCESS_KEY", "")
if access_key and secret_key:
results["email"] = {
"status": "ok",
"message": "Amazon SES configured",
"items": [f"Region: {env_vars.get('AWS_REGION', 'eu-west-1')}"]
}
else:
results["email"] = {
"status": "missing",
"message": "SES selected but not configured",
"items": ["Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY"]
}
# -------------------------------------------------------------------------
# Platform Domain
# -------------------------------------------------------------------------
domain = env_vars.get("PLATFORM_DOMAIN", "wizamart.com")
if domain != "wizamart.com":
results["domain"] = {
"status": "ok",
"message": f"Custom domain: {domain}",
"items": []
}
else:
results["domain"] = {
"status": "warning",
"message": "Using default domain",
"items": ["Set PLATFORM_DOMAIN for your deployment"]
}
return results
def print_configuration_report(config_status: dict):
"""Print a formatted configuration status report."""
print_section("Configuration Status")
ok_count = 0
warning_count = 0
missing_count = 0
for category, status in config_status.items():
if status["status"] == "ok":
icon = f"{Colors.GREEN}{Colors.ENDC}"
ok_count += 1
elif status["status"] == "warning":
icon = f"{Colors.WARNING}{Colors.ENDC}"
warning_count += 1
else:
icon = f"{Colors.FAIL}{Colors.ENDC}"
missing_count += 1
print(f"\n {icon} {Colors.BOLD}{category.upper()}{Colors.ENDC}: {status['message']}")
for item in status.get("items", []):
print(f" {Colors.DIM}{item}{Colors.ENDC}")
print(f"\n {Colors.DIM}" * 35 + Colors.ENDC)
print(f" Summary: {Colors.GREEN}{ok_count} OK{Colors.ENDC}, "
f"{Colors.WARNING}{warning_count} warnings{Colors.ENDC}, "
f"{Colors.FAIL}{missing_count} missing{Colors.ENDC}")
return ok_count, warning_count, missing_count
# =============================================================================
# INSTALLATION STEPS
# =============================================================================
def run_migrations() -> bool:
"""Run database migrations."""
try:
result = subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
print_success("Migrations completed successfully")
return True
else:
print_error("Migration failed")
if result.stderr:
print_info(result.stderr[:500])
return False
except Exception as e:
print_error(f"Failed to run migrations: {e}")
return False
def run_init_script(script_name: str, description: str) -> bool:
"""Run an initialization script."""
script_path = project_root / "scripts" / script_name
try:
result = subprocess.run(
[sys.executable, str(script_path)],
cwd=project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
print_success(description)
return True
else:
print_error(f"Failed: {description}")
if result.stderr:
print_info(result.stderr[:300])
return False
except Exception as e:
print_error(f"Error running {script_name}: {e}")
return False
# =============================================================================
# MAIN INSTALLATION FLOW
# =============================================================================
def main():
"""Main installation flow."""
print_header("WIZAMART PLATFORM INSTALLATION")
# -------------------------------------------------------------------------
# Step 1: Pre-flight checks
# -------------------------------------------------------------------------
print_step(1, 4, "Pre-flight checks")
if not check_python_version():
sys.exit(1)
env_exists, env_vars = check_env_file()
if not env_exists:
print()
print_warning("Installation can continue with defaults,")
print_warning("but you should configure .env before going to production.")
print()
response = input("Continue with default configuration? [y/N]: ")
if response.lower() != "y":
print("\nInstallation cancelled. Please configure .env first.")
sys.exit(0)
env_vars = {}
# -------------------------------------------------------------------------
# Step 2: Validate configuration
# -------------------------------------------------------------------------
print_step(2, 4, "Validating configuration")
config_status = validate_configuration(env_vars)
ok_count, warning_count, missing_count = print_configuration_report(config_status)
if missing_count > 0:
print()
print_warning(f"{missing_count} critical configuration(s) missing!")
print_warning("The platform may not function correctly.")
response = input("\nContinue anyway? [y/N]: ")
if response.lower() != "y":
print("\nInstallation cancelled. Please fix configuration first.")
sys.exit(0)
# -------------------------------------------------------------------------
# Step 3: Database setup
# -------------------------------------------------------------------------
print_step(3, 4, "Database setup")
print_info("Running database migrations...")
if not run_migrations():
print_error("Failed to run migrations. Cannot continue.")
sys.exit(1)
# -------------------------------------------------------------------------
# Step 4: Initialize platform data
# -------------------------------------------------------------------------
print_step(4, 4, "Initializing platform data")
init_scripts = [
("init_production.py", "Admin user and platform settings"),
("init_log_settings.py", "Log settings"),
("create_default_content_pages.py", "Default CMS pages"),
("create_platform_pages.py", "Platform pages and landing"),
("seed_email_templates.py", "Email templates"),
]
all_success = True
for script, description in init_scripts:
if not run_init_script(script, description):
all_success = False
# -------------------------------------------------------------------------
# Summary
# -------------------------------------------------------------------------
print_header("INSTALLATION COMPLETE")
if all_success:
print(f"\n {Colors.GREEN}✓ Platform installed successfully!{Colors.ENDC}")
else:
print(f"\n {Colors.WARNING}⚠ Installation completed with some errors{Colors.ENDC}")
print(f"\n {Colors.BOLD}Next Steps:{Colors.ENDC}")
print(" 1. Review any warnings above")
if warning_count > 0 or missing_count > 0:
print(" 2. Update .env with production values")
print(" 3. Run 'make install' again to verify")
else:
print(" 2. Start the application: make dev")
print(f"\n {Colors.BOLD}Admin Login:{Colors.ENDC}")
admin_email = env_vars.get("ADMIN_EMAIL", "admin@wizamart.com")
print(f" URL: /admin/login")
print(f" Email: {admin_email}")
print(f" Password: {'(configured in .env)' if env_vars.get('ADMIN_PASSWORD') else 'admin123'}")
if not env_vars.get("ADMIN_PASSWORD"):
print(f"\n {Colors.WARNING}⚠ CHANGE DEFAULT PASSWORD IMMEDIATELY!{Colors.ENDC}")
print(f"\n {Colors.BOLD}For demo data (development only):{Colors.ENDC}")
print(" make seed-demo")
print()
if __name__ == "__main__":
main()

View File

@@ -39,6 +39,45 @@ function adminSettings() {
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
emailSettings: {
provider: 'smtp',
from_email: '',
from_name: '',
reply_to: '',
smtp_host: '',
smtp_port: 587,
smtp_user: '',
mailgun_domain: '',
aws_region: '',
debug: false,
enabled: true,
is_configured: false,
has_db_overrides: false
},
// Email editing form (separate from display to track changes)
emailForm: {
provider: 'smtp',
from_email: '',
from_name: '',
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',
enabled: true,
debug: false
},
emailEditMode: false,
testEmailAddress: '',
sendingTestEmail: false,
async init() {
// Guard against multiple initialization
@@ -50,7 +89,8 @@ function adminSettings() {
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings()
this.loadShippingSettings(),
this.loadEmailSettings()
]);
} catch (error) {
settingsLog.error('Init failed:', error);
@@ -64,7 +104,8 @@ function adminSettings() {
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings()
this.loadShippingSettings(),
this.loadEmailSettings()
]);
},
@@ -266,6 +307,190 @@ function adminSettings() {
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
if (!prefix || !shipmentNumber) return null;
return prefix + shipmentNumber;
},
// =====================================================================
// EMAIL SETTINGS
// =====================================================================
async loadEmailSettings() {
try {
const data = await apiClient.get('/admin/settings/email/status');
this.emailSettings = {
provider: data.provider || 'smtp',
from_email: data.from_email || '',
from_name: data.from_name || '',
reply_to: data.reply_to || '',
smtp_host: data.smtp_host || '',
smtp_port: data.smtp_port || 587,
smtp_user: data.smtp_user || '',
mailgun_domain: data.mailgun_domain || '',
aws_region: data.aws_region || '',
debug: data.debug || false,
enabled: data.enabled !== false,
is_configured: data.is_configured || false,
has_db_overrides: data.has_db_overrides || false
};
// Populate edit form with current values
this.populateEmailForm();
settingsLog.info('Email settings loaded:', this.emailSettings);
} catch (error) {
settingsLog.error('Failed to load email settings:', error);
// Use defaults on error
}
},
populateEmailForm() {
// Copy current settings to form (passwords are not loaded from API)
this.emailForm = {
provider: this.emailSettings.provider,
from_email: this.emailSettings.from_email,
from_name: this.emailSettings.from_name,
reply_to: this.emailSettings.reply_to || '',
smtp_host: this.emailSettings.smtp_host || '',
smtp_port: this.emailSettings.smtp_port || 587,
smtp_user: this.emailSettings.smtp_user || '',
smtp_password: '', // Never populated from API
smtp_use_tls: true,
smtp_use_ssl: false,
sendgrid_api_key: '',
mailgun_api_key: '',
mailgun_domain: this.emailSettings.mailgun_domain || '',
aws_access_key_id: '',
aws_secret_access_key: '',
aws_region: this.emailSettings.aws_region || 'eu-west-1',
enabled: this.emailSettings.enabled,
debug: this.emailSettings.debug
};
},
enableEmailEditing() {
this.emailEditMode = true;
this.populateEmailForm();
},
cancelEmailEditing() {
this.emailEditMode = false;
this.populateEmailForm();
},
async saveEmailSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Only send non-empty values to update
const payload = {};
// Always send these core fields
if (this.emailForm.provider) payload.provider = this.emailForm.provider;
if (this.emailForm.from_email) payload.from_email = this.emailForm.from_email;
if (this.emailForm.from_name) payload.from_name = this.emailForm.from_name;
if (this.emailForm.reply_to) payload.reply_to = this.emailForm.reply_to;
payload.enabled = this.emailForm.enabled;
payload.debug = this.emailForm.debug;
// Provider-specific fields
if (this.emailForm.provider === 'smtp') {
if (this.emailForm.smtp_host) payload.smtp_host = this.emailForm.smtp_host;
if (this.emailForm.smtp_port) payload.smtp_port = this.emailForm.smtp_port;
if (this.emailForm.smtp_user) payload.smtp_user = this.emailForm.smtp_user;
if (this.emailForm.smtp_password) payload.smtp_password = this.emailForm.smtp_password;
payload.smtp_use_tls = this.emailForm.smtp_use_tls;
payload.smtp_use_ssl = this.emailForm.smtp_use_ssl;
} else if (this.emailForm.provider === 'sendgrid') {
if (this.emailForm.sendgrid_api_key) payload.sendgrid_api_key = this.emailForm.sendgrid_api_key;
} else if (this.emailForm.provider === 'mailgun') {
if (this.emailForm.mailgun_api_key) payload.mailgun_api_key = this.emailForm.mailgun_api_key;
if (this.emailForm.mailgun_domain) payload.mailgun_domain = this.emailForm.mailgun_domain;
} else if (this.emailForm.provider === 'ses') {
if (this.emailForm.aws_access_key_id) payload.aws_access_key_id = this.emailForm.aws_access_key_id;
if (this.emailForm.aws_secret_access_key) payload.aws_secret_access_key = this.emailForm.aws_secret_access_key;
if (this.emailForm.aws_region) payload.aws_region = this.emailForm.aws_region;
}
const data = await apiClient.put('/admin/settings/email/settings', payload);
this.successMessage = data.message || 'Email settings saved successfully';
this.emailEditMode = false;
// Reload to get updated status
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save email settings:', error);
this.error = error.message || 'Failed to save email settings';
} finally {
this.saving = false;
}
},
async resetEmailSettings() {
if (!confirm('This will reset all email settings to use .env defaults. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.delete('/admin/settings/email/settings');
this.successMessage = data.message || 'Email settings reset to defaults';
this.emailEditMode = false;
// Reload to get .env values
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings reset successfully');
} catch (error) {
settingsLog.error('Failed to reset email settings:', error);
this.error = error.message || 'Failed to reset email settings';
} finally {
this.saving = false;
}
},
async sendTestEmail() {
if (!this.testEmailAddress) {
this.error = 'Please enter a test email address';
return;
}
this.sendingTestEmail = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.post('/admin/settings/email/test', {
to_email: this.testEmailAddress
});
if (data.success) {
this.successMessage = `Test email sent to ${this.testEmailAddress}`;
setTimeout(() => {
this.successMessage = null;
}, 5000);
} else {
this.error = data.message || 'Failed to send test email';
}
} catch (error) {
settingsLog.error('Failed to send test email:', error);
this.error = error.message || 'Failed to send test email';
} finally {
this.sendingTestEmail = false;
}
}
};
}

View File

@@ -166,7 +166,8 @@ function vendorAnalytics() {
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString();
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return num.toLocaleString(locale);
},
/**

View File

@@ -189,7 +189,8 @@ function vendorBilling() {
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -199,9 +200,11 @@ function vendorBilling() {
formatCurrency(cents, currency = 'EUR') {
if (cents === null || cents === undefined) return '-';
const amount = cents / 100;
return new Intl.NumberFormat('en-US', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currencyCode = window.VENDOR_CONFIG?.currency || currency;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
currency: currencyCode
}).format(amount);
}
};

View File

@@ -264,7 +264,8 @@ function vendorCustomers() {
*/
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -276,9 +277,11 @@ function vendorCustomers() {
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR'
currency: currency
}).format(cents / 100);
},

View File

@@ -107,16 +107,19 @@ function vendorDashboard() {
},
formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR'
currency: currency
}).format(amount || 0);
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -219,3 +219,52 @@ function languageSelector(currentLang, enabledLanguages) {
}
window.languageSelector = languageSelector;
/**
* Email Settings Warning Component
* Shows warning banner when vendor email settings are not configured
*
* Usage in template:
* <div x-data="emailSettingsWarning()" x-show="showWarning">...</div>
*/
function emailSettingsWarning() {
return {
showWarning: false,
loading: true,
vendorCode: null,
async init() {
// Get vendor code from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Skip if we're on the settings page (to avoid showing banner on config page)
if (path.includes('/settings')) {
this.loading = false;
return;
}
// Check email settings status
await this.checkEmailStatus();
},
async checkEmailStatus() {
try {
const response = await apiClient.get('/vendor/email-settings/status');
// Show warning if not configured
this.showWarning = !response.is_configured;
} catch (error) {
// Don't show warning on error (might be 401, etc.)
console.debug('[EmailWarning] Failed to check email status:', error);
this.showWarning = false;
} finally {
this.loading = false;
}
}
};
}
window.emailSettingsWarning = emailSettingsWarning;

View File

@@ -353,7 +353,8 @@ function vendorInventory() {
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Intl.NumberFormat(locale).format(num);
},
/**

View File

@@ -379,7 +379,8 @@ function vendorInvoices() {
formatDate(dateStr) {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
day: '2-digit',
month: 'short',
year: 'numeric'
@@ -392,9 +393,11 @@ function vendorInvoices() {
formatCurrency(cents, currency = 'EUR') {
if (cents === null || cents === undefined) return 'N/A';
const amount = cents / 100;
return new Intl.NumberFormat('de-LU', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currencyCode = window.VENDOR_CONFIG?.currency || currency;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
currency: currencyCode
}).format(amount);
}
};

View File

@@ -416,7 +416,8 @@ function vendorLetzshop() {
formatDate(dateStr) {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale) + ' ' + date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
},
/**

View File

@@ -262,7 +262,8 @@ function vendorMarketplace() {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -23,6 +23,7 @@ function vendorMessages(initialConversationId = null) {
loadingMessages: false,
sendingMessage: false,
creatingConversation: false,
error: '',
// Conversations state
conversations: [],
@@ -384,7 +385,8 @@ function vendorMessages(initialConversationId = null) {
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 172800) return 'Yesterday';
return date.toLocaleDateString();
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale);
},
formatTime(dateString) {
@@ -392,11 +394,12 @@ function vendorMessages(initialConversationId = null) {
const date = new Date(dateString);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return date.toLocaleString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
};
}

View File

@@ -250,7 +250,8 @@ function vendorNotifications() {
if (diff < 172800) return 'Yesterday';
// Show full date for older dates
return date.toLocaleDateString();
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale);
},
// Pagination methods

View File

@@ -188,9 +188,11 @@ function vendorOrderDetail() {
*/
formatPrice(cents) {
if (cents === null || cents === undefined) return '-';
return new Intl.NumberFormat('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR'
currency: currency
}).format(cents / 100);
},
@@ -199,7 +201,8 @@ function vendorOrderDetail() {
*/
formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -312,9 +312,11 @@ function vendorOrders() {
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR'
currency: currency
}).format(cents / 100);
},
@@ -323,7 +325,8 @@ function vendorOrders() {
*/
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -321,9 +321,11 @@ function vendorProducts() {
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR'
currency: currency
}).format(cents / 100);
},

View File

@@ -40,7 +40,8 @@ function vendorSettings() {
{ id: 'branding', label: 'Branding', icon: 'color-swatch' },
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
{ id: 'api', label: 'API & Payments', icon: 'key' },
{ id: 'notifications', label: 'Notifications', icon: 'bell' }
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
{ id: 'email', label: 'Email', icon: 'envelope' }
],
// Forms for different sections
@@ -95,6 +96,38 @@ function vendorSettings() {
storefront_locale: ''
},
// Email settings
emailSettings: null,
emailSettingsLoading: false,
emailProviders: [],
emailForm: {
from_email: '',
from_name: '',
reply_to_email: '',
signature_text: '',
signature_html: '',
provider: 'smtp',
// SMTP
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_use_ssl: false,
// SendGrid
sendgrid_api_key: '',
// Mailgun
mailgun_api_key: '',
mailgun_domain: '',
// SES
ses_access_key_id: '',
ses_secret_access_key: '',
ses_region: 'eu-west-1'
},
testEmailAddress: '',
sendingTestEmail: false,
hasEmailChanges: false,
// Track changes per section
hasChanges: false,
hasBusinessChanges: false,
@@ -383,6 +416,161 @@ function vendorSettings() {
} finally {
this.saving = false;
}
},
// =====================================================================
// EMAIL SETTINGS
// =====================================================================
/**
* Load email settings when email tab is activated
*/
async loadEmailSettings() {
if (this.emailSettings !== null) {
return; // Already loaded
}
this.emailSettingsLoading = true;
try {
// Load settings and providers in parallel
const [settingsResponse, providersResponse] = await Promise.all([
apiClient.get('/vendor/email-settings'),
apiClient.get('/vendor/email-settings/providers')
]);
this.emailProviders = providersResponse.providers || [];
if (settingsResponse.configured && settingsResponse.settings) {
this.emailSettings = settingsResponse.settings;
this.populateEmailForm(settingsResponse.settings);
} else {
this.emailSettings = { is_configured: false, is_verified: false };
}
vendorSettingsLog.info('Loaded email settings');
} catch (error) {
vendorSettingsLog.error('Failed to load email settings:', error);
Utils.showToast('Failed to load email settings', 'error');
} finally {
this.emailSettingsLoading = false;
}
},
/**
* Populate email form from settings
*/
populateEmailForm(settings) {
this.emailForm = {
from_email: settings.from_email || '',
from_name: settings.from_name || '',
reply_to_email: settings.reply_to_email || '',
signature_text: settings.signature_text || '',
signature_html: settings.signature_html || '',
provider: settings.provider || 'smtp',
// SMTP - don't populate password
smtp_host: settings.smtp_host || '',
smtp_port: settings.smtp_port || 587,
smtp_username: settings.smtp_username || '',
smtp_password: '', // Never populate password
smtp_use_tls: settings.smtp_use_tls !== false,
smtp_use_ssl: settings.smtp_use_ssl || false,
// SendGrid - don't populate API key
sendgrid_api_key: '',
// Mailgun - don't populate API key
mailgun_api_key: '',
mailgun_domain: settings.mailgun_domain || '',
// SES - don't populate secrets
ses_access_key_id: '',
ses_secret_access_key: '',
ses_region: settings.ses_region || 'eu-west-1'
};
this.hasEmailChanges = false;
},
/**
* Mark email form as changed
*/
markEmailChanged() {
this.hasEmailChanges = true;
},
/**
* Save email settings
*/
async saveEmailSettings() {
// Validate required fields
if (!this.emailForm.from_email || !this.emailForm.from_name) {
Utils.showToast('From Email and From Name are required', 'error');
return;
}
this.saving = true;
try {
const response = await apiClient.put('/vendor/email-settings', this.emailForm);
if (response.success) {
Utils.showToast('Email settings saved', 'success');
vendorSettingsLog.info('Email settings updated');
// Update local state
this.emailSettings = response.settings;
this.hasEmailChanges = false;
} else {
Utils.showToast(response.message || 'Failed to save email settings', 'error');
}
} catch (error) {
vendorSettingsLog.error('Failed to save email settings:', error);
Utils.showToast(error.message || 'Failed to save email settings', 'error');
} finally {
this.saving = false;
}
},
/**
* Send test email
*/
async sendTestEmail() {
if (!this.testEmailAddress) {
Utils.showToast('Please enter a test email address', 'error');
return;
}
if (!this.emailSettings?.is_configured) {
Utils.showToast('Please save your email settings first', 'error');
return;
}
this.sendingTestEmail = true;
try {
const response = await apiClient.post('/vendor/email-settings/verify', {
test_email: this.testEmailAddress
});
if (response.success) {
Utils.showToast('Test email sent! Check your inbox.', 'success');
// Update verification status
this.emailSettings.is_verified = true;
} else {
Utils.showToast(response.message || 'Failed to send test email', 'error');
}
} catch (error) {
vendorSettingsLog.error('Failed to send test email:', error);
Utils.showToast(error.message || 'Failed to send test email', 'error');
} finally {
this.sendingTestEmail = false;
}
},
/**
* Switch active section - with email loading hook
*/
setSection(sectionId) {
this.activeSection = sectionId;
// Load email settings when email tab is activated
if (sectionId === 'email' && this.emailSettings === null) {
this.loadEmailSettings();
}
}
};
}

View File

@@ -264,7 +264,8 @@ function vendorTeam() {
*/
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'