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:
57
Makefile
57
Makefile
@@ -97,21 +97,30 @@ migrate-status:
|
|||||||
init-prod:
|
init-prod:
|
||||||
@echo "🔧 Initializing production database..."
|
@echo "🔧 Initializing production database..."
|
||||||
@echo ""
|
@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
|
$(PYTHON) scripts/init_production.py
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 2/4: Initializing log settings..."
|
@echo "Step 2/5: Initializing log settings..."
|
||||||
$(PYTHON) scripts/init_log_settings.py
|
$(PYTHON) scripts/init_log_settings.py
|
||||||
@echo ""
|
@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
|
$(PYTHON) scripts/create_default_content_pages.py
|
||||||
@echo ""
|
@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
|
$(PYTHON) scripts/create_platform_pages.py
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Step 5/5: Seeding email templates..."
|
||||||
|
$(PYTHON) scripts/seed_email_templates.py
|
||||||
|
@echo ""
|
||||||
@echo "✅ Production initialization completed"
|
@echo "✅ Production initialization completed"
|
||||||
@echo "✨ Platform is ready for production OR development"
|
@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
|
# Demo data seeding - Cross-platform using Python to set environment
|
||||||
seed-demo:
|
seed-demo:
|
||||||
@echo "🎪 Seeding demo data (normal mode)..."
|
@echo "🎪 Seeding demo data (normal mode)..."
|
||||||
@@ -423,7 +432,8 @@ help:
|
|||||||
@echo " migrate-up - Apply pending migrations"
|
@echo " migrate-up - Apply pending migrations"
|
||||||
@echo " migrate-down - Rollback last migration"
|
@echo " migrate-down - Rollback last migration"
|
||||||
@echo " migrate-status - Show migration status"
|
@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 - Seed demo data (3 companies + vendors)"
|
||||||
@echo " seed-demo-minimal - Seed minimal demo (1 company + vendor)"
|
@echo " seed-demo-minimal - Seed minimal demo (1 company + vendor)"
|
||||||
@echo " seed-demo-reset - DELETE ALL demo data and reseed"
|
@echo " seed-demo-reset - DELETE ALL demo data and reseed"
|
||||||
@@ -483,13 +493,23 @@ help-db:
|
|||||||
@echo " migrate-down - Rollback last migration"
|
@echo " migrate-down - Rollback last migration"
|
||||||
@echo " migrate-status - Show current status and history"
|
@echo " migrate-status - Show current status and history"
|
||||||
@echo ""
|
@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 "PLATFORM INITIALIZATION (Production + Development):"
|
||||||
@echo "──────────────────────────────────────────────────────────"
|
@echo "──────────────────────────────────────────────────────────"
|
||||||
@echo " init-prod - Complete platform setup (4 steps):"
|
@echo " init-prod - Complete platform setup (5 steps):"
|
||||||
@echo " 1. Create admin user + alerts"
|
@echo " 1. Create admin user + settings"
|
||||||
@echo " 2. Initialize log settings"
|
@echo " 2. Initialize log settings"
|
||||||
@echo " 3. Create CMS defaults"
|
@echo " 3. Create CMS defaults"
|
||||||
@echo " 4. Create platform pages"
|
@echo " 4. Create platform pages"
|
||||||
|
@echo " 5. Seed email templates"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "DEMO DATA (Development Only - NEVER in production):"
|
@echo "DEMO DATA (Development Only - NEVER in production):"
|
||||||
@echo "──────────────────────────────────────────────────────────"
|
@echo "──────────────────────────────────────────────────────────"
|
||||||
@@ -510,17 +530,18 @@ help-db:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "TYPICAL FIRST-TIME SETUP (Development):"
|
@echo "TYPICAL FIRST-TIME SETUP (Development):"
|
||||||
@echo "──────────────────────────────────────────────────────────"
|
@echo "──────────────────────────────────────────────────────────"
|
||||||
@echo " 1. make migrate-up # Apply database schema"
|
@echo " 1. cp .env.example .env # Configure environment"
|
||||||
@echo " 2. make init-prod # Initialize platform (admin, CMS, logging, pages)"
|
@echo " 2. make install # Validates config + initializes platform"
|
||||||
@echo " 3. make seed-demo # Add demo data (companies, vendors, products)"
|
@echo " 3. make seed-demo # Add demo data (optional)"
|
||||||
@echo " 4. make dev # Start development server"
|
@echo " 4. make dev # Start development server"
|
||||||
@echo ""
|
|
||||||
@echo " OR simply: make db-setup # Does all the above!"
|
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "PRODUCTION SETUP:"
|
@echo "PRODUCTION SETUP:"
|
||||||
@echo "──────────────────────────────────────────────────────────"
|
@echo "──────────────────────────────────────────────────────────"
|
||||||
@echo " 1. Set ENV=production or ENVIRONMENT=production"
|
@echo " 1. Configure .env with production values:"
|
||||||
@echo " 2. make migrate-up # Apply database schema"
|
@echo " - DATABASE_URL (PostgreSQL recommended)"
|
||||||
@echo " 3. make init-prod # Initialize platform (with .env credentials)"
|
@echo " - JWT_SECRET_KEY (use: openssl rand -hex 32)"
|
||||||
@echo " 4. Create companies via admin panel"
|
@echo " - STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY"
|
||||||
@echo " 5. DO NOT run seed-demo in production!"
|
@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
|
- Viewing all platform settings
|
||||||
- Creating/updating settings
|
- Creating/updating settings
|
||||||
- Managing configuration by category
|
- Managing configuration by category
|
||||||
|
- Email configuration status and testing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
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.core.database import get_db
|
||||||
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
|
||||||
from app.services.admin_audit_service import admin_audit_service
|
from app.services.admin_audit_service import admin_audit_service
|
||||||
@@ -286,3 +289,416 @@ def delete_setting(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": message}
|
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,
|
content_pages,
|
||||||
customers,
|
customers,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
email_settings,
|
||||||
email_templates,
|
email_templates,
|
||||||
features,
|
features,
|
||||||
info,
|
info,
|
||||||
@@ -61,6 +62,7 @@ router.include_router(dashboard.router, tags=["vendor-dashboard"])
|
|||||||
router.include_router(profile.router, tags=["vendor-profile"])
|
router.include_router(profile.router, tags=["vendor-profile"])
|
||||||
router.include_router(settings.router, tags=["vendor-settings"])
|
router.include_router(settings.router, tags=["vendor-settings"])
|
||||||
router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
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"])
|
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
||||||
|
|
||||||
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
# 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 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:
|
Vendors can:
|
||||||
- View their content pages (includes platform defaults)
|
- View their content pages (includes platform defaults)
|
||||||
- Create/edit/delete their own content page overrides
|
- Create/edit/delete their own content page overrides
|
||||||
@@ -15,11 +18,10 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api, get_db
|
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 app.services.content_page_service import content_page_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/content-pages")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,11 +113,8 @@ def list_vendor_pages(
|
|||||||
|
|
||||||
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
|
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(
|
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]
|
return [page.to_dict() for page in pages]
|
||||||
@@ -132,11 +131,8 @@ def list_vendor_overrides(
|
|||||||
|
|
||||||
Shows what the vendor has customized.
|
Shows what the vendor has customized.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
|
||||||
raise VendorNotAssociatedException()
|
|
||||||
|
|
||||||
pages = content_page_service.list_all_vendor_pages(
|
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]
|
return [page.to_dict() for page in pages]
|
||||||
@@ -154,13 +150,10 @@ def get_page(
|
|||||||
|
|
||||||
Returns vendor override if exists, otherwise platform default.
|
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(
|
page = content_page_service.get_page_for_vendor_or_raise(
|
||||||
db,
|
db,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
vendor_id=current_user.vendor_id,
|
vendor_id=current_user.token_vendor_id,
|
||||||
include_unpublished=include_unpublished,
|
include_unpublished=include_unpublished,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,15 +171,12 @@ def create_vendor_page(
|
|||||||
|
|
||||||
This will be shown instead of the platform default for this vendor.
|
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(
|
page = content_page_service.create_page(
|
||||||
db,
|
db,
|
||||||
slug=page_data.slug,
|
slug=page_data.slug,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
vendor_id=current_user.vendor_id,
|
vendor_id=current_user.token_vendor_id,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_keywords=page_data.meta_keywords,
|
||||||
@@ -214,14 +204,11 @@ def update_vendor_page(
|
|||||||
|
|
||||||
Can only update pages owned by this vendor.
|
Can only update pages owned by this vendor.
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
|
||||||
raise VendorNotAssociatedException()
|
|
||||||
|
|
||||||
# Update with ownership check in service layer
|
# Update with ownership check in service layer
|
||||||
page = content_page_service.update_vendor_page(
|
page = content_page_service.update_vendor_page(
|
||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
vendor_id=current_user.vendor_id,
|
vendor_id=current_user.token_vendor_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
@@ -251,9 +238,6 @@ def delete_vendor_page(
|
|||||||
Can only delete pages owned by this vendor.
|
Can only delete pages owned by this vendor.
|
||||||
After deletion, platform default will be shown (if exists).
|
After deletion, platform default will be shown (if exists).
|
||||||
"""
|
"""
|
||||||
if not current_user.vendor_id:
|
|
||||||
raise VendorNotAssociatedException()
|
|
||||||
|
|
||||||
# Delete with ownership check in service layer
|
# 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()
|
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"
|
PLATFORM_DEFAULT_LANGUAGE = "en"
|
||||||
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
|
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
|
@dataclass
|
||||||
class ResolvedTemplate:
|
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",
|
subject="Hello",
|
||||||
body_html="<h1>Hello</h1>",
|
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):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
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())
|
self.jinja_env = Environment(loader=BaseLoader())
|
||||||
# Cache vendor and feature data to avoid repeated queries
|
# Cache vendor and feature data to avoid repeated queries
|
||||||
self._vendor_cache: dict[int, Any] = {}
|
self._vendor_cache: dict[int, Any] = {}
|
||||||
self._feature_cache: dict[int, set[str]] = {}
|
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):
|
def _get_vendor(self, vendor_id: int):
|
||||||
"""Get vendor with caching."""
|
"""Get vendor with caching."""
|
||||||
@@ -419,6 +1017,76 @@ class EmailService:
|
|||||||
|
|
||||||
return feature_code in self._feature_cache[vendor_id]
|
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(
|
def resolve_language(
|
||||||
self,
|
self,
|
||||||
explicit_language: str | None = None,
|
explicit_language: str | None = None,
|
||||||
@@ -721,16 +1389,55 @@ class EmailService:
|
|||||||
related_type: str | None = None,
|
related_type: str | None = None,
|
||||||
related_id: int | None = None,
|
related_id: int | None = None,
|
||||||
extra_data: str | None = None,
|
extra_data: str | None = None,
|
||||||
|
is_platform_email: bool = False,
|
||||||
) -> EmailLog:
|
) -> EmailLog:
|
||||||
"""
|
"""
|
||||||
Send a raw email without using a template.
|
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:
|
Returns:
|
||||||
EmailLog record
|
EmailLog record
|
||||||
"""
|
"""
|
||||||
from_email = from_email or settings.email_from_address
|
# Determine which provider and settings to use
|
||||||
from_name = from_name or settings.email_from_name
|
vendor_settings = None
|
||||||
reply_to = reply_to or settings.email_reply_to or 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
|
# Create log entry
|
||||||
log = EmailLog(
|
log = EmailLog(
|
||||||
@@ -745,7 +1452,7 @@ class EmailService:
|
|||||||
from_name=from_name,
|
from_name=from_name,
|
||||||
reply_to=reply_to,
|
reply_to=reply_to,
|
||||||
status=EmailStatus.PENDING.value,
|
status=EmailStatus.PENDING.value,
|
||||||
provider=settings.email_provider,
|
provider=provider_name,
|
||||||
vendor_id=vendor_id,
|
vendor_id=vendor_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
related_type=related_type,
|
related_type=related_type,
|
||||||
@@ -755,16 +1462,20 @@ class EmailService:
|
|||||||
self.db.add(log)
|
self.db.add(log)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
# Check if emails are disabled
|
# Check if emails are disabled (uses DB config if available)
|
||||||
if not settings.email_enabled:
|
email_enabled = self._platform_config.get("enabled", settings.email_enabled)
|
||||||
|
if not email_enabled:
|
||||||
log.status = EmailStatus.FAILED.value
|
log.status = EmailStatus.FAILED.value
|
||||||
log.error_message = "Email sending is disabled"
|
log.error_message = "Email sending is disabled"
|
||||||
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
|
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
|
||||||
logger.info(f"Email sending disabled, skipping: {to_email}")
|
logger.info(f"Email sending disabled, skipping: {to_email}")
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
# Use vendor provider if available, otherwise platform provider
|
||||||
|
provider_to_use = vendor_provider or self.provider
|
||||||
|
|
||||||
# Send email
|
# Send email
|
||||||
success, message_id, error = self.provider.send(
|
success, message_id, error = provider_to_use.send(
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
to_name=to_name,
|
to_name=to_name,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@@ -777,7 +1488,7 @@ class EmailService:
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
log.mark_sent(message_id)
|
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:
|
else:
|
||||||
log.mark_failed(error or "Unknown error")
|
log.mark_failed(error or "Unknown error")
|
||||||
logger.error(f"Email failed to {to_email}: {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() %}
|
{% call tabs_nav() %}
|
||||||
{{ tab_button('display', 'Display', icon='view-grid') }}
|
{{ tab_button('display', 'Display', icon='view-grid') }}
|
||||||
{{ tab_button('logging', 'Logging', icon='document-text') }}
|
{{ tab_button('logging', 'Logging', icon='document-text') }}
|
||||||
|
{{ tab_button('email', 'Email', icon='envelope') }}
|
||||||
{{ tab_button('shipping', 'Shipping', icon='truck') }}
|
{{ tab_button('shipping', 'Shipping', icon='truck') }}
|
||||||
{{ tab_button('system', 'System', icon='cog') }}
|
{{ tab_button('system', 'System', icon='cog') }}
|
||||||
{{ tab_button('security', 'Security', icon='shield-check') }}
|
{{ tab_button('security', 'Security', icon='shield-check') }}
|
||||||
@@ -218,6 +219,349 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Shipping Settings Tab -->
|
||||||
<div x-show="activeTab === 'shipping'" x-transition>
|
<div x-show="activeTab === 'shipping'" x-transition>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<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>
|
<span>Upgrade to <span x-text="$store.upgrade.nextTier?.name"></span></span>
|
||||||
</a>
|
</a>
|
||||||
{% endmacro %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Address Modal -->
|
<!-- 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"
|
<div x-show="showAddressModal"
|
||||||
x-cloak
|
x-cloak
|
||||||
class="fixed inset-0 z-50 overflow-y-auto"
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{# app/templates/shop/account/dashboard.html #}
|
{# app/templates/shop/account/dashboard.html #}
|
||||||
{% extends "shop/base.html" %}
|
{% extends "shop/base.html" %}
|
||||||
|
{% from 'shared/macros/modals.html' import confirm_modal %}
|
||||||
|
|
||||||
{% block title %}My Account - {{ vendor.name }}{% endblock %}
|
{% block title %}My Account - {{ vendor.name }}{% endblock %}
|
||||||
|
|
||||||
@@ -117,75 +118,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logout Confirmation Modal -->
|
<!-- Logout Confirmation Modal -->
|
||||||
<div x-show="showLogoutModal"
|
{{ confirm_modal(
|
||||||
x-cloak
|
id='logoutModal',
|
||||||
class="fixed inset-0 z-50 overflow-y-auto"
|
title='Logout Confirmation',
|
||||||
aria-labelledby="modal-title"
|
message="Are you sure you want to logout? You'll need to sign in again to access your account.",
|
||||||
role="dialog"
|
confirm_action='confirmLogout()',
|
||||||
aria-modal="true">
|
show_var='showLogoutModal',
|
||||||
<!-- Background overlay -->
|
confirm_text='Logout',
|
||||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
cancel_text='Cancel',
|
||||||
<!-- Overlay backdrop -->
|
variant='danger'
|
||||||
<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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
|
{# noqa: FE-001 - Custom pagination with currentPage/totalPages vars (not pagination.page/pagination.total) #}
|
||||||
<template x-if="totalPages > 1">
|
<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">
|
<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"
|
<button @click="prevPage()" :disabled="currentPage === 1"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{# Quantity Controls #}
|
{# 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-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
{# Add to Cart Section #}
|
{# Add to Cart Section #}
|
||||||
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
|
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
|
||||||
{# Quantity Selector #}
|
{# Quantity Selector #}
|
||||||
|
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block font-semibold text-lg mb-2">Quantity:</label>
|
<label class="block font-semibold text-lg mb-2">Quantity:</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
94
app/templates/vendor/billing.html
vendored
94
app/templates/vendor/billing.html
vendored
@@ -1,16 +1,14 @@
|
|||||||
{# app/templates/vendor/billing.html #}
|
{# app/templates/vendor/billing.html #}
|
||||||
{% extends "vendor/base.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 title %}Billing & Subscription{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}vendorBilling(){% endblock %}
|
{% block alpine_data %}vendorBilling(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between my-6">
|
{{ page_header('Billing & Subscription') }}
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
|
||||||
Billing & Subscription
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success/Cancel Messages -->
|
<!-- Success/Cancel Messages -->
|
||||||
<template x-if="showSuccessMessage">
|
<template x-if="showSuccessMessage">
|
||||||
@@ -260,61 +258,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Tiers Modal -->
|
<!-- Tiers Modal -->
|
||||||
<div x-show="showTiersModal"
|
{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
|
||||||
x-transition:enter="transition ease-out duration-150"
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
x-transition:enter-start="opacity-0"
|
<template x-for="tier in tiers" :key="tier.code">
|
||||||
x-transition:enter-end="opacity-100"
|
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
|
||||||
x-transition:leave="transition ease-in duration-150"
|
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
x-transition:leave-start="opacity-100"
|
<template x-if="tier.is_current">
|
||||||
x-transition:leave-end="opacity-0"
|
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
|
||||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
</template>
|
||||||
@click.self="showTiersModal = false">
|
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
|
||||||
<div class="w-full max-w-4xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
|
||||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Choose Your Plan</h3>
|
<span class="text-sm font-normal text-gray-500">/mo</span>
|
||||||
<button @click="showTiersModal = false" class="text-gray-400 hover:text-gray-600">
|
</p>
|
||||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li class="flex items-center">
|
||||||
|
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||||
|
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||||
|
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||||
|
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button @click="selectTier(tier)"
|
||||||
|
:disabled="tier.is_current"
|
||||||
|
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
|
||||||
|
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
|
||||||
|
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
</template>
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<template x-for="tier in tiers" :key="tier.code">
|
|
||||||
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
|
|
||||||
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<template x-if="tier.is_current">
|
|
||||||
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
|
|
||||||
</template>
|
|
||||||
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
|
|
||||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
|
||||||
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
|
|
||||||
<span class="text-sm font-normal text-gray-500">/mo</span>
|
|
||||||
</p>
|
|
||||||
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<li class="flex items-center">
|
|
||||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
|
||||||
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center">
|
|
||||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
|
||||||
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center">
|
|
||||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
|
||||||
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button @click="selectTier(tier)"
|
|
||||||
:disabled="tier.is_current"
|
|
||||||
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
|
|
||||||
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
|
|
||||||
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
<!-- Add-ons Modal -->
|
<!-- Add-ons Modal -->
|
||||||
<div x-show="showAddonsModal"
|
<div x-show="showAddonsModal"
|
||||||
|
|||||||
222
app/templates/vendor/customers.html
vendored
222
app/templates/vendor/customers.html
vendored
@@ -3,6 +3,8 @@
|
|||||||
{% from 'shared/macros/pagination.html' import pagination %}
|
{% from 'shared/macros/pagination.html' import pagination %}
|
||||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% 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 %}
|
{% block title %}Customers{% endblock %}
|
||||||
|
|
||||||
@@ -98,133 +100,123 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Customers Table -->
|
<!-- Customers Table -->
|
||||||
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && !error" class="mb-8">
|
||||||
<div class="w-full overflow-x-auto">
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-no-wrap">
|
<thead>
|
||||||
<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">
|
||||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
<th class="px-4 py-3">Customer</th>
|
||||||
<th class="px-4 py-3">Customer</th>
|
<th class="px-4 py-3">Email</th>
|
||||||
<th class="px-4 py-3">Email</th>
|
<th class="px-4 py-3">Joined</th>
|
||||||
<th class="px-4 py-3">Joined</th>
|
<th class="px-4 py-3">Orders</th>
|
||||||
<th class="px-4 py-3">Orders</th>
|
<th class="px-4 py-3">Actions</th>
|
||||||
<th class="px-4 py-3">Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<template x-for="customer in customers" :key="customer.id">
|
||||||
<template x-for="customer in customers" :key="customer.id">
|
<tr class="text-gray-700 dark:text-gray-400">
|
||||||
<tr class="text-gray-700 dark:text-gray-400">
|
<!-- Customer Info -->
|
||||||
<!-- Customer Info -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="flex items-center text-sm">
|
||||||
<div class="flex items-center text-sm">
|
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(customer)"></span>
|
||||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(customer)"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold" x-text="`${customer.first_name || ''} ${customer.last_name || ''}`.trim() || 'Unknown'"></p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="customer.phone || ''"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<!-- Email -->
|
|
||||||
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
|
|
||||||
<!-- Joined -->
|
|
||||||
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
|
|
||||||
<!-- Orders -->
|
|
||||||
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center space-x-2 text-sm">
|
|
||||||
<button
|
|
||||||
@click="viewCustomer(customer)"
|
|
||||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
|
||||||
title="View Details"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="viewCustomerOrders(customer)"
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
|
||||||
title="View Orders"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="messageCustomer(customer)"
|
|
||||||
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
|
|
||||||
title="Send Message"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<!-- Empty State -->
|
|
||||||
<tr x-show="customers.length === 0">
|
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
|
||||||
<p class="text-lg font-medium">No customers found</p>
|
|
||||||
<p class="text-sm">Customers will appear here when they make purchases</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
</tr>
|
<p class="font-semibold" x-text="`${customer.first_name || ''} ${customer.last_name || ''}`.trim() || 'Unknown'"></p>
|
||||||
</tbody>
|
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="customer.phone || ''"></p>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Email -->
|
||||||
|
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
|
||||||
|
<!-- Joined -->
|
||||||
|
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
|
||||||
|
<!-- Orders -->
|
||||||
|
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
|
<button
|
||||||
|
@click="viewCustomer(customer)"
|
||||||
|
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="viewCustomerOrders(customer)"
|
||||||
|
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
|
title="View Orders"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="messageCustomer(customer)"
|
||||||
|
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
|
||||||
|
title="Send Message"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<!-- Empty State -->
|
||||||
|
<tr x-show="customers.length === 0">
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||||
|
<p class="text-lg font-medium">No customers found</p>
|
||||||
|
<p class="text-sm">Customers will appear here when they make purchases</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||||
|
|
||||||
<!-- Customer Detail Modal -->
|
<!-- 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">
|
{% call modal_simple('customerDetailModal', 'Customer Details', show_var='showDetailModal', size='md') %}
|
||||||
<div class="w-full max-w-lg bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showDetailModal = false">
|
<div x-show="selectedCustomer">
|
||||||
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
<div class="flex items-center mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Customer Details</h3>
|
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
|
||||||
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(selectedCustomer)"></span>
|
||||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4" x-show="selectedCustomer">
|
<div>
|
||||||
<div class="flex items-center mb-4">
|
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim() || 'Unknown'"></p>
|
||||||
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCustomer?.email"></p>
|
||||||
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(selectedCustomer)"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim() || 'Unknown'"></p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCustomer?.email"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Phone</p>
|
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.phone || '-'"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Joined</p>
|
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
|
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
|
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
|
</div>
|
||||||
<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">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
Close
|
<div>
|
||||||
</button>
|
<p class="text-gray-500 dark:text-gray-400">Phone</p>
|
||||||
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.phone || '-'"></p>
|
||||||
Send Message
|
</div>
|
||||||
</button>
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Joined</p>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
|
||||||
|
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-4 mt-4 border-t dark:border-gray-700">
|
||||||
|
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
<!-- Customer Orders Modal -->
|
<!-- 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">
|
<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 %}
|
{% block alpine_data %}vendorDashboard(){% endblock %}
|
||||||
|
|
||||||
|
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Email Settings Warning -->
|
||||||
|
{{ email_settings_warning() }}
|
||||||
|
|
||||||
<!-- Limit Warnings -->
|
<!-- Limit Warnings -->
|
||||||
{{ limit_warning("orders") }}
|
{{ limit_warning("orders") }}
|
||||||
{{ limit_warning("products") }}
|
{{ limit_warning("products") }}
|
||||||
|
|||||||
1
app/templates/vendor/email-templates.html
vendored
1
app/templates/vendor/email-templates.html
vendored
@@ -34,6 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Templates Table -->
|
<!-- 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="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||||
<div class="p-4 border-b dark:border-gray-700">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Available Templates</h3>
|
||||||
|
|||||||
186
app/templates/vendor/inventory.html
vendored
186
app/templates/vendor/inventory.html
vendored
@@ -4,6 +4,7 @@
|
|||||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||||
|
|
||||||
{% block title %}Inventory{% endblock %}
|
{% block title %}Inventory{% endblock %}
|
||||||
|
|
||||||
@@ -154,99 +155,97 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inventory Table -->
|
<!-- Inventory Table -->
|
||||||
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && !error" class="mb-8">
|
||||||
<div class="w-full overflow-x-auto">
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-no-wrap">
|
<thead>
|
||||||
<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">
|
||||||
<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">
|
||||||
<th class="px-4 py-3 w-10">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
:checked="allSelected"
|
||||||
:checked="allSelected"
|
:indeterminate="someSelected"
|
||||||
:indeterminate="someSelected"
|
@click="toggleSelectAll()"
|
||||||
@click="toggleSelectAll()"
|
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th class="px-4 py-3">Product</th>
|
||||||
<th class="px-4 py-3">Product</th>
|
<th class="px-4 py-3">SKU</th>
|
||||||
<th class="px-4 py-3">SKU</th>
|
<th class="px-4 py-3">Location</th>
|
||||||
<th class="px-4 py-3">Location</th>
|
<th class="px-4 py-3">Quantity</th>
|
||||||
<th class="px-4 py-3">Quantity</th>
|
<th class="px-4 py-3">Status</th>
|
||||||
<th class="px-4 py-3">Status</th>
|
<th class="px-4 py-3">Actions</th>
|
||||||
<th class="px-4 py-3">Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<template x-for="item in inventory" :key="item.id">
|
||||||
<template x-for="item in inventory" :key="item.id">
|
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(item.id)}">
|
||||||
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(item.id)}">
|
<!-- Checkbox -->
|
||||||
<!-- Checkbox -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
:checked="isSelected(item.id)"
|
||||||
:checked="isSelected(item.id)"
|
@click="toggleSelect(item.id)"
|
||||||
@click="toggleSelect(item.id)"
|
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
/>
|
||||||
/>
|
</td>
|
||||||
</td>
|
<!-- Product -->
|
||||||
<!-- Product -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="text-sm">
|
||||||
<div class="text-sm">
|
<p class="font-semibold" x-text="item.product_name || 'Unknown Product'"></p>
|
||||||
<p class="font-semibold" x-text="item.product_name || 'Unknown Product'"></p>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<!-- SKU -->
|
||||||
<!-- SKU -->
|
<td class="px-4 py-3 text-sm font-mono" x-text="item.product_sku || '-'"></td>
|
||||||
<td class="px-4 py-3 text-sm font-mono" x-text="item.product_sku || '-'"></td>
|
<!-- Location -->
|
||||||
<!-- Location -->
|
<td class="px-4 py-3 text-sm" x-text="item.location || 'Default'"></td>
|
||||||
<td class="px-4 py-3 text-sm" x-text="item.location || 'Default'"></td>
|
<!-- Quantity -->
|
||||||
<!-- Quantity -->
|
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(item.quantity)"></td>
|
||||||
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(item.quantity)"></td>
|
<!-- Status -->
|
||||||
<!-- Status -->
|
<td class="px-4 py-3 text-xs">
|
||||||
<td class="px-4 py-3 text-xs">
|
<span
|
||||||
<span
|
:class="{
|
||||||
:class="{
|
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
||||||
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStockStatus(item) === 'ok',
|
||||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStockStatus(item) === 'ok',
|
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStockStatus(item) === 'low',
|
||||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStockStatus(item) === 'low',
|
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStockStatus(item) === 'out'
|
||||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStockStatus(item) === 'out'
|
}"
|
||||||
}"
|
>
|
||||||
>
|
<span x-text="getStockStatus(item) === 'out' ? 'Out of Stock' : (getStockStatus(item) === 'low' ? 'Low Stock' : 'In Stock')"></span>
|
||||||
<span x-text="getStockStatus(item) === 'out' ? 'Out of Stock' : (getStockStatus(item) === 'low' ? 'Low Stock' : 'In Stock')"></span>
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<!-- Actions -->
|
||||||
<!-- Actions -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
<div class="flex items-center space-x-2 text-sm">
|
<button
|
||||||
<button
|
@click="openAdjustModal(item)"
|
||||||
@click="openAdjustModal(item)"
|
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
title="Adjust Stock"
|
||||||
title="Adjust Stock"
|
>
|
||||||
>
|
<span x-html="$icon('plus-minus', 'w-5 h-5')"></span>
|
||||||
<span x-html="$icon('plus-minus', 'w-5 h-5')"></span>
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
@click="openSetModal(item)"
|
||||||
@click="openSetModal(item)"
|
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
title="Set Quantity"
|
||||||
title="Set Quantity"
|
>
|
||||||
>
|
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</template>
|
||||||
</template>
|
<!-- Empty State -->
|
||||||
<!-- Empty State -->
|
<tr x-show="inventory.length === 0">
|
||||||
<tr x-show="inventory.length === 0">
|
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<div class="flex flex-col items-center">
|
||||||
<div class="flex flex-col items-center">
|
<span x-html="$icon('archive', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||||
<span x-html="$icon('archive', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
<p class="text-lg font-medium">No inventory found</p>
|
||||||
<p class="text-lg font-medium">No inventory found</p>
|
<p class="text-sm">Add products and set their stock levels</p>
|
||||||
<p class="text-sm">Add products and set their stock levels</p>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
{% endcall %}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
@@ -263,6 +262,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Adjustment (+ or -)</label>
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
x-model.number="adjustForm.quantity"
|
x-model.number="adjustForm.quantity"
|
||||||
|
|||||||
209
app/templates/vendor/invoices.html
vendored
209
app/templates/vendor/invoices.html
vendored
@@ -1,6 +1,10 @@
|
|||||||
{# app/templates/vendor/invoices.html #}
|
{# app/templates/vendor/invoices.html #}
|
||||||
{% extends "vendor/base.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 title %}Invoices{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}vendorInvoices(){% endblock %}
|
{% block alpine_data %}vendorInvoices(){% endblock %}
|
||||||
@@ -11,36 +15,18 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="flex items-center justify-between my-6">
|
{% call page_header_flex(title='Invoices', subtitle='Create and manage invoices for your orders') %}
|
||||||
<div>
|
<button
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
@click="openCreateModal()"
|
||||||
Invoices
|
:disabled="!hasSettings"
|
||||||
</h2>
|
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"
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
:title="!hasSettings ? 'Configure invoice settings first' : 'Create new invoice'"
|
||||||
Create and manage invoices for your orders
|
>
|
||||||
</p>
|
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||||
</div>
|
Create Invoice
|
||||||
<div class="flex gap-2">
|
</button>
|
||||||
<button
|
{{ refresh_button(loading_var='loading', onclick='refreshData()', variant='secondary') }}
|
||||||
@click="openCreateModal()"
|
{% endcall %}
|
||||||
:disabled="!hasSettings"
|
|
||||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
:title="!hasSettings ? 'Configure invoice settings first' : 'Create new invoice'"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
|
||||||
Create Invoice
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="refreshData()"
|
|
||||||
:disabled="loading"
|
|
||||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
|
||||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- 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">
|
<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>
|
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||||
<div>
|
<div>
|
||||||
@@ -169,20 +156,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoices Table -->
|
<!-- Invoices Table -->
|
||||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
{% call table_wrapper() %}
|
||||||
<div class="w-full overflow-x-auto">
|
{{ table_header(['Invoice #', 'Customer', 'Date', 'Amount', 'Status', 'Actions']) }}
|
||||||
<table class="w-full whitespace-no-wrap">
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<thead>
|
|
||||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
|
||||||
<th class="px-4 py-3">Invoice #</th>
|
|
||||||
<th class="px-4 py-3">Customer</th>
|
|
||||||
<th class="px-4 py-3">Date</th>
|
|
||||||
<th class="px-4 py-3">Amount</th>
|
|
||||||
<th class="px-4 py-3">Status</th>
|
|
||||||
<th class="px-4 py-3">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
|
||||||
<template x-if="loading && invoices.length === 0">
|
<template x-if="loading && invoices.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
@@ -269,41 +245,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
{% endcall %}
|
||||||
</div>
|
|
||||||
<!-- Pagination -->
|
<!-- 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">
|
{{ simple_pagination(page_var='page', total_var='totalInvoices', limit_var='perPage', on_change='loadInvoices()') }}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -522,81 +468,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Invoice Modal -->
|
<!-- Create Invoice Modal -->
|
||||||
<div
|
{% call form_modal('createInvoiceModal', 'Create Invoice', show_var='showCreateModal', submit_action='createInvoice()', submit_text='Create Invoice', loading_var='creatingInvoice', loading_text='Creating...', size='sm') %}
|
||||||
x-show="showCreateModal"
|
<div class="mb-4">
|
||||||
x-transition:enter="transition ease-out duration-150"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||||
x-transition:enter-start="opacity-0"
|
Order ID <span class="text-red-500">*</span>
|
||||||
x-transition:enter-end="opacity-100"
|
</label>
|
||||||
x-transition:leave="transition ease-in duration-150"
|
{# noqa: FE-008 - Order ID is a reference field, not a quantity stepper #}
|
||||||
x-transition:leave-start="opacity-100"
|
<input
|
||||||
x-transition:leave-end="opacity-0"
|
type="number"
|
||||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
x-model="createForm.order_id"
|
||||||
@click.self="showCreateModal = false"
|
required
|
||||||
>
|
placeholder="Enter order ID"
|
||||||
<div
|
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"
|
||||||
x-transition:enter="transition ease-out duration-150"
|
/>
|
||||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
x-transition:enter-end="opacity-100"
|
Enter the order ID to generate an invoice for
|
||||||
x-transition:leave="transition ease-in duration-150"
|
</p>
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
|
||||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<header class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Create Invoice</h3>
|
|
||||||
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form @submit.prevent="createInvoice()">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
|
||||||
Order ID <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model="createForm.order_id"
|
|
||||||
required
|
|
||||||
placeholder="Enter order ID"
|
|
||||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Enter the order ID to generate an invoice for
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
|
||||||
Notes (Optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
x-model="createForm.notes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any additional notes for the invoice..."
|
|
||||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="showCreateModal = false"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="creatingInvoice"
|
|
||||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span x-show="creatingInvoice" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
||||||
<span x-text="creatingInvoice ? 'Creating...' : 'Create Invoice'"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||||
|
Notes (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
x-model="createForm.notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Any additional notes for the invoice..."
|
||||||
|
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
248
app/templates/vendor/letzshop.html
vendored
248
app/templates/vendor/letzshop.html
vendored
@@ -1,5 +1,8 @@
|
|||||||
{# app/templates/vendor/letzshop.html #}
|
{# app/templates/vendor/letzshop.html #}
|
||||||
{% extends "vendor/base.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 %}
|
{% block title %}Letzshop Orders{% endblock %}
|
||||||
|
|
||||||
@@ -11,36 +14,26 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="flex items-center justify-between my-6">
|
{% call page_header_flex(title='Letzshop Orders', subtitle='Manage orders from Letzshop marketplace') %}
|
||||||
<div>
|
<button
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
@click="importOrders()"
|
||||||
Letzshop Orders
|
:disabled="!status.is_configured || importing"
|
||||||
</h2>
|
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"
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
>
|
||||||
Manage orders from Letzshop marketplace
|
<span x-show="!importing" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
|
||||||
</p>
|
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||||
</div>
|
<span x-text="importing ? 'Importing...' : 'Import Orders'"></span>
|
||||||
<div class="flex gap-2">
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="importOrders()"
|
@click="refreshData()"
|
||||||
:disabled="!status.is_configured || importing"
|
: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"
|
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="!importing" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
|
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||||
<span x-show="importing" x-html="$icon('spinner', '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="importing ? 'Importing...' : 'Import Orders'"></span>
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<button
|
{% endcall %}
|
||||||
@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>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- 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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# noqa: FE-003 - Custom dismissible error with dark mode support not available in error_state macro #}
|
||||||
<!-- Error Message -->
|
<!-- 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">
|
<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>
|
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||||
@@ -167,19 +161,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders Table -->
|
<!-- Orders Table -->
|
||||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
{% call table_wrapper() %}
|
||||||
<div class="w-full overflow-x-auto">
|
{{ table_header(['Order', 'Customer', 'Total', 'Status', 'Date', 'Actions']) }}
|
||||||
<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>
|
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<template x-if="loading && orders.length === 0">
|
<template x-if="loading && orders.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -268,39 +251,38 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
{% endcall %}
|
||||||
</div>
|
{# noqa: FE-001 - Uses flat variables (page, limit, totalOrders) instead of pagination object expected by macro #}
|
||||||
<!-- Pagination -->
|
<!-- 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">
|
<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">
|
<span class="flex items-center col-span-3">
|
||||||
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalOrders)"></span> of <span x-text="totalOrders"></span>
|
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalOrders)"></span> of <span x-text="totalOrders"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="col-span-2"></span>
|
<span class="col-span-2"></span>
|
||||||
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
|
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
|
||||||
<nav aria-label="Table navigation">
|
<nav aria-label="Table navigation">
|
||||||
<ul class="inline-flex items-center">
|
<ul class="inline-flex items-center">
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@click="page--; loadOrders()"
|
@click="page--; loadOrders()"
|
||||||
:disabled="page <= 1"
|
:disabled="page <= 1"
|
||||||
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
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>
|
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@click="page++; loadOrders()"
|
@click="page++; loadOrders()"
|
||||||
:disabled="page * limit >= totalOrders"
|
:disabled="page * limit >= totalOrders"
|
||||||
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
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>
|
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -582,88 +564,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tracking Modal -->
|
<!-- Tracking Modal -->
|
||||||
<div
|
{% 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') %}
|
||||||
x-show="showTrackingModal"
|
<div class="mb-4">
|
||||||
x-transition:enter="transition ease-out duration-150"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||||
x-transition:enter-start="opacity-0"
|
Tracking Number <span class="text-red-500">*</span>
|
||||||
x-transition:enter-end="opacity-100"
|
</label>
|
||||||
x-transition:leave="transition ease-in duration-150"
|
<input
|
||||||
x-transition:leave-start="opacity-100"
|
type="text"
|
||||||
x-transition:leave-end="opacity-0"
|
x-model="trackingForm.tracking_number"
|
||||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
required
|
||||||
@click.self="showTrackingModal = false"
|
placeholder="1Z999AA10123456784"
|
||||||
>
|
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||||
<div
|
/>
|
||||||
x-transition:enter="transition ease-out duration-150"
|
|
||||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
x-transition:leave="transition ease-in duration-150"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
|
||||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<header class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Set Tracking Information</h3>
|
|
||||||
<button @click="showTrackingModal = false" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form @submit.prevent="submitTracking()">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
|
||||||
Tracking Number <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="trackingForm.tracking_number"
|
|
||||||
required
|
|
||||||
placeholder="1Z999AA10123456784"
|
|
||||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
|
||||||
Carrier <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
x-model="trackingForm.tracking_carrier"
|
|
||||||
required
|
|
||||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Select carrier...</option>
|
|
||||||
<option value="dhl">DHL</option>
|
|
||||||
<option value="ups">UPS</option>
|
|
||||||
<option value="fedex">FedEx</option>
|
|
||||||
<option value="post_lu">Post Luxembourg</option>
|
|
||||||
<option value="dpd">DPD</option>
|
|
||||||
<option value="gls">GLS</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="showTrackingModal = false"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="submittingTracking"
|
|
||||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span x-show="submittingTracking" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
||||||
<span x-text="submittingTracking ? 'Saving...' : 'Save Tracking'"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||||
|
Carrier <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
x-model="trackingForm.tracking_carrier"
|
||||||
|
required
|
||||||
|
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select carrier...</option>
|
||||||
|
<option value="dhl">DHL</option>
|
||||||
|
<option value="ups">UPS</option>
|
||||||
|
<option value="fedex">FedEx</option>
|
||||||
|
<option value="post_lu">Post Luxembourg</option>
|
||||||
|
<option value="dpd">DPD</option>
|
||||||
|
<option value="gls">GLS</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
<!-- Order Details Modal -->
|
<!-- Order Details Modal -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
180
app/templates/vendor/marketplace.html
vendored
180
app/templates/vendor/marketplace.html
vendored
@@ -1,6 +1,10 @@
|
|||||||
{# app/templates/vendor/marketplace.html #}
|
{# app/templates/vendor/marketplace.html #}
|
||||||
{% extends "vendor/base.html" %}
|
{% extends "vendor/base.html" %}
|
||||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
{% 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 %}
|
{% block title %}Marketplace Import{% endblock %}
|
||||||
|
|
||||||
@@ -12,25 +16,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="flex items-center justify-between my-6">
|
{% call page_header_flex(title='Marketplace Import', subtitle='Import products from Letzshop marketplace CSV feeds') %}
|
||||||
<div>
|
{{ refresh_button(loading_var='loading', onclick='refreshJobs()') }}
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
{% endcall %}
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- 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">
|
{{ error_state(title='Error', error_var='error', show_condition='error') }}
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Import Form Card -->
|
<!-- Import Form Card -->
|
||||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||||
@@ -194,88 +176,77 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Jobs Table -->
|
<!-- Jobs Table -->
|
||||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && jobs.length > 0">
|
||||||
<div class="w-full overflow-x-auto">
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-no-wrap">
|
{{ table_header(['Job ID', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Actions']) }}
|
||||||
<thead>
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<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">
|
<template x-for="job in jobs" :key="job.id">
|
||||||
<th class="px-4 py-3">Job ID</th>
|
<tr class="text-gray-700 dark:text-gray-400">
|
||||||
<th class="px-4 py-3">Marketplace</th>
|
<td class="px-4 py-3 text-sm">
|
||||||
<th class="px-4 py-3">Status</th>
|
#<span x-text="job.id"></span>
|
||||||
<th class="px-4 py-3">Progress</th>
|
</td>
|
||||||
<th class="px-4 py-3">Started</th>
|
<td class="px-4 py-3 text-sm">
|
||||||
<th class="px-4 py-3">Duration</th>
|
<span x-text="job.marketplace"></span>
|
||||||
<th class="px-4 py-3">Actions</th>
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||||
|
:class="{
|
||||||
|
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||||
|
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||||
|
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||||
|
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||||
|
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||||
|
}"
|
||||||
|
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||||
|
</div>
|
||||||
|
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
<span x-text="job.error_count"></span> errors
|
||||||
|
</div>
|
||||||
|
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
Total: <span x-text="job.total_processed"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<span x-text="calculateDuration(job)"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="viewJobDetails(job.id)"
|
||||||
|
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||||
|
@click="refreshJobStatus(job.id)"
|
||||||
|
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="Refresh Status"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</template>
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
</tbody>
|
||||||
<template x-for="job in jobs" :key="job.id">
|
{% endcall %}
|
||||||
<tr class="text-gray-700 dark:text-gray-400">
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
#<span x-text="job.id"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span x-text="job.marketplace"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
|
||||||
:class="{
|
|
||||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
|
||||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
|
||||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
|
||||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
|
||||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
|
||||||
}"
|
|
||||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
|
||||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
|
||||||
</div>
|
|
||||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
|
||||||
<span x-text="job.error_count"></span> errors
|
|
||||||
</div>
|
|
||||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
Total: <span x-text="job.total_processed"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span x-text="calculateDuration(job)"></span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
@click="viewJobDetails(job.id)"
|
|
||||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="View Details"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
|
||||||
@click="refreshJobStatus(job.id)"
|
|
||||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Refresh Status"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- 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 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="flex items-center justify-between">
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
@@ -304,6 +275,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# noqa: FE-004 - Custom modal with different field names (imported_count vs imported) #}
|
||||||
<!-- Job Details Modal -->
|
<!-- Job Details Modal -->
|
||||||
<div x-show="showJobModal"
|
<div x-show="showJobModal"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
118
app/templates/vendor/messages.html
vendored
118
app/templates/vendor/messages.html
vendored
@@ -2,6 +2,7 @@
|
|||||||
{% extends "vendor/base.html" %}
|
{% extends "vendor/base.html" %}
|
||||||
{% from 'shared/macros/headers.html' import page_header %}
|
{% from 'shared/macros/headers.html' import page_header %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
|
||||||
{% block title %}Messages{% endblock %}
|
{% block title %}Messages{% endblock %}
|
||||||
|
|
||||||
@@ -218,72 +219,59 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compose Modal -->
|
<!-- Compose Modal -->
|
||||||
<div x-show="showComposeModal"
|
{% call modal_simple('composeMessageModal', 'New Conversation', show_var='showComposeModal', size='md') %}
|
||||||
x-cloak
|
<form @submit.prevent="createConversation()" class="space-y-4">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
<!-- Customer -->
|
||||||
@keydown.escape.window="showComposeModal = false">
|
<div>
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-50" @click="showComposeModal = false"></div>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer</label>
|
||||||
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
<select
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
x-model="compose.recipientId"
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">New Conversation</h3>
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
<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>
|
<option value="">Select customer...</option>
|
||||||
</button>
|
<template x-for="r in recipients" :key="r.id">
|
||||||
</div>
|
<option :value="r.id" x-text="r.name + ' - ' + (r.email || '')"></option>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="createConversation()" class="p-6 space-y-4">
|
</select>
|
||||||
<!-- Customer -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Customer</label>
|
|
||||||
<select
|
|
||||||
x-model="compose.recipientId"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
<option value="">Select customer...</option>
|
|
||||||
<template x-for="r in recipients" :key="r.id">
|
|
||||||
<option :value="r.id" x-text="r.name + ' - ' + (r.email || '')"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subject -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="compose.subject"
|
|
||||||
placeholder="What is this about?"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
|
|
||||||
<textarea
|
|
||||||
x-model="compose.message"
|
|
||||||
rows="4"
|
|
||||||
placeholder="Type your message..."
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
|
||||||
<button type="button" @click="showComposeModal = false"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
:disabled="!compose.recipientId || !compose.subject.trim() || creatingConversation"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
|
||||||
<span x-show="!creatingConversation">Start Conversation</span>
|
|
||||||
<span x-show="creatingConversation">Creating...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Subject -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="compose.subject"
|
||||||
|
placeholder="What is this about?"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
|
||||||
|
<textarea
|
||||||
|
x-model="compose.message"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
|
||||||
|
<button type="button" @click="showComposeModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
:disabled="!compose.recipientId || !compose.subject.trim() || creatingConversation"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
<span x-show="!creatingConversation">Start Conversation</span>
|
||||||
|
<span x-show="creatingConversation">Creating...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
87
app/templates/vendor/notifications.html
vendored
87
app/templates/vendor/notifications.html
vendored
@@ -3,6 +3,7 @@
|
|||||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
{% from 'shared/macros/pagination.html' import pagination_simple %}
|
{% from 'shared/macros/pagination.html' import pagination_simple %}
|
||||||
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
|
||||||
{% block title %}Notifications{% endblock %}
|
{% block title %}Notifications{% endblock %}
|
||||||
|
|
||||||
@@ -180,56 +181,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- 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">
|
{% call modal_simple('notificationSettingsModal', 'Notification Settings', show_var='showSettingsModal', size='md') %}
|
||||||
<div class="w-full max-w-md bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showSettingsModal = false">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
<!-- Email Notifications -->
|
||||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Notification Settings</h3>
|
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<button @click="showSettingsModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
<div>
|
||||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
<p class="font-medium text-gray-700 dark:text-gray-300">Email Notifications</p>
|
||||||
</button>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Receive notifications via email</p>
|
||||||
</div>
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<!-- Email Notifications -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-700 dark:text-gray-300">Email Notifications</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Receive notifications via email</p>
|
|
||||||
</div>
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" x-model="settingsForm.email_notifications" class="sr-only peer" />
|
|
||||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- In-App Notifications -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-700 dark:text-gray-300">In-App Notifications</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Show notifications in the dashboard</p>
|
|
||||||
</div>
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" x-model="settingsForm.in_app_notifications" class="sr-only peer" />
|
|
||||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
|
||||||
Note: Full notification settings management coming soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2 p-4 border-t dark:border-gray-700">
|
|
||||||
<button @click="showSettingsModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="saveSettings()"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
|
||||||
>
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="settingsForm.email_notifications" class="sr-only peer" />
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- In-App Notifications -->
|
||||||
|
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-700 dark:text-gray-300">In-App Notifications</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Show notifications in the dashboard</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="settingsForm.in_app_notifications" class="sr-only peer" />
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-purple-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
Note: Full notification settings management coming soon.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t dark:border-gray-700">
|
||||||
|
<button @click="showSettingsModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="saveSettings()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
2
app/templates/vendor/onboarding.html
vendored
2
app/templates/vendor/onboarding.html
vendored
@@ -31,6 +31,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Language Selector -->
|
<!-- Language Selector -->
|
||||||
|
{# noqa: FE-006 - Custom language selector with flags, not suited for dropdown macro #}
|
||||||
<div class="relative" x-data="{ open: false }">
|
<div class="relative" x-data="{ open: false }">
|
||||||
<button @click="open = !open"
|
<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">
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.preorder_days')"></label>
|
<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"
|
<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" />
|
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>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="t('step3.preorder_days_help')"></p>
|
||||||
|
|||||||
197
app/templates/vendor/orders.html
vendored
197
app/templates/vendor/orders.html
vendored
@@ -4,6 +4,7 @@
|
|||||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||||
|
|
||||||
{% block title %}Orders{% endblock %}
|
{% block title %}Orders{% endblock %}
|
||||||
|
|
||||||
@@ -160,105 +161,103 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders Table -->
|
<!-- Orders Table -->
|
||||||
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && !error" class="mb-8">
|
||||||
<div class="w-full overflow-x-auto">
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-no-wrap">
|
<thead>
|
||||||
<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">
|
||||||
<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">
|
||||||
<th class="px-4 py-3 w-10">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
:checked="allSelected"
|
||||||
:checked="allSelected"
|
:indeterminate="someSelected"
|
||||||
:indeterminate="someSelected"
|
@click="toggleSelectAll()"
|
||||||
@click="toggleSelectAll()"
|
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th class="px-4 py-3">Order #</th>
|
||||||
<th class="px-4 py-3">Order #</th>
|
<th class="px-4 py-3">Customer</th>
|
||||||
<th class="px-4 py-3">Customer</th>
|
<th class="px-4 py-3">Date</th>
|
||||||
<th class="px-4 py-3">Date</th>
|
<th class="px-4 py-3">Total</th>
|
||||||
<th class="px-4 py-3">Total</th>
|
<th class="px-4 py-3">Status</th>
|
||||||
<th class="px-4 py-3">Status</th>
|
<th class="px-4 py-3">Actions</th>
|
||||||
<th class="px-4 py-3">Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<template x-for="order in orders" :key="order.id">
|
||||||
<template x-for="order in orders" :key="order.id">
|
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(order.id)}">
|
||||||
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(order.id)}">
|
<!-- Checkbox -->
|
||||||
<!-- Checkbox -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
:checked="isSelected(order.id)"
|
||||||
:checked="isSelected(order.id)"
|
@click="toggleSelect(order.id)"
|
||||||
@click="toggleSelect(order.id)"
|
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
||||||
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
|
/>
|
||||||
/>
|
</td>
|
||||||
</td>
|
<!-- Order Number -->
|
||||||
<!-- Order Number -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<span class="font-mono font-semibold" x-text="order.order_number || `#${order.id}`"></span>
|
||||||
<span class="font-mono font-semibold" x-text="order.order_number || `#${order.id}`"></span>
|
</td>
|
||||||
</td>
|
<!-- Customer -->
|
||||||
<!-- Customer -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="text-sm">
|
||||||
<div class="text-sm">
|
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
|
||||||
<p class="font-medium" x-text="order.customer_name || 'Guest'"></p>
|
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="order.customer_email || ''"></p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="order.customer_email || ''"></p>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<!-- Date -->
|
||||||
<!-- Date -->
|
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)"></td>
|
||||||
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)"></td>
|
<!-- Total -->
|
||||||
<!-- Total -->
|
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(order.total)"></td>
|
||||||
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(order.total)"></td>
|
<!-- Status -->
|
||||||
<!-- Status -->
|
<td class="px-4 py-3 text-xs">
|
||||||
<td class="px-4 py-3 text-xs">
|
<span
|
||||||
<span
|
:class="{
|
||||||
:class="{
|
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
||||||
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStatusColor(order.status) === 'yellow',
|
||||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': getStatusColor(order.status) === 'yellow',
|
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': getStatusColor(order.status) === 'blue',
|
||||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': getStatusColor(order.status) === 'blue',
|
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': getStatusColor(order.status) === 'orange',
|
||||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': getStatusColor(order.status) === 'orange',
|
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStatusColor(order.status) === 'green',
|
||||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': getStatusColor(order.status) === 'green',
|
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStatusColor(order.status) === 'red',
|
||||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': getStatusColor(order.status) === 'red',
|
'text-indigo-700 bg-indigo-100 dark:bg-indigo-700 dark:text-indigo-100': getStatusColor(order.status) === 'indigo',
|
||||||
'text-indigo-700 bg-indigo-100 dark:bg-indigo-700 dark:text-indigo-100': getStatusColor(order.status) === 'indigo',
|
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order.status) === 'gray'
|
||||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': getStatusColor(order.status) === 'gray'
|
}"
|
||||||
}"
|
x-text="getStatusLabel(order.status)"
|
||||||
x-text="getStatusLabel(order.status)"
|
></span>
|
||||||
></span>
|
</td>
|
||||||
</td>
|
<!-- Actions -->
|
||||||
<!-- Actions -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
<div class="flex items-center space-x-2 text-sm">
|
<button
|
||||||
<button
|
@click="viewOrder(order)"
|
||||||
@click="viewOrder(order)"
|
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
title="View Details"
|
||||||
title="View Details"
|
>
|
||||||
>
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
@click="openStatusModal(order)"
|
||||||
@click="openStatusModal(order)"
|
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
title="Update Status"
|
||||||
title="Update Status"
|
>
|
||||||
>
|
<span x-html="$icon('pencil-square', 'w-5 h-5')"></span>
|
||||||
<span x-html="$icon('pencil-square', 'w-5 h-5')"></span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</template>
|
||||||
</template>
|
<!-- Empty State -->
|
||||||
<!-- Empty State -->
|
<tr x-show="orders.length === 0">
|
||||||
<tr x-show="orders.length === 0">
|
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
<div class="flex flex-col items-center">
|
||||||
<div class="flex flex-col items-center">
|
<span x-html="$icon('document-text', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||||
<span x-html="$icon('document-text', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
<p class="text-lg font-medium">No orders found</p>
|
||||||
<p class="text-lg font-medium">No orders found</p>
|
<p class="text-sm">Orders will appear here when customers make purchases</p>
|
||||||
<p class="text-sm">Orders will appear here when customers make purchases</p>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
{% endcall %}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- 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('marketplace', 'Marketplace', tab_var='activeSection', icon='shopping-cart') }}
|
||||||
{{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }}
|
{{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }}
|
||||||
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
|
{{ 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('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
|
||||||
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
|
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
|
||||||
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
|
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
|
||||||
@@ -591,6 +592,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boost Sort -->
|
<!-- Boost Sort -->
|
||||||
|
{# noqa: FE-008 - Decimal input with 0.1 step and custom @input handler, not suited for number_stepper #}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Boost Sort Priority
|
Boost Sort Priority
|
||||||
@@ -803,6 +805,398 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Domains Settings -->
|
||||||
<div x-show="activeSection === 'domains'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<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">
|
<div class="p-4 border-b dark:border-gray-700">
|
||||||
|
|||||||
185
app/templates/vendor/team.html
vendored
185
app/templates/vendor/team.html
vendored
@@ -3,6 +3,7 @@
|
|||||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||||
|
|
||||||
{% block title %}Team{% endblock %}
|
{% block title %}Team{% endblock %}
|
||||||
|
|
||||||
@@ -64,100 +65,98 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team Members Table -->
|
<!-- Team Members Table -->
|
||||||
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
<div x-show="!loading && !error" class="mb-8">
|
||||||
<div class="w-full overflow-x-auto">
|
{% call table_wrapper() %}
|
||||||
<table class="w-full whitespace-no-wrap">
|
<thead>
|
||||||
<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">
|
||||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
<th class="px-4 py-3">Member</th>
|
||||||
<th class="px-4 py-3">Member</th>
|
<th class="px-4 py-3">Role</th>
|
||||||
<th class="px-4 py-3">Role</th>
|
<th class="px-4 py-3">Status</th>
|
||||||
<th class="px-4 py-3">Status</th>
|
<th class="px-4 py-3">Joined</th>
|
||||||
<th class="px-4 py-3">Joined</th>
|
<th class="px-4 py-3">Actions</th>
|
||||||
<th class="px-4 py-3">Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<template x-for="member in members" :key="member.user_id">
|
||||||
<template x-for="member in members" :key="member.user_id">
|
<tr class="text-gray-700 dark:text-gray-400">
|
||||||
<tr class="text-gray-700 dark:text-gray-400">
|
<!-- Member Info -->
|
||||||
<!-- Member Info -->
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="flex items-center text-sm">
|
||||||
<div class="flex items-center text-sm">
|
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(member)"></span>
|
||||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(member)"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold" x-text="`${member.first_name || ''} ${member.last_name || ''}`.trim() || member.email"></p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<!-- Role -->
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100"
|
|
||||||
x-text="getRoleName(member)"
|
|
||||||
></span>
|
|
||||||
</td>
|
|
||||||
<!-- Status -->
|
|
||||||
<td class="px-4 py-3 text-xs">
|
|
||||||
<span
|
|
||||||
:class="{
|
|
||||||
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
|
||||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': member.is_active && !member.invitation_pending,
|
|
||||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': member.invitation_pending,
|
|
||||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !member.is_active
|
|
||||||
}"
|
|
||||||
x-text="member.invitation_pending ? 'Pending' : (member.is_active ? 'Active' : 'Inactive')"
|
|
||||||
></span>
|
|
||||||
</td>
|
|
||||||
<!-- Joined -->
|
|
||||||
<td class="px-4 py-3 text-sm" x-text="formatDate(member.joined_at)"></td>
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center space-x-2 text-sm">
|
|
||||||
<!-- Edit button - not for owners -->
|
|
||||||
<button
|
|
||||||
@click="openEditModal(member)"
|
|
||||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
|
||||||
title="Edit"
|
|
||||||
x-show="!member.is_owner"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
<!-- Owner badge -->
|
|
||||||
<span x-show="member.is_owner" class="text-xs text-gray-400 dark:text-gray-500 italic">Owner</span>
|
|
||||||
<!-- Remove button - not for owners -->
|
|
||||||
<button
|
|
||||||
@click="confirmRemove(member)"
|
|
||||||
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
|
||||||
title="Remove"
|
|
||||||
x-show="!member.is_owner"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<!-- Empty State -->
|
|
||||||
<tr x-show="members.length === 0">
|
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
|
||||||
<p class="text-lg font-medium">No team members yet</p>
|
|
||||||
<p class="text-sm">Invite your first team member to get started</p>
|
|
||||||
<button
|
|
||||||
@click="openInviteModal()"
|
|
||||||
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
|
||||||
>
|
|
||||||
Invite Member
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div>
|
||||||
</tr>
|
<p class="font-semibold" x-text="`${member.first_name || ''} ${member.last_name || ''}`.trim() || member.email"></p>
|
||||||
</tbody>
|
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"></p>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Role -->
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100"
|
||||||
|
x-text="getRoleName(member)"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<!-- Status -->
|
||||||
|
<td class="px-4 py-3 text-xs">
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'px-2 py-1 font-semibold leading-tight rounded-full': true,
|
||||||
|
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': member.is_active && !member.invitation_pending,
|
||||||
|
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': member.invitation_pending,
|
||||||
|
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !member.is_active
|
||||||
|
}"
|
||||||
|
x-text="member.invitation_pending ? 'Pending' : (member.is_active ? 'Active' : 'Inactive')"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<!-- Joined -->
|
||||||
|
<td class="px-4 py-3 text-sm" x-text="formatDate(member.joined_at)"></td>
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
|
<!-- Edit button - not for owners -->
|
||||||
|
<button
|
||||||
|
@click="openEditModal(member)"
|
||||||
|
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
|
title="Edit"
|
||||||
|
x-show="!member.is_owner"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Owner badge -->
|
||||||
|
<span x-show="member.is_owner" class="text-xs text-gray-400 dark:text-gray-500 italic">Owner</span>
|
||||||
|
<!-- Remove button - not for owners -->
|
||||||
|
<button
|
||||||
|
@click="confirmRemove(member)"
|
||||||
|
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||||
|
title="Remove"
|
||||||
|
x-show="!member.is_owner"
|
||||||
|
>
|
||||||
|
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<!-- Empty State -->
|
||||||
|
<tr x-show="members.length === 0">
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||||
|
<p class="text-lg font-medium">No team members yet</p>
|
||||||
|
<p class="text-sm">Invite your first team member to get started</p>
|
||||||
|
<button
|
||||||
|
@click="openInviteModal()"
|
||||||
|
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Invite Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invite Modal -->
|
<!-- Invite Modal -->
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .customer import Customer, CustomerAddress
|
|||||||
from .password_reset_token import PasswordResetToken
|
from .password_reset_token import PasswordResetToken
|
||||||
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||||
from .vendor_email_template import VendorEmailTemplate
|
from .vendor_email_template import VendorEmailTemplate
|
||||||
|
from .vendor_email_settings import EmailProvider, VendorEmailSettings, PREMIUM_EMAIL_PROVIDERS
|
||||||
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
|
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
|
||||||
from .inventory import Inventory
|
from .inventory import Inventory
|
||||||
from .inventory_transaction import InventoryTransaction, TransactionType
|
from .inventory_transaction import InventoryTransaction, TransactionType
|
||||||
@@ -114,6 +115,9 @@ __all__ = [
|
|||||||
"EmailStatus",
|
"EmailStatus",
|
||||||
"EmailTemplate",
|
"EmailTemplate",
|
||||||
"VendorEmailTemplate",
|
"VendorEmailTemplate",
|
||||||
|
"VendorEmailSettings",
|
||||||
|
"EmailProvider",
|
||||||
|
"PREMIUM_EMAIL_PROVIDERS",
|
||||||
# Features
|
# Features
|
||||||
"Feature",
|
"Feature",
|
||||||
"FeatureCategory",
|
"FeatureCategory",
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ class Vendor(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
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 (one-to-one)
|
||||||
subscription = relationship(
|
subscription = relationship(
|
||||||
"VendorSubscription",
|
"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_colissimo_label_url: '',
|
||||||
carrier_xpresslogistics_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() {
|
async init() {
|
||||||
// Guard against multiple initialization
|
// Guard against multiple initialization
|
||||||
@@ -50,7 +89,8 @@ function adminSettings() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadDisplaySettings(),
|
this.loadDisplaySettings(),
|
||||||
this.loadLogSettings(),
|
this.loadLogSettings(),
|
||||||
this.loadShippingSettings()
|
this.loadShippingSettings(),
|
||||||
|
this.loadEmailSettings()
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
settingsLog.error('Init failed:', error);
|
settingsLog.error('Init failed:', error);
|
||||||
@@ -64,7 +104,8 @@ function adminSettings() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadDisplaySettings(),
|
this.loadDisplaySettings(),
|
||||||
this.loadLogSettings(),
|
this.loadLogSettings(),
|
||||||
this.loadShippingSettings()
|
this.loadShippingSettings(),
|
||||||
|
this.loadEmailSettings()
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -266,6 +307,190 @@ function adminSettings() {
|
|||||||
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
|
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
|
||||||
if (!prefix || !shipmentNumber) return null;
|
if (!prefix || !shipmentNumber) return null;
|
||||||
return prefix + shipmentNumber;
|
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) {
|
formatNumber(num) {
|
||||||
if (num === null || num === undefined) return '0';
|
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) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return '-';
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -199,9 +200,11 @@ function vendorBilling() {
|
|||||||
formatCurrency(cents, currency = 'EUR') {
|
formatCurrency(cents, currency = 'EUR') {
|
||||||
if (cents === null || cents === undefined) return '-';
|
if (cents === null || cents === undefined) return '-';
|
||||||
const amount = cents / 100;
|
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',
|
style: 'currency',
|
||||||
currency: currency
|
currency: currencyCode
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
9
static/vendor/js/customers.js
vendored
9
static/vendor/js/customers.js
vendored
@@ -264,7 +264,8 @@ function vendorCustomers() {
|
|||||||
*/
|
*/
|
||||||
formatDate(dateStr) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
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',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -276,9 +277,11 @@ function vendorCustomers() {
|
|||||||
*/
|
*/
|
||||||
formatPrice(cents) {
|
formatPrice(cents) {
|
||||||
if (!cents && cents !== 0) return '-';
|
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',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: currency
|
||||||
}).format(cents / 100);
|
}).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) {
|
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',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: currency
|
||||||
}).format(amount || 0);
|
}).format(amount || 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
|
|||||||
51
static/vendor/js/init-alpine.js
vendored
51
static/vendor/js/init-alpine.js
vendored
@@ -218,4 +218,53 @@ function languageSelector(currentLang, enabledLanguages) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.languageSelector = languageSelector;
|
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) {
|
formatNumber(num) {
|
||||||
if (num === null || num === undefined) return '0';
|
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) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
const date = new Date(dateStr);
|
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',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
@@ -392,9 +393,11 @@ function vendorInvoices() {
|
|||||||
formatCurrency(cents, currency = 'EUR') {
|
formatCurrency(cents, currency = 'EUR') {
|
||||||
if (cents === null || cents === undefined) return 'N/A';
|
if (cents === null || cents === undefined) return 'N/A';
|
||||||
const amount = cents / 100;
|
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',
|
style: 'currency',
|
||||||
currency: currency
|
currency: currencyCode
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
3
static/vendor/js/letzshop.js
vendored
3
static/vendor/js/letzshop.js
vendored
@@ -416,7 +416,8 @@ function vendorLetzshop() {
|
|||||||
formatDate(dateStr) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
const date = new Date(dateStr);
|
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 {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString('en-US', {
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
return date.toLocaleString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
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,
|
loadingMessages: false,
|
||||||
sendingMessage: false,
|
sendingMessage: false,
|
||||||
creatingConversation: false,
|
creatingConversation: false,
|
||||||
|
error: '',
|
||||||
|
|
||||||
// Conversations state
|
// Conversations state
|
||||||
conversations: [],
|
conversations: [],
|
||||||
@@ -384,7 +385,8 @@ function vendorMessages(initialConversationId = null) {
|
|||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||||
if (diff < 172800) return 'Yesterday';
|
if (diff < 172800) return 'Yesterday';
|
||||||
return date.toLocaleDateString();
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
return date.toLocaleDateString(locale);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatTime(dateString) {
|
formatTime(dateString) {
|
||||||
@@ -392,11 +394,12 @@ function vendorMessages(initialConversationId = null) {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
|
||||||
if (isToday) {
|
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';
|
if (diff < 172800) return 'Yesterday';
|
||||||
|
|
||||||
// Show full date for older dates
|
// Show full date for older dates
|
||||||
return date.toLocaleDateString();
|
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||||
|
return date.toLocaleDateString(locale);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pagination methods
|
// 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) {
|
formatPrice(cents) {
|
||||||
if (cents === null || cents === undefined) return '-';
|
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',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: currency
|
||||||
}).format(cents / 100);
|
}).format(cents / 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -199,7 +201,8 @@ function vendorOrderDetail() {
|
|||||||
*/
|
*/
|
||||||
formatDateTime(dateStr) {
|
formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
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',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
9
static/vendor/js/orders.js
vendored
9
static/vendor/js/orders.js
vendored
@@ -312,9 +312,11 @@ function vendorOrders() {
|
|||||||
*/
|
*/
|
||||||
formatPrice(cents) {
|
formatPrice(cents) {
|
||||||
if (!cents && cents !== 0) return '-';
|
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',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: currency
|
||||||
}).format(cents / 100);
|
}).format(cents / 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -323,7 +325,8 @@ function vendorOrders() {
|
|||||||
*/
|
*/
|
||||||
formatDate(dateStr) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
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',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
6
static/vendor/js/products.js
vendored
6
static/vendor/js/products.js
vendored
@@ -321,9 +321,11 @@ function vendorProducts() {
|
|||||||
*/
|
*/
|
||||||
formatPrice(cents) {
|
formatPrice(cents) {
|
||||||
if (!cents && cents !== 0) return '-';
|
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',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: currency
|
||||||
}).format(cents / 100);
|
}).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: 'branding', label: 'Branding', icon: 'color-swatch' },
|
||||||
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
|
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
|
||||||
{ id: 'api', label: 'API & Payments', icon: 'key' },
|
{ 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
|
// Forms for different sections
|
||||||
@@ -95,6 +96,38 @@ function vendorSettings() {
|
|||||||
storefront_locale: ''
|
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
|
// Track changes per section
|
||||||
hasChanges: false,
|
hasChanges: false,
|
||||||
hasBusinessChanges: false,
|
hasBusinessChanges: false,
|
||||||
@@ -383,6 +416,161 @@ function vendorSettings() {
|
|||||||
} finally {
|
} finally {
|
||||||
this.saving = false;
|
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) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
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',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
|
|||||||
Reference in New Issue
Block a user