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:
55
Makefile
55
Makefile
@@ -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
123
TODO-VENDOR-SECTION
Normal 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
|
||||
102
alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
Normal file
102
alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
Normal 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")
|
||||
@@ -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)}",
|
||||
)
|
||||
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -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.)
|
||||
|
||||
36
app/api/v1/vendor/content_pages.py
vendored
36
app/api/v1/vendor/content_pages.py
vendored
@@ -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
225
app/api/v1/vendor/email_settings.py
vendored
Normal 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")
|
||||
@@ -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}")
|
||||
|
||||
444
app/services/vendor_email_settings_service.py
Normal file
444
app/services/vendor_email_settings_service.py
Normal 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)
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">​</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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
30
app/templates/vendor/billing.html
vendored
30
app/templates/vendor/billing.html
vendored
@@ -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"
|
||||
|
||||
26
app/templates/vendor/customers.html
vendored
26
app/templates/vendor/customers.html
vendored
@@ -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">
|
||||
|
||||
5
app/templates/vendor/dashboard.html
vendored
5
app/templates/vendor/dashboard.html
vendored
@@ -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") }}
|
||||
|
||||
1
app/templates/vendor/email-templates.html
vendored
1
app/templates/vendor/email-templates.html
vendored
@@ -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>
|
||||
|
||||
10
app/templates/vendor/inventory.html
vendored
10
app/templates/vendor/inventory.html
vendored
@@ -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"
|
||||
|
||||
133
app/templates/vendor/invoices.html
vendored
133
app/templates/vendor/invoices.html
vendored
@@ -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 %}
|
||||
|
||||
92
app/templates/vendor/letzshop.html
vendored
92
app/templates/vendor/letzshop.html
vendored
@@ -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
|
||||
|
||||
56
app/templates/vendor/marketplace.html
vendored
56
app/templates/vendor/marketplace.html
vendored
@@ -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
|
||||
|
||||
22
app/templates/vendor/messages.html
vendored
22
app/templates/vendor/messages.html
vendored
@@ -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 %}
|
||||
|
||||
17
app/templates/vendor/notifications.html
vendored
17
app/templates/vendor/notifications.html
vendored
@@ -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 %}
|
||||
|
||||
2
app/templates/vendor/onboarding.html
vendored
2
app/templates/vendor/onboarding.html
vendored
@@ -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>
|
||||
|
||||
9
app/templates/vendor/orders.html
vendored
9
app/templates/vendor/orders.html
vendored
@@ -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 -->
|
||||
|
||||
394
app/templates/vendor/settings.html
vendored
394
app/templates/vendor/settings.html
vendored
@@ -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, 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">
|
||||
|
||||
9
app/templates/vendor/team.html
vendored
9
app/templates/vendor/team.html
vendored
@@ -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 -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
255
models/database/vendor_email_settings.py
Normal file
255
models/database/vendor_email_settings.py
Normal 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
549
scripts/install.py
Executable 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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
3
static/vendor/js/analytics.js
vendored
3
static/vendor/js/analytics.js
vendored
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
9
static/vendor/js/billing.js
vendored
9
static/vendor/js/billing.js
vendored
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
9
static/vendor/js/customers.js
vendored
9
static/vendor/js/customers.js
vendored
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
9
static/vendor/js/dashboard.js
vendored
9
static/vendor/js/dashboard.js
vendored
@@ -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'
|
||||
|
||||
49
static/vendor/js/init-alpine.js
vendored
49
static/vendor/js/init-alpine.js
vendored
@@ -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;
|
||||
3
static/vendor/js/inventory.js
vendored
3
static/vendor/js/inventory.js
vendored
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
9
static/vendor/js/invoices.js
vendored
9
static/vendor/js/invoices.js
vendored
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
3
static/vendor/js/letzshop.js
vendored
3
static/vendor/js/letzshop.js
vendored
@@ -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' });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
3
static/vendor/js/marketplace.js
vendored
3
static/vendor/js/marketplace.js
vendored
@@ -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',
|
||||
|
||||
9
static/vendor/js/messages.js
vendored
9
static/vendor/js/messages.js
vendored
@@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
3
static/vendor/js/notifications.js
vendored
3
static/vendor/js/notifications.js
vendored
@@ -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
|
||||
|
||||
9
static/vendor/js/order-detail.js
vendored
9
static/vendor/js/order-detail.js
vendored
@@ -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',
|
||||
|
||||
9
static/vendor/js/orders.js
vendored
9
static/vendor/js/orders.js
vendored
@@ -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',
|
||||
|
||||
6
static/vendor/js/products.js
vendored
6
static/vendor/js/products.js
vendored
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
190
static/vendor/js/settings.js
vendored
190
static/vendor/js/settings.js
vendored
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
3
static/vendor/js/team.js
vendored
3
static/vendor/js/team.js
vendored
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user