feat: add mandatory vendor onboarding wizard
Implement 4-step onboarding flow for new vendors after signup: - Step 1: Company profile setup - Step 2: Letzshop API configuration with connection testing - Step 3: Product & order import CSV URL configuration - Step 4: Historical order sync with progress bar Key features: - Blocks dashboard access until completed - Step indicators with visual progress - Resume capability (progress persisted in DB) - Admin skip capability for support cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
72
alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py
Normal file
72
alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""add vendor onboarding table
|
||||
|
||||
Revision ID: m1b2c3d4e5f6
|
||||
Revises: d7a4a3f06394
|
||||
Create Date: 2025-12-27 22:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'm1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = 'd7a4a3f06394'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('vendor_onboarding',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
# Overall status
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'),
|
||||
sa.Column('current_step', sa.String(length=30), nullable=False, server_default='company_profile'),
|
||||
# Step 1: Company Profile
|
||||
sa.Column('step_company_profile_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('step_company_profile_completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('step_company_profile_data', sa.JSON(), nullable=True),
|
||||
# Step 2: Letzshop API Configuration
|
||||
sa.Column('step_letzshop_api_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('step_letzshop_api_completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('step_letzshop_api_connection_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
# Step 3: Product Import
|
||||
sa.Column('step_product_import_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('step_product_import_completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('step_product_import_csv_url_set', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
# Step 4: Order Sync
|
||||
sa.Column('step_order_sync_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('step_order_sync_completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('step_order_sync_job_id', sa.Integer(), nullable=True),
|
||||
# Completion tracking
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
# Admin override
|
||||
sa.Column('skipped_by_admin', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('skipped_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('skipped_reason', sa.Text(), nullable=True),
|
||||
sa.Column('skipped_by_user_id', sa.Integer(), nullable=True),
|
||||
# Timestamps
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
# Constraints
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['skipped_by_user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sqlite_autoincrement=True
|
||||
)
|
||||
op.create_index(op.f('ix_vendor_onboarding_id'), 'vendor_onboarding', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vendor_onboarding_vendor_id'), 'vendor_onboarding', ['vendor_id'], unique=True)
|
||||
op.create_index(op.f('ix_vendor_onboarding_status'), 'vendor_onboarding', ['status'], unique=False)
|
||||
op.create_index('idx_onboarding_vendor_status', 'vendor_onboarding', ['vendor_id', 'status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_onboarding_vendor_status', table_name='vendor_onboarding')
|
||||
op.drop_index(op.f('ix_vendor_onboarding_status'), table_name='vendor_onboarding')
|
||||
op.drop_index(op.f('ix_vendor_onboarding_vendor_id'), table_name='vendor_onboarding')
|
||||
op.drop_index(op.f('ix_vendor_onboarding_id'), table_name='vendor_onboarding')
|
||||
op.drop_table('vendor_onboarding')
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -28,6 +28,7 @@ from . import (
|
||||
media,
|
||||
messages,
|
||||
notifications,
|
||||
onboarding,
|
||||
order_item_exceptions,
|
||||
orders,
|
||||
payments,
|
||||
@@ -56,6 +57,7 @@ router.include_router(auth.router, tags=["vendor-auth"])
|
||||
router.include_router(dashboard.router, tags=["vendor-dashboard"])
|
||||
router.include_router(profile.router, tags=["vendor-profile"])
|
||||
router.include_router(settings.router, tags=["vendor-settings"])
|
||||
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
||||
|
||||
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
||||
router.include_router(products.router, tags=["vendor-products"])
|
||||
|
||||
294
app/api/v1/vendor/onboarding.py
vendored
Normal file
294
app/api/v1/vendor/onboarding.py
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
# app/api/v1/vendor/onboarding.py
|
||||
"""
|
||||
Vendor onboarding API endpoints.
|
||||
|
||||
Provides endpoints for the 4-step mandatory onboarding wizard:
|
||||
1. Company Profile Setup
|
||||
2. Letzshop API Configuration
|
||||
3. Product & Order Import Configuration
|
||||
4. Order Sync (historical import)
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.onboarding_service import (
|
||||
OnboardingError,
|
||||
OnboardingService,
|
||||
OnboardingStepError,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.onboarding import (
|
||||
CompanyProfileRequest,
|
||||
CompanyProfileResponse,
|
||||
LetzshopApiConfigRequest,
|
||||
LetzshopApiConfigResponse,
|
||||
LetzshopApiTestRequest,
|
||||
LetzshopApiTestResponse,
|
||||
OnboardingStatusResponse,
|
||||
OrderSyncCompleteRequest,
|
||||
OrderSyncCompleteResponse,
|
||||
OrderSyncProgressResponse,
|
||||
OrderSyncTriggerRequest,
|
||||
OrderSyncTriggerResponse,
|
||||
ProductImportConfigRequest,
|
||||
ProductImportConfigResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/onboarding")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Status Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/status", response_model=OnboardingStatusResponse)
|
||||
def get_onboarding_status(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current onboarding status.
|
||||
|
||||
Returns full status including all step completion states and progress.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
status = service.get_status_response(current_user.token_vendor_id)
|
||||
return status
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: Company Profile
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/step/company-profile")
|
||||
def get_company_profile(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current company profile data for editing.
|
||||
|
||||
Returns pre-filled data from vendor and company records.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
return service.get_company_profile_data(current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.post("/step/company-profile", response_model=CompanyProfileResponse)
|
||||
def save_company_profile(
|
||||
request: CompanyProfileRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Save company profile and complete Step 1.
|
||||
|
||||
Updates vendor and company records with provided data.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
|
||||
try:
|
||||
result = service.complete_company_profile(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
company_name=request.company_name,
|
||||
brand_name=request.brand_name,
|
||||
description=request.description,
|
||||
contact_email=request.contact_email,
|
||||
contact_phone=request.contact_phone,
|
||||
website=request.website,
|
||||
business_address=request.business_address,
|
||||
tax_number=request.tax_number,
|
||||
default_language=request.default_language,
|
||||
dashboard_language=request.dashboard_language,
|
||||
)
|
||||
return result
|
||||
except OnboardingError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Letzshop API Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse)
|
||||
def test_letzshop_api(
|
||||
request: LetzshopApiTestRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Test Letzshop API connection without saving.
|
||||
|
||||
Use this to validate API key before saving credentials.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
return service.test_letzshop_api(
|
||||
api_key=request.api_key,
|
||||
shop_slug=request.shop_slug,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse)
|
||||
def save_letzshop_api(
|
||||
request: LetzshopApiConfigRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Save Letzshop API credentials and complete Step 2.
|
||||
|
||||
Tests connection first, only saves if successful.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
|
||||
try:
|
||||
result = service.complete_letzshop_api(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
api_key=request.api_key,
|
||||
shop_slug=request.shop_slug,
|
||||
letzshop_vendor_id=request.vendor_id,
|
||||
)
|
||||
return result
|
||||
except OnboardingStepError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OnboardingError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: Product & Order Import Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/step/product-import")
|
||||
def get_product_import_config(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get current product import configuration.
|
||||
|
||||
Returns pre-filled CSV URLs and Letzshop feed settings.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
return service.get_product_import_config(current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.post("/step/product-import", response_model=ProductImportConfigResponse)
|
||||
def save_product_import_config(
|
||||
request: ProductImportConfigRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Save product import configuration and complete Step 3.
|
||||
|
||||
At least one CSV URL must be provided.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
|
||||
try:
|
||||
result = service.complete_product_import(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
csv_url_fr=request.csv_url_fr,
|
||||
csv_url_en=request.csv_url_en,
|
||||
csv_url_de=request.csv_url_de,
|
||||
default_tax_rate=request.default_tax_rate,
|
||||
delivery_method=request.delivery_method,
|
||||
preorder_days=request.preorder_days,
|
||||
)
|
||||
return result
|
||||
except OnboardingStepError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OnboardingError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: Order Sync
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse)
|
||||
def trigger_order_sync(
|
||||
request: OrderSyncTriggerRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Trigger historical order import.
|
||||
|
||||
Creates a background job that imports orders from Letzshop.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
|
||||
try:
|
||||
result = service.trigger_order_sync(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
user_id=current_user.id,
|
||||
days_back=request.days_back,
|
||||
include_products=request.include_products,
|
||||
)
|
||||
return result
|
||||
except OnboardingStepError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OnboardingError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/step/order-sync/progress/{job_id}",
|
||||
response_model=OrderSyncProgressResponse,
|
||||
)
|
||||
def get_order_sync_progress(
|
||||
job_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get order sync job progress.
|
||||
|
||||
Poll this endpoint to show progress bar during import.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
return service.get_order_sync_progress(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse)
|
||||
def complete_order_sync(
|
||||
request: OrderSyncCompleteRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Mark order sync step as complete.
|
||||
|
||||
Called after the import job finishes (success or failure).
|
||||
This also marks the entire onboarding as complete.
|
||||
"""
|
||||
service = OnboardingService(db)
|
||||
|
||||
try:
|
||||
result = service.complete_order_sync(
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
job_id=request.job_id,
|
||||
)
|
||||
return result
|
||||
except OnboardingStepError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OnboardingError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -11,7 +11,8 @@ Authentication failures redirect to /vendor/{vendor_code}/login.
|
||||
Routes:
|
||||
- GET /vendor/{vendor_code}/ → Redirect to login or dashboard
|
||||
- GET /vendor/{vendor_code}/login → Vendor login page
|
||||
- GET /vendor/{vendor_code}/dashboard → Vendor dashboard
|
||||
- GET /vendor/{vendor_code}/onboarding → Vendor onboarding wizard
|
||||
- GET /vendor/{vendor_code}/dashboard → Vendor dashboard (requires onboarding)
|
||||
- GET /vendor/{vendor_code}/products → Product management
|
||||
- GET /vendor/{vendor_code}/orders → Order management
|
||||
- GET /vendor/{vendor_code}/customers → Customer management
|
||||
@@ -34,6 +35,7 @@ from app.api.deps import (
|
||||
get_db,
|
||||
)
|
||||
from app.services.content_page_service import content_page_service
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -111,6 +113,44 @@ async def vendor_login_page(
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/onboarding", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_onboarding_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor onboarding wizard.
|
||||
|
||||
Mandatory 4-step wizard that must be completed before accessing dashboard:
|
||||
1. Company Profile Setup
|
||||
2. Letzshop API Configuration
|
||||
3. Product & Order Import Configuration
|
||||
4. Order Sync (historical import)
|
||||
|
||||
If onboarding is already completed, redirects to dashboard.
|
||||
"""
|
||||
# Check if onboarding is completed
|
||||
onboarding_service = OnboardingService(db)
|
||||
if onboarding_service.is_completed(current_user.token_vendor_id):
|
||||
return RedirectResponse(
|
||||
url=f"/vendor/{vendor_code}/dashboard",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"vendor/onboarding.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"vendor_code": vendor_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
@@ -118,16 +158,27 @@ async def vendor_dashboard_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render vendor dashboard.
|
||||
|
||||
Redirects to onboarding if not completed.
|
||||
|
||||
JavaScript will:
|
||||
- Load vendor info via API
|
||||
- Load dashboard stats via API
|
||||
- Load recent orders via API
|
||||
- Handle all interactivity
|
||||
"""
|
||||
# Check if onboarding is completed
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_vendor_id):
|
||||
return RedirectResponse(
|
||||
url=f"/vendor/{vendor_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"vendor/dashboard.html",
|
||||
{
|
||||
|
||||
676
app/services/onboarding_service.py
Normal file
676
app/services/onboarding_service.py
Normal file
@@ -0,0 +1,676 @@
|
||||
# app/services/onboarding_service.py
|
||||
"""
|
||||
Vendor onboarding service.
|
||||
|
||||
Handles the 4-step mandatory onboarding wizard for new vendors:
|
||||
1. Company Profile Setup
|
||||
2. Letzshop API Configuration
|
||||
3. Product & Order Import (CSV feed URL configuration)
|
||||
4. Order Sync (historical import with progress tracking)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
||||
from app.services.letzshop.order_service import LetzshopOrderService
|
||||
from models.database.company import Company
|
||||
from models.database.letzshop import LetzshopHistoricalImportJob
|
||||
from models.database.onboarding import (
|
||||
OnboardingStatus,
|
||||
OnboardingStep,
|
||||
VendorOnboarding,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OnboardingError(Exception):
|
||||
"""Base exception for onboarding errors."""
|
||||
|
||||
|
||||
class OnboardingNotFoundError(OnboardingError):
|
||||
"""Raised when onboarding record is not found."""
|
||||
|
||||
|
||||
class OnboardingStepError(OnboardingError):
|
||||
"""Raised when a step operation fails."""
|
||||
|
||||
|
||||
class OnboardingService:
|
||||
"""
|
||||
Service for managing vendor onboarding workflow.
|
||||
|
||||
Provides methods for each onboarding step and progress tracking.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize the onboarding service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# =========================================================================
|
||||
# Onboarding CRUD
|
||||
# =========================================================================
|
||||
|
||||
def get_onboarding(self, vendor_id: int) -> VendorOnboarding | None:
|
||||
"""Get onboarding record for a vendor."""
|
||||
return (
|
||||
self.db.query(VendorOnboarding)
|
||||
.filter(VendorOnboarding.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_onboarding_or_raise(self, vendor_id: int) -> VendorOnboarding:
|
||||
"""Get onboarding record or raise OnboardingNotFoundError."""
|
||||
onboarding = self.get_onboarding(vendor_id)
|
||||
if onboarding is None:
|
||||
raise OnboardingNotFoundError(
|
||||
f"Onboarding not found for vendor {vendor_id}"
|
||||
)
|
||||
return onboarding
|
||||
|
||||
def create_onboarding(self, vendor_id: int) -> VendorOnboarding:
|
||||
"""
|
||||
Create a new onboarding record for a vendor.
|
||||
|
||||
This is called automatically when a vendor is created during signup.
|
||||
"""
|
||||
# Check if already exists
|
||||
existing = self.get_onboarding(vendor_id)
|
||||
if existing:
|
||||
logger.warning(f"Onboarding already exists for vendor {vendor_id}")
|
||||
return existing
|
||||
|
||||
onboarding = VendorOnboarding(
|
||||
vendor_id=vendor_id,
|
||||
status=OnboardingStatus.NOT_STARTED.value,
|
||||
current_step=OnboardingStep.COMPANY_PROFILE.value,
|
||||
)
|
||||
|
||||
self.db.add(onboarding)
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Created onboarding record for vendor {vendor_id}")
|
||||
return onboarding
|
||||
|
||||
def get_or_create_onboarding(self, vendor_id: int) -> VendorOnboarding:
|
||||
"""Get existing onboarding or create new one."""
|
||||
onboarding = self.get_onboarding(vendor_id)
|
||||
if onboarding is None:
|
||||
onboarding = self.create_onboarding(vendor_id)
|
||||
return onboarding
|
||||
|
||||
# =========================================================================
|
||||
# Status Helpers
|
||||
# =========================================================================
|
||||
|
||||
def is_completed(self, vendor_id: int) -> bool:
|
||||
"""Check if onboarding is completed for a vendor."""
|
||||
onboarding = self.get_onboarding(vendor_id)
|
||||
if onboarding is None:
|
||||
return False
|
||||
return onboarding.is_completed
|
||||
|
||||
def get_status_response(self, vendor_id: int) -> dict:
|
||||
"""
|
||||
Get full onboarding status for API response.
|
||||
|
||||
Returns a dictionary with all step statuses and progress information.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
return {
|
||||
"id": onboarding.id,
|
||||
"vendor_id": onboarding.vendor_id,
|
||||
"status": onboarding.status,
|
||||
"current_step": onboarding.current_step,
|
||||
# Step statuses
|
||||
"company_profile": {
|
||||
"completed": onboarding.step_company_profile_completed,
|
||||
"completed_at": onboarding.step_company_profile_completed_at,
|
||||
"data": onboarding.step_company_profile_data,
|
||||
},
|
||||
"letzshop_api": {
|
||||
"completed": onboarding.step_letzshop_api_completed,
|
||||
"completed_at": onboarding.step_letzshop_api_completed_at,
|
||||
"connection_verified": onboarding.step_letzshop_api_connection_verified,
|
||||
},
|
||||
"product_import": {
|
||||
"completed": onboarding.step_product_import_completed,
|
||||
"completed_at": onboarding.step_product_import_completed_at,
|
||||
"csv_url_set": onboarding.step_product_import_csv_url_set,
|
||||
},
|
||||
"order_sync": {
|
||||
"completed": onboarding.step_order_sync_completed,
|
||||
"completed_at": onboarding.step_order_sync_completed_at,
|
||||
"job_id": onboarding.step_order_sync_job_id,
|
||||
},
|
||||
# Progress tracking
|
||||
"completion_percentage": onboarding.completion_percentage,
|
||||
"completed_steps_count": onboarding.completed_steps_count,
|
||||
"total_steps": 4,
|
||||
# Completion info
|
||||
"is_completed": onboarding.is_completed,
|
||||
"started_at": onboarding.started_at,
|
||||
"completed_at": onboarding.completed_at,
|
||||
# Admin override info
|
||||
"skipped_by_admin": onboarding.skipped_by_admin,
|
||||
"skipped_at": onboarding.skipped_at,
|
||||
"skipped_reason": onboarding.skipped_reason,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Step 1: Company Profile
|
||||
# =========================================================================
|
||||
|
||||
def get_company_profile_data(self, vendor_id: int) -> dict:
|
||||
"""Get current company profile data for editing."""
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
return {}
|
||||
|
||||
company = vendor.company
|
||||
|
||||
return {
|
||||
"company_name": company.name if company else None,
|
||||
"brand_name": vendor.name,
|
||||
"description": vendor.description,
|
||||
"contact_email": vendor.effective_contact_email,
|
||||
"contact_phone": vendor.effective_contact_phone,
|
||||
"website": vendor.effective_website,
|
||||
"business_address": vendor.effective_business_address,
|
||||
"tax_number": vendor.effective_tax_number,
|
||||
"default_language": vendor.default_language,
|
||||
"dashboard_language": vendor.dashboard_language,
|
||||
}
|
||||
|
||||
def complete_company_profile(
|
||||
self,
|
||||
vendor_id: int,
|
||||
company_name: str | None = None,
|
||||
brand_name: str | None = None,
|
||||
description: str | None = None,
|
||||
contact_email: str | None = None,
|
||||
contact_phone: str | None = None,
|
||||
website: str | None = None,
|
||||
business_address: str | None = None,
|
||||
tax_number: str | None = None,
|
||||
default_language: str = "fr",
|
||||
dashboard_language: str = "fr",
|
||||
) -> dict:
|
||||
"""
|
||||
Save company profile and mark Step 1 as complete.
|
||||
|
||||
Returns response with next step information.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
# Update onboarding status if this is the first step
|
||||
if onboarding.status == OnboardingStatus.NOT_STARTED.value:
|
||||
onboarding.status = OnboardingStatus.IN_PROGRESS.value
|
||||
onboarding.started_at = datetime.now(UTC)
|
||||
|
||||
# Get vendor and company
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise OnboardingStepError(f"Vendor {vendor_id} not found")
|
||||
|
||||
company = vendor.company
|
||||
|
||||
# Update company name if provided
|
||||
if company and company_name:
|
||||
company.name = company_name
|
||||
|
||||
# Update vendor fields
|
||||
if brand_name:
|
||||
vendor.name = brand_name
|
||||
if description is not None:
|
||||
vendor.description = description
|
||||
|
||||
# Update contact info (vendor-level overrides)
|
||||
vendor.contact_email = contact_email
|
||||
vendor.contact_phone = contact_phone
|
||||
vendor.website = website
|
||||
vendor.business_address = business_address
|
||||
vendor.tax_number = tax_number
|
||||
|
||||
# Update language settings
|
||||
vendor.default_language = default_language
|
||||
vendor.dashboard_language = dashboard_language
|
||||
|
||||
# Store profile data in onboarding record
|
||||
onboarding.step_company_profile_data = {
|
||||
"company_name": company_name,
|
||||
"brand_name": brand_name,
|
||||
"description": description,
|
||||
"contact_email": contact_email,
|
||||
"contact_phone": contact_phone,
|
||||
"website": website,
|
||||
"business_address": business_address,
|
||||
"tax_number": tax_number,
|
||||
"default_language": default_language,
|
||||
"dashboard_language": dashboard_language,
|
||||
}
|
||||
|
||||
# Mark step complete
|
||||
onboarding.mark_step_complete(OnboardingStep.COMPANY_PROFILE.value)
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Completed company profile step for vendor {vendor_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"step_completed": True,
|
||||
"next_step": onboarding.current_step,
|
||||
"message": "Company profile saved successfully",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Step 2: Letzshop API Configuration
|
||||
# =========================================================================
|
||||
|
||||
def test_letzshop_api(
|
||||
self,
|
||||
api_key: str,
|
||||
shop_slug: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Test Letzshop API connection without saving credentials.
|
||||
|
||||
Returns connection test result with vendor info if successful.
|
||||
"""
|
||||
credentials_service = LetzshopCredentialsService(self.db)
|
||||
|
||||
# Test the API key
|
||||
success, response_time, error = credentials_service.test_api_key(api_key)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connection successful ({response_time:.0f}ms)",
|
||||
"vendor_name": None, # Would need to query Letzshop for this
|
||||
"vendor_id": None,
|
||||
"shop_slug": shop_slug,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": error or "Connection failed",
|
||||
"vendor_name": None,
|
||||
"vendor_id": None,
|
||||
"shop_slug": None,
|
||||
}
|
||||
|
||||
def complete_letzshop_api(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str,
|
||||
shop_slug: str,
|
||||
letzshop_vendor_id: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Save Letzshop API credentials and mark Step 2 as complete.
|
||||
|
||||
Tests connection first, only saves if successful.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
# Verify step order
|
||||
if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value):
|
||||
raise OnboardingStepError(
|
||||
"Please complete the Company Profile step first"
|
||||
)
|
||||
|
||||
# Test connection first
|
||||
credentials_service = LetzshopCredentialsService(self.db)
|
||||
success, response_time, error = credentials_service.test_api_key(api_key)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"step_completed": False,
|
||||
"next_step": None,
|
||||
"message": f"Connection test failed: {error}",
|
||||
"connection_verified": False,
|
||||
}
|
||||
|
||||
# Save credentials
|
||||
credentials_service.upsert_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=api_key,
|
||||
auto_sync_enabled=False, # Enable after onboarding
|
||||
sync_interval_minutes=15,
|
||||
)
|
||||
|
||||
# Update vendor with Letzshop identity
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if vendor:
|
||||
vendor.letzshop_vendor_slug = shop_slug
|
||||
if letzshop_vendor_id:
|
||||
vendor.letzshop_vendor_id = letzshop_vendor_id
|
||||
|
||||
# Mark step complete
|
||||
onboarding.step_letzshop_api_connection_verified = True
|
||||
onboarding.mark_step_complete(OnboardingStep.LETZSHOP_API.value)
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Completed Letzshop API step for vendor {vendor_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"step_completed": True,
|
||||
"next_step": onboarding.current_step,
|
||||
"message": "Letzshop API configured successfully",
|
||||
"connection_verified": True,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Step 3: Product & Order Import Configuration
|
||||
# =========================================================================
|
||||
|
||||
def get_product_import_config(self, vendor_id: int) -> dict:
|
||||
"""Get current product import configuration."""
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"csv_url_fr": vendor.letzshop_csv_url_fr,
|
||||
"csv_url_en": vendor.letzshop_csv_url_en,
|
||||
"csv_url_de": vendor.letzshop_csv_url_de,
|
||||
"default_tax_rate": vendor.letzshop_default_tax_rate,
|
||||
"delivery_method": vendor.letzshop_delivery_method,
|
||||
"preorder_days": vendor.letzshop_preorder_days,
|
||||
}
|
||||
|
||||
def complete_product_import(
|
||||
self,
|
||||
vendor_id: int,
|
||||
csv_url_fr: str | None = None,
|
||||
csv_url_en: str | None = None,
|
||||
csv_url_de: str | None = None,
|
||||
default_tax_rate: int = 17,
|
||||
delivery_method: str = "package_delivery",
|
||||
preorder_days: int = 1,
|
||||
) -> dict:
|
||||
"""
|
||||
Save product import configuration and mark Step 3 as complete.
|
||||
|
||||
At least one CSV URL must be provided.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
# Verify step order
|
||||
if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value):
|
||||
raise OnboardingStepError(
|
||||
"Please complete the Letzshop API step first"
|
||||
)
|
||||
|
||||
# Validate at least one CSV URL
|
||||
csv_urls_count = sum([
|
||||
bool(csv_url_fr),
|
||||
bool(csv_url_en),
|
||||
bool(csv_url_de),
|
||||
])
|
||||
|
||||
if csv_urls_count == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"step_completed": False,
|
||||
"next_step": None,
|
||||
"message": "At least one CSV URL must be provided",
|
||||
"csv_urls_configured": 0,
|
||||
}
|
||||
|
||||
# Update vendor settings
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise OnboardingStepError(f"Vendor {vendor_id} not found")
|
||||
|
||||
vendor.letzshop_csv_url_fr = csv_url_fr
|
||||
vendor.letzshop_csv_url_en = csv_url_en
|
||||
vendor.letzshop_csv_url_de = csv_url_de
|
||||
vendor.letzshop_default_tax_rate = default_tax_rate
|
||||
vendor.letzshop_delivery_method = delivery_method
|
||||
vendor.letzshop_preorder_days = preorder_days
|
||||
|
||||
# Mark step complete
|
||||
onboarding.step_product_import_csv_url_set = True
|
||||
onboarding.mark_step_complete(OnboardingStep.PRODUCT_IMPORT.value)
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Completed product import step for vendor {vendor_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"step_completed": True,
|
||||
"next_step": onboarding.current_step,
|
||||
"message": "Product import configured successfully",
|
||||
"csv_urls_configured": csv_urls_count,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Step 4: Order Sync
|
||||
# =========================================================================
|
||||
|
||||
def trigger_order_sync(
|
||||
self,
|
||||
vendor_id: int,
|
||||
user_id: int,
|
||||
days_back: int = 90,
|
||||
include_products: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Trigger historical order import and return job info.
|
||||
|
||||
Creates a background job that imports historical orders from Letzshop.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
# Verify step order
|
||||
if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value):
|
||||
raise OnboardingStepError(
|
||||
"Please complete the Product Import step first"
|
||||
)
|
||||
|
||||
# Create historical import job
|
||||
order_service = LetzshopOrderService(self.db)
|
||||
|
||||
# Check for existing running job
|
||||
existing_job = order_service.get_running_historical_import_job(vendor_id)
|
||||
if existing_job:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Import job already running",
|
||||
"job_id": existing_job.id,
|
||||
"estimated_duration_minutes": 5, # Estimate
|
||||
}
|
||||
|
||||
# Create new job
|
||||
job = order_service.create_historical_import_job(
|
||||
vendor_id=vendor_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Store job ID in onboarding
|
||||
onboarding.step_order_sync_job_id = job.id
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Triggered order sync job {job.id} for vendor {vendor_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Historical import started",
|
||||
"job_id": job.id,
|
||||
"estimated_duration_minutes": 5, # Estimate
|
||||
}
|
||||
|
||||
def get_order_sync_progress(
|
||||
self,
|
||||
vendor_id: int,
|
||||
job_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Get progress of historical import job.
|
||||
|
||||
Returns current status, progress, and counts.
|
||||
"""
|
||||
order_service = LetzshopOrderService(self.db)
|
||||
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
|
||||
|
||||
if not job:
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "not_found",
|
||||
"progress_percentage": 0,
|
||||
"current_phase": None,
|
||||
"orders_imported": 0,
|
||||
"orders_total": None,
|
||||
"products_imported": 0,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"estimated_remaining_seconds": None,
|
||||
"error_message": "Job not found",
|
||||
}
|
||||
|
||||
# Calculate progress percentage
|
||||
progress = 0
|
||||
if job.status == "completed":
|
||||
progress = 100
|
||||
elif job.status == "failed":
|
||||
progress = 0
|
||||
elif job.status == "processing":
|
||||
if job.total_shipments and job.total_shipments > 0:
|
||||
progress = int((job.processed_shipments or 0) / job.total_shipments * 100)
|
||||
else:
|
||||
progress = 50 # Indeterminate
|
||||
|
||||
# Determine current phase
|
||||
current_phase = None
|
||||
if job.status == "fetching":
|
||||
current_phase = "fetching"
|
||||
elif job.status == "processing":
|
||||
current_phase = "orders"
|
||||
elif job.status == "completed":
|
||||
current_phase = "complete"
|
||||
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"status": job.status,
|
||||
"progress_percentage": progress,
|
||||
"current_phase": current_phase,
|
||||
"orders_imported": job.processed_shipments or 0,
|
||||
"orders_total": job.total_shipments,
|
||||
"products_imported": 0, # TODO: Track this
|
||||
"started_at": job.started_at,
|
||||
"completed_at": job.completed_at,
|
||||
"estimated_remaining_seconds": None, # TODO: Calculate
|
||||
"error_message": job.error_message,
|
||||
}
|
||||
|
||||
def complete_order_sync(
|
||||
self,
|
||||
vendor_id: int,
|
||||
job_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Mark order sync step as complete after job finishes.
|
||||
|
||||
Also marks the entire onboarding as complete.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
# Verify job is complete
|
||||
order_service = LetzshopOrderService(self.db)
|
||||
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
|
||||
|
||||
if not job:
|
||||
raise OnboardingStepError(f"Job {job_id} not found")
|
||||
|
||||
if job.status not in ("completed", "failed"):
|
||||
return {
|
||||
"success": False,
|
||||
"step_completed": False,
|
||||
"onboarding_completed": False,
|
||||
"message": f"Job is still {job.status}, please wait",
|
||||
"redirect_url": None,
|
||||
}
|
||||
|
||||
# Mark step complete (even if job failed - they can retry later)
|
||||
onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value)
|
||||
|
||||
# Enable auto-sync now that onboarding is complete
|
||||
credentials_service = LetzshopCredentialsService(self.db)
|
||||
credentials = credentials_service.get_credentials(vendor_id)
|
||||
if credentials:
|
||||
credentials.auto_sync_enabled = True
|
||||
|
||||
self.db.flush()
|
||||
|
||||
# Get vendor code for redirect URL
|
||||
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
vendor_code = vendor.vendor_code if vendor else ""
|
||||
|
||||
logger.info(f"Completed onboarding for vendor {vendor_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"step_completed": True,
|
||||
"onboarding_completed": True,
|
||||
"message": "Onboarding complete! Welcome to Wizamart.",
|
||||
"redirect_url": f"/vendor/{vendor_code}/dashboard",
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Admin Skip
|
||||
# =========================================================================
|
||||
|
||||
def skip_onboarding(
|
||||
self,
|
||||
vendor_id: int,
|
||||
admin_user_id: int,
|
||||
reason: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Admin-only: Skip onboarding for a vendor.
|
||||
|
||||
Used for support cases where manual setup is needed.
|
||||
"""
|
||||
onboarding = self.get_or_create_onboarding(vendor_id)
|
||||
|
||||
onboarding.skipped_by_admin = True
|
||||
onboarding.skipped_at = datetime.now(UTC)
|
||||
onboarding.skipped_reason = reason
|
||||
onboarding.skipped_by_user_id = admin_user_id
|
||||
onboarding.status = OnboardingStatus.SKIPPED.value
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin_user_id} skipped onboarding for vendor {vendor_id}: {reason}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Onboarding skipped by admin",
|
||||
"vendor_id": vendor_id,
|
||||
"skipped_at": onboarding.skipped_at,
|
||||
}
|
||||
|
||||
|
||||
# Singleton-style convenience instance
|
||||
def get_onboarding_service(db: Session) -> OnboardingService:
|
||||
"""Get an OnboardingService instance."""
|
||||
return OnboardingService(db)
|
||||
@@ -23,6 +23,7 @@ from app.exceptions import (
|
||||
ValidationException,
|
||||
)
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
from app.services.stripe_service import stripe_service
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.company import Company
|
||||
@@ -372,6 +373,10 @@ class PlatformSignupService:
|
||||
)
|
||||
db.add(vendor_user)
|
||||
|
||||
# Create VendorOnboarding record
|
||||
onboarding_service = OnboardingService(db)
|
||||
onboarding_service.create_onboarding(vendor.id)
|
||||
|
||||
# Create Stripe Customer
|
||||
stripe_customer_id = stripe_service.create_customer(
|
||||
vendor=vendor,
|
||||
@@ -615,11 +620,12 @@ class PlatformSignupService:
|
||||
|
||||
logger.info(f"Completed signup for vendor {vendor_id}")
|
||||
|
||||
# Redirect to onboarding instead of dashboard
|
||||
return SignupCompletionResult(
|
||||
success=True,
|
||||
vendor_code=vendor_code,
|
||||
vendor_id=vendor_id,
|
||||
redirect_url=f"/vendor/{vendor_code}/dashboard",
|
||||
redirect_url=f"/vendor/{vendor_code}/onboarding",
|
||||
trial_ends_at=trial_ends_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
380
app/templates/vendor/onboarding.html
vendored
Normal file
380
app/templates/vendor/onboarding.html
vendored
Normal file
@@ -0,0 +1,380 @@
|
||||
{# app/templates/vendor/onboarding.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="vendorOnboarding()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to Wizamart - Setup Your Account</title>
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="min-h-screen p-6" x-cloak>
|
||||
<!-- Header -->
|
||||
<div class="max-w-4xl mx-auto mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-600 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-gray-800 dark:text-white">Wizamart</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span x-text="completedSteps"></span> of 4 steps completed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="max-w-4xl mx-auto mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<template x-for="(step, index) in steps" :key="step.id">
|
||||
<div class="flex items-center" :class="{ 'flex-1': index < steps.length - 1 }">
|
||||
<!-- Step Circle -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-200"
|
||||
:class="{
|
||||
'bg-purple-600 text-white': isStepCompleted(step.id) || currentStep === step.id,
|
||||
'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400': !isStepCompleted(step.id) && currentStep !== step.id
|
||||
}">
|
||||
<template x-if="isStepCompleted(step.id)">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="!isStepCompleted(step.id)">
|
||||
<span x-text="index + 1"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="mt-2 text-xs font-medium text-gray-600 dark:text-gray-400 text-center w-24"
|
||||
x-text="step.title"></span>
|
||||
</div>
|
||||
<!-- Connector Line -->
|
||||
<template x-if="index < steps.length - 1">
|
||||
<div class="flex-1 h-1 mx-4 rounded"
|
||||
:class="{
|
||||
'bg-purple-600': isStepCompleted(step.id),
|
||||
'bg-gray-200 dark:bg-gray-700': !isStepCompleted(step.id)
|
||||
}"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<div class="max-w-4xl mx-auto bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="p-12 text-center">
|
||||
<svg class="inline w-8 h-8 animate-spin text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Loading your setup...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="p-6">
|
||||
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4 text-center">
|
||||
<p class="text-red-600 dark:text-red-400" x-text="error"></p>
|
||||
<button @click="loadStatus()" class="mt-4 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div x-show="!loading && !error">
|
||||
<!-- Step 1: Company Profile -->
|
||||
<div x-show="currentStep === 'company_profile'" x-transition>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">Company Profile</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Let's set up your company information. This will be used for invoices and customer communication.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Name</label>
|
||||
<input type="text" x-model="formData.company_name"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Brand Name</label>
|
||||
<input type="text" x-model="formData.brand_name"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<textarea x-model="formData.description" rows="3"
|
||||
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"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contact Email</label>
|
||||
<input type="email" x-model="formData.contact_email"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contact Phone</label>
|
||||
<input type="tel" x-model="formData.contact_phone"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Website</label>
|
||||
<input type="url" x-model="formData.website"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax Number (VAT)</label>
|
||||
<input type="text" x-model="formData.tax_number"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Business Address</label>
|
||||
<textarea x-model="formData.business_address" rows="2"
|
||||
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"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default Language</label>
|
||||
<select x-model="formData.default_language"
|
||||
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">
|
||||
<option value="fr">French</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Dashboard Language</label>
|
||||
<select x-model="formData.dashboard_language"
|
||||
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">
|
||||
<option value="fr">French</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Letzshop API -->
|
||||
<div x-show="currentStep === 'letzshop_api'" x-transition>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">Letzshop API Configuration</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect your Letzshop marketplace account to sync orders automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Letzshop API Key</label>
|
||||
<input type="password" x-model="formData.api_key" placeholder="Enter your API key"
|
||||
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">
|
||||
Get your API key from Letzshop Vendor Portal > Settings > API Access
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Shop Slug</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm">
|
||||
letzshop.lu/vendors/
|
||||
</span>
|
||||
<input type="text" x-model="formData.shop_slug" placeholder="your-shop"
|
||||
class="flex-1 block w-full rounded-none rounded-r-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-purple-500 focus:ring-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="testLetzshopApi()" :disabled="saving || !formData.api_key || !formData.shop_slug"
|
||||
class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!testing">Test Connection</span>
|
||||
<span x-show="testing" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Testing...
|
||||
</span>
|
||||
</button>
|
||||
<span x-show="connectionStatus === 'success'" class="text-green-600 dark:text-green-400 text-sm flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Connection successful
|
||||
</span>
|
||||
<span x-show="connectionStatus === 'failed'" class="text-red-600 dark:text-red-400 text-sm flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
<span x-text="connectionError || 'Connection failed'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Product Import -->
|
||||
<div x-show="currentStep === 'product_import'" x-transition>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">Product Import Configuration</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Set up your product CSV feed URLs for each language. At least one URL is required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">French CSV URL</label>
|
||||
<input type="url" x-model="formData.csv_url_fr" placeholder="https://..."
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">English CSV URL (optional)</label>
|
||||
<input type="url" x-model="formData.csv_url_en" placeholder="https://..."
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">German CSV URL (optional)</label>
|
||||
<input type="url" x-model="formData.csv_url_de" placeholder="https://..."
|
||||
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" />
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Letzshop Feed Settings</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400">Default Tax Rate</label>
|
||||
<select x-model="formData.default_tax_rate"
|
||||
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">
|
||||
<option value="17">17% (Standard)</option>
|
||||
<option value="14">14% (Intermediate)</option>
|
||||
<option value="8">8% (Reduced)</option>
|
||||
<option value="3">3% (Super-reduced)</option>
|
||||
<option value="0">0% (Exempt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400">Delivery Method</label>
|
||||
<select x-model="formData.delivery_method"
|
||||
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">
|
||||
<option value="package_delivery">Package Delivery</option>
|
||||
<option value="self_collect">Self Collect</option>
|
||||
<option value="nationwide">Nationwide</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400">Pre-order Days</label>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Order Sync -->
|
||||
<div x-show="currentStep === 'order_sync'" x-transition>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">Historical Order Import</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Import your existing orders from Letzshop to get started with a complete order history.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Before Sync -->
|
||||
<div x-show="!syncJobId">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Import orders from the last</label>
|
||||
<select x-model="formData.days_back"
|
||||
class="block w-48 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">
|
||||
<option value="30">30 days</option>
|
||||
<option value="60">60 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="180">6 months</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="startOrderSync()" :disabled="saving"
|
||||
class="px-6 py-3 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
|
||||
Start Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- During Sync -->
|
||||
<div x-show="syncJobId && !syncComplete" class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div class="bg-purple-600 h-4 rounded-full transition-all duration-500"
|
||||
:style="{ width: syncProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-medium text-gray-800 dark:text-white">
|
||||
<span x-text="syncProgress"></span>% Complete
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="syncPhase"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
<span x-text="ordersImported"></span> orders imported
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After Sync -->
|
||||
<div x-show="syncComplete" class="text-center py-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium text-gray-800 dark:text-white">Import Complete!</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<span x-text="ordersImported"></span> orders have been imported.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="p-6 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button x-show="currentStepIndex > 0 && !syncJobId"
|
||||
@click="goToPreviousStep()"
|
||||
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 dark:hover:bg-gray-700">
|
||||
Previous
|
||||
</button>
|
||||
<div x-show="currentStepIndex === 0"></div>
|
||||
|
||||
<button x-show="currentStep !== 'order_sync' || syncComplete"
|
||||
@click="saveAndContinue()" :disabled="saving"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-show="!saving">
|
||||
<span x-text="currentStep === 'order_sync' && syncComplete ? 'Finish Setup' : 'Save & Continue'"></span>
|
||||
</span>
|
||||
<span x-show="saving" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
<script src="{{ url_for('static', path='vendor/js/onboarding.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
191
docs/features/vendor-onboarding.md
Normal file
191
docs/features/vendor-onboarding.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Vendor Onboarding System
|
||||
|
||||
The vendor onboarding system is a mandatory 4-step wizard that guides new vendors through the initial setup process after signup. Dashboard access is blocked until onboarding is completed.
|
||||
|
||||
## Overview
|
||||
|
||||
The onboarding wizard consists of four sequential steps:
|
||||
|
||||
1. **Company Profile Setup** - Basic company and contact information
|
||||
2. **Letzshop API Configuration** - Connect to Letzshop marketplace
|
||||
3. **Product & Order Import Configuration** - Set up CSV feed URLs
|
||||
4. **Order Sync** - Import historical orders with progress tracking
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
Signup Complete
|
||||
↓
|
||||
Redirect to /vendor/{code}/onboarding
|
||||
↓
|
||||
Step 1: Company Profile
|
||||
↓
|
||||
Step 2: Letzshop API (with connection test)
|
||||
↓
|
||||
Step 3: Product Import Config
|
||||
↓
|
||||
Step 4: Order Sync (with progress bar)
|
||||
↓
|
||||
Onboarding Complete
|
||||
↓
|
||||
Redirect to Dashboard
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### Mandatory Completion
|
||||
- Dashboard and other protected routes redirect to onboarding if not completed
|
||||
- Admin can skip onboarding for support cases (via admin API)
|
||||
|
||||
### Step Validation
|
||||
- Steps must be completed in order (no skipping ahead)
|
||||
- Each step validates required fields before proceeding
|
||||
|
||||
### Progress Persistence
|
||||
- Onboarding progress is saved in the database
|
||||
- Users can resume from where they left off
|
||||
- Page reload doesn't lose progress
|
||||
|
||||
### Connection Testing
|
||||
- Step 2 includes real-time Letzshop API connection testing
|
||||
- Shows success/failure status before saving credentials
|
||||
|
||||
### Historical Import
|
||||
- Step 4 triggers a background job for order import
|
||||
- Real-time progress bar with polling (2-second intervals)
|
||||
- Shows order count as import progresses
|
||||
|
||||
## Database Model
|
||||
|
||||
### VendorOnboarding Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | Integer | Primary key |
|
||||
| vendor_id | Integer | Foreign key to vendors (unique) |
|
||||
| status | String(20) | not_started, in_progress, completed, skipped |
|
||||
| current_step | String(30) | Current step identifier |
|
||||
| step_*_completed | Boolean | Completion flag per step |
|
||||
| step_*_completed_at | DateTime | Completion timestamp per step |
|
||||
| skipped_by_admin | Boolean | Admin override flag |
|
||||
| skipped_reason | Text | Reason for skip (admin) |
|
||||
|
||||
### Onboarding Steps Enum
|
||||
|
||||
```python
|
||||
class OnboardingStep(str, enum.Enum):
|
||||
COMPANY_PROFILE = "company_profile"
|
||||
LETZSHOP_API = "letzshop_api"
|
||||
PRODUCT_IMPORT = "product_import"
|
||||
ORDER_SYNC = "order_sync"
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are under `/api/v1/vendor/onboarding/`:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/status` | Get full onboarding status |
|
||||
| GET | `/step/company-profile` | Get company profile data |
|
||||
| POST | `/step/company-profile` | Save company profile |
|
||||
| POST | `/step/letzshop-api/test` | Test API connection |
|
||||
| POST | `/step/letzshop-api` | Save API credentials |
|
||||
| GET | `/step/product-import` | Get import config |
|
||||
| POST | `/step/product-import` | Save import config |
|
||||
| POST | `/step/order-sync/trigger` | Start historical import |
|
||||
| GET | `/step/order-sync/progress/{job_id}` | Get import progress |
|
||||
| POST | `/step/order-sync/complete` | Complete onboarding |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Signup Flow
|
||||
|
||||
When a vendor is created during signup, an onboarding record is automatically created:
|
||||
|
||||
```python
|
||||
# In platform_signup_service.py
|
||||
onboarding_service = OnboardingService(db)
|
||||
onboarding_service.create_onboarding(vendor.id)
|
||||
```
|
||||
|
||||
### Route Protection
|
||||
|
||||
Protected routes check onboarding status and redirect if not completed:
|
||||
|
||||
```python
|
||||
# In vendor_pages.py
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_vendor_id):
|
||||
return RedirectResponse(f"/vendor/{vendor_code}/onboarding")
|
||||
```
|
||||
|
||||
### Historical Import
|
||||
|
||||
Step 4 uses the existing `LetzshopHistoricalImportJob` infrastructure:
|
||||
|
||||
```python
|
||||
order_service = LetzshopOrderService(db)
|
||||
job = order_service.create_historical_import_job(vendor_id, user_id)
|
||||
```
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Template
|
||||
|
||||
`app/templates/vendor/onboarding.html`:
|
||||
- Standalone page (doesn't use vendor base template)
|
||||
- Progress indicator with step circles
|
||||
- Animated transitions between steps
|
||||
- Real-time sync progress bar
|
||||
|
||||
### JavaScript
|
||||
|
||||
`static/vendor/js/onboarding.js`:
|
||||
- Alpine.js component
|
||||
- API calls for each step
|
||||
- Connection test functionality
|
||||
- Progress polling for order sync
|
||||
|
||||
## Admin Skip Capability
|
||||
|
||||
For support cases, admins can skip onboarding:
|
||||
|
||||
```python
|
||||
onboarding_service.skip_onboarding(
|
||||
vendor_id=vendor_id,
|
||||
admin_user_id=admin_user_id,
|
||||
reason="Manual setup required for migration"
|
||||
)
|
||||
```
|
||||
|
||||
This sets `skipped_by_admin=True` and allows dashboard access without completing all steps.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `models/database/onboarding.py` | Database model and enums |
|
||||
| `models/schema/onboarding.py` | Pydantic schemas |
|
||||
| `app/services/onboarding_service.py` | Business logic |
|
||||
| `app/api/v1/vendor/onboarding.py` | API endpoints |
|
||||
| `app/routes/vendor_pages.py` | Page routes and redirects |
|
||||
| `app/templates/vendor/onboarding.html` | Frontend template |
|
||||
| `static/vendor/js/onboarding.js` | Alpine.js component |
|
||||
| `alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py` | Migration |
|
||||
|
||||
## Testing
|
||||
|
||||
Run the onboarding tests:
|
||||
|
||||
```bash
|
||||
pytest tests/integration/api/v1/vendor/test_onboarding.py -v
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
No additional configuration is required. The onboarding system uses existing configurations:
|
||||
|
||||
- Letzshop API: Uses `LetzshopCredentialsService`
|
||||
- Order Import: Uses `LetzshopOrderService`
|
||||
- Email: Uses `EmailService` for welcome email (sent after signup)
|
||||
@@ -200,6 +200,7 @@ nav:
|
||||
- Implementation Guide: features/cms-implementation-guide.md
|
||||
- Platform Homepage: features/platform-homepage.md
|
||||
- Vendor Landing Pages: features/vendor-landing-pages.md
|
||||
- Vendor Onboarding: features/vendor-onboarding.md
|
||||
- Subscription & Billing: features/subscription-billing.md
|
||||
- Email System: features/email-system.md
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from .marketplace_product import (
|
||||
ProductType,
|
||||
)
|
||||
from .marketplace_product_translation import MarketplaceProductTranslation
|
||||
from .onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding
|
||||
from .order import Order, OrderItem
|
||||
from .order_item_exception import OrderItemException
|
||||
from .product import Product
|
||||
@@ -153,4 +154,8 @@ __all__ = [
|
||||
"Message",
|
||||
"MessageAttachment",
|
||||
"ParticipantType",
|
||||
# Onboarding
|
||||
"OnboardingStatus",
|
||||
"OnboardingStep",
|
||||
"VendorOnboarding",
|
||||
]
|
||||
|
||||
222
models/database/onboarding.py
Normal file
222
models/database/onboarding.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# models/database/onboarding.py
|
||||
"""
|
||||
Vendor onboarding progress tracking.
|
||||
|
||||
Tracks completion status of mandatory onboarding steps for new vendors.
|
||||
Onboarding must be completed before accessing the vendor dashboard.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from .base import TimestampMixin
|
||||
|
||||
|
||||
class OnboardingStep(str, enum.Enum):
|
||||
"""Onboarding step identifiers."""
|
||||
|
||||
COMPANY_PROFILE = "company_profile"
|
||||
LETZSHOP_API = "letzshop_api"
|
||||
PRODUCT_IMPORT = "product_import"
|
||||
ORDER_SYNC = "order_sync"
|
||||
|
||||
|
||||
class OnboardingStatus(str, enum.Enum):
|
||||
"""Overall onboarding status."""
|
||||
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
SKIPPED = "skipped" # For admin override capability
|
||||
|
||||
|
||||
# Step order for validation
|
||||
STEP_ORDER = [
|
||||
OnboardingStep.COMPANY_PROFILE,
|
||||
OnboardingStep.LETZSHOP_API,
|
||||
OnboardingStep.PRODUCT_IMPORT,
|
||||
OnboardingStep.ORDER_SYNC,
|
||||
]
|
||||
|
||||
|
||||
class VendorOnboarding(Base, TimestampMixin):
|
||||
"""
|
||||
Per-vendor onboarding progress tracking.
|
||||
|
||||
Created automatically when vendor is created during signup.
|
||||
Blocks dashboard access until status = 'completed' or skipped_by_admin = True.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_onboarding"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Overall status
|
||||
status = Column(
|
||||
String(20),
|
||||
default=OnboardingStatus.NOT_STARTED.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
current_step = Column(
|
||||
String(30),
|
||||
default=OnboardingStep.COMPANY_PROFILE.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Step 1: Company Profile
|
||||
step_company_profile_completed = Column(Boolean, default=False, nullable=False)
|
||||
step_company_profile_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
step_company_profile_data = Column(JSON, nullable=True) # Store what was entered
|
||||
|
||||
# Step 2: Letzshop API Configuration
|
||||
step_letzshop_api_completed = Column(Boolean, default=False, nullable=False)
|
||||
step_letzshop_api_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
step_letzshop_api_connection_verified = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Step 3: Product & Order Import (CSV feed URL + historical import)
|
||||
step_product_import_completed = Column(Boolean, default=False, nullable=False)
|
||||
step_product_import_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
step_product_import_csv_url_set = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Step 4: Order Sync
|
||||
step_order_sync_completed = Column(Boolean, default=False, nullable=False)
|
||||
step_order_sync_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
step_order_sync_job_id = Column(Integer, nullable=True) # FK to LetzshopHistoricalImportJob
|
||||
|
||||
# Completion tracking
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Admin override (for support cases)
|
||||
skipped_by_admin = Column(Boolean, default=False, nullable=False)
|
||||
skipped_at = Column(DateTime(timezone=True), nullable=True)
|
||||
skipped_reason = Column(Text, nullable=True)
|
||||
skipped_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="onboarding")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_onboarding_vendor_status", "vendor_id", "status"),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VendorOnboarding(vendor_id={self.vendor_id}, status='{self.status}', step='{self.current_step}')>"
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if onboarding is fully completed or skipped."""
|
||||
return (
|
||||
self.status == OnboardingStatus.COMPLETED.value or self.skipped_by_admin
|
||||
)
|
||||
|
||||
@property
|
||||
def completion_percentage(self) -> int:
|
||||
"""Calculate completion percentage (0-100)."""
|
||||
completed_steps = sum(
|
||||
[
|
||||
self.step_company_profile_completed,
|
||||
self.step_letzshop_api_completed,
|
||||
self.step_product_import_completed,
|
||||
self.step_order_sync_completed,
|
||||
]
|
||||
)
|
||||
return int((completed_steps / 4) * 100)
|
||||
|
||||
@property
|
||||
def completed_steps_count(self) -> int:
|
||||
"""Get number of completed steps."""
|
||||
return sum(
|
||||
[
|
||||
self.step_company_profile_completed,
|
||||
self.step_letzshop_api_completed,
|
||||
self.step_product_import_completed,
|
||||
self.step_order_sync_completed,
|
||||
]
|
||||
)
|
||||
|
||||
def is_step_completed(self, step: str) -> bool:
|
||||
"""Check if a specific step is completed."""
|
||||
step_mapping = {
|
||||
OnboardingStep.COMPANY_PROFILE.value: self.step_company_profile_completed,
|
||||
OnboardingStep.LETZSHOP_API.value: self.step_letzshop_api_completed,
|
||||
OnboardingStep.PRODUCT_IMPORT.value: self.step_product_import_completed,
|
||||
OnboardingStep.ORDER_SYNC.value: self.step_order_sync_completed,
|
||||
}
|
||||
return step_mapping.get(step, False)
|
||||
|
||||
def can_proceed_to_step(self, step: str) -> bool:
|
||||
"""Check if user can proceed to a specific step (no skipping)."""
|
||||
target_index = None
|
||||
for i, s in enumerate(STEP_ORDER):
|
||||
if s.value == step:
|
||||
target_index = i
|
||||
break
|
||||
|
||||
if target_index is None:
|
||||
return False
|
||||
|
||||
# Check all previous steps are completed
|
||||
for i in range(target_index):
|
||||
if not self.is_step_completed(STEP_ORDER[i].value):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_next_step(self) -> str | None:
|
||||
"""Get the next incomplete step."""
|
||||
for step in STEP_ORDER:
|
||||
if not self.is_step_completed(step.value):
|
||||
return step.value
|
||||
return None
|
||||
|
||||
def mark_step_complete(self, step: str, timestamp: datetime | None = None) -> None:
|
||||
"""Mark a step as complete and update current step."""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.utcnow()
|
||||
|
||||
if step == OnboardingStep.COMPANY_PROFILE.value:
|
||||
self.step_company_profile_completed = True
|
||||
self.step_company_profile_completed_at = timestamp
|
||||
elif step == OnboardingStep.LETZSHOP_API.value:
|
||||
self.step_letzshop_api_completed = True
|
||||
self.step_letzshop_api_completed_at = timestamp
|
||||
elif step == OnboardingStep.PRODUCT_IMPORT.value:
|
||||
self.step_product_import_completed = True
|
||||
self.step_product_import_completed_at = timestamp
|
||||
elif step == OnboardingStep.ORDER_SYNC.value:
|
||||
self.step_order_sync_completed = True
|
||||
self.step_order_sync_completed_at = timestamp
|
||||
|
||||
# Update current step to next incomplete step
|
||||
next_step = self.get_next_step()
|
||||
if next_step:
|
||||
self.current_step = next_step
|
||||
else:
|
||||
# All steps complete
|
||||
self.status = OnboardingStatus.COMPLETED.value
|
||||
self.completed_at = timestamp
|
||||
@@ -211,6 +211,14 @@ class Vendor(Base, TimestampMixin):
|
||||
"ContentPage", back_populates="vendor", cascade="all, delete-orphan"
|
||||
) # Relationship with ContentPage model for vendor-specific content pages
|
||||
|
||||
# Onboarding progress (one-to-one)
|
||||
onboarding = relationship(
|
||||
"VendorOnboarding",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the Vendor object."""
|
||||
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
|
||||
|
||||
@@ -10,6 +10,7 @@ from . import (
|
||||
marketplace_import_job,
|
||||
marketplace_product,
|
||||
message,
|
||||
onboarding,
|
||||
stats,
|
||||
vendor,
|
||||
)
|
||||
@@ -24,6 +25,7 @@ __all__ = [
|
||||
"marketplace_product",
|
||||
"message",
|
||||
"inventory",
|
||||
"onboarding",
|
||||
"vendor",
|
||||
"marketplace_import_job",
|
||||
"stats",
|
||||
|
||||
291
models/schema/onboarding.py
Normal file
291
models/schema/onboarding.py
Normal file
@@ -0,0 +1,291 @@
|
||||
# models/schema/onboarding.py
|
||||
"""
|
||||
Pydantic schemas for Vendor Onboarding operations.
|
||||
|
||||
Schemas include:
|
||||
- OnboardingStatusResponse: Current onboarding status with all step states
|
||||
- CompanyProfileRequest/Response: Step 1 - Company profile data
|
||||
- LetzshopApiConfigRequest/Response: Step 2 - API configuration
|
||||
- ProductImportConfigRequest/Response: Step 3 - CSV URL configuration
|
||||
- OrderSyncTriggerResponse: Step 4 - Job trigger response
|
||||
- OrderSyncProgressResponse: Step 4 - Progress polling response
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP STATUS MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class StepStatus(BaseModel):
|
||||
"""Status for a single onboarding step."""
|
||||
|
||||
completed: bool = False
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class CompanyProfileStepStatus(StepStatus):
|
||||
"""Step 1 status with saved data."""
|
||||
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class LetzshopApiStepStatus(StepStatus):
|
||||
"""Step 2 status with connection verification."""
|
||||
|
||||
connection_verified: bool = False
|
||||
|
||||
|
||||
class ProductImportStepStatus(StepStatus):
|
||||
"""Step 3 status with CSV URL flag."""
|
||||
|
||||
csv_url_set: bool = False
|
||||
|
||||
|
||||
class OrderSyncStepStatus(StepStatus):
|
||||
"""Step 4 status with job tracking."""
|
||||
|
||||
job_id: int | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ONBOARDING STATUS RESPONSE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OnboardingStatusResponse(BaseModel):
|
||||
"""Full onboarding status with all step information."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
status: str # not_started, in_progress, completed, skipped
|
||||
current_step: str # company_profile, letzshop_api, product_import, order_sync
|
||||
|
||||
# Step statuses
|
||||
company_profile: CompanyProfileStepStatus
|
||||
letzshop_api: LetzshopApiStepStatus
|
||||
product_import: ProductImportStepStatus
|
||||
order_sync: OrderSyncStepStatus
|
||||
|
||||
# Progress tracking
|
||||
completion_percentage: int
|
||||
completed_steps_count: int
|
||||
total_steps: int = 4
|
||||
|
||||
# Completion info
|
||||
is_completed: bool
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
# Admin override info
|
||||
skipped_by_admin: bool = False
|
||||
skipped_at: datetime | None = None
|
||||
skipped_reason: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP 1: COMPANY PROFILE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CompanyProfileRequest(BaseModel):
|
||||
"""Request to save company profile during onboarding Step 1."""
|
||||
|
||||
# Company name is already set during signup, but can be updated
|
||||
company_name: str | None = Field(None, min_length=2, max_length=255)
|
||||
|
||||
# Vendor/brand name
|
||||
brand_name: str | None = Field(None, min_length=2, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
|
||||
# Contact information
|
||||
contact_email: str | None = Field(None, max_length=255)
|
||||
contact_phone: str | None = Field(None, max_length=50)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
business_address: str | None = Field(None, max_length=500)
|
||||
tax_number: str | None = Field(None, max_length=100)
|
||||
|
||||
# Language preferences
|
||||
default_language: str = Field("fr", pattern="^(en|fr|de|lb)$")
|
||||
dashboard_language: str = Field("fr", pattern="^(en|fr|de|lb)$")
|
||||
|
||||
|
||||
class CompanyProfileResponse(BaseModel):
|
||||
"""Response after saving company profile."""
|
||||
|
||||
success: bool
|
||||
step_completed: bool
|
||||
next_step: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP 2: LETZSHOP API CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LetzshopApiConfigRequest(BaseModel):
|
||||
"""Request to configure Letzshop API credentials."""
|
||||
|
||||
api_key: str = Field(..., min_length=10, description="Letzshop API key")
|
||||
shop_slug: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=100,
|
||||
description="Letzshop shop URL slug (e.g., 'my-shop')",
|
||||
)
|
||||
vendor_id: str | None = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Letzshop vendor ID (optional, auto-detected if not provided)",
|
||||
)
|
||||
|
||||
|
||||
class LetzshopApiTestRequest(BaseModel):
|
||||
"""Request to test Letzshop API connection."""
|
||||
|
||||
api_key: str = Field(..., min_length=10)
|
||||
shop_slug: str = Field(..., min_length=2, max_length=100)
|
||||
|
||||
|
||||
class LetzshopApiTestResponse(BaseModel):
|
||||
"""Response from Letzshop API connection test."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
vendor_name: str | None = None
|
||||
vendor_id: str | None = None
|
||||
shop_slug: str | None = None
|
||||
|
||||
|
||||
class LetzshopApiConfigResponse(BaseModel):
|
||||
"""Response after saving Letzshop API configuration."""
|
||||
|
||||
success: bool
|
||||
step_completed: bool
|
||||
next_step: str | None = None
|
||||
message: str | None = None
|
||||
connection_verified: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: PRODUCT & ORDER IMPORT CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ProductImportConfigRequest(BaseModel):
|
||||
"""Request to configure product import settings."""
|
||||
|
||||
# CSV feed URLs for each language
|
||||
csv_url_fr: str | None = Field(None, max_length=500)
|
||||
csv_url_en: str | None = Field(None, max_length=500)
|
||||
csv_url_de: str | None = Field(None, max_length=500)
|
||||
|
||||
# Letzshop feed settings
|
||||
default_tax_rate: int = Field(
|
||||
17,
|
||||
ge=0,
|
||||
le=17,
|
||||
description="Default VAT rate: 0, 3, 8, 14, or 17",
|
||||
)
|
||||
delivery_method: str = Field(
|
||||
"package_delivery",
|
||||
description="Delivery method: nationwide, package_delivery, self_collect",
|
||||
)
|
||||
preorder_days: int = Field(1, ge=0, le=30)
|
||||
|
||||
|
||||
class ProductImportConfigResponse(BaseModel):
|
||||
"""Response after saving product import configuration."""
|
||||
|
||||
success: bool
|
||||
step_completed: bool
|
||||
next_step: str | None = None
|
||||
message: str | None = None
|
||||
csv_urls_configured: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP 4: ORDER SYNC
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrderSyncTriggerRequest(BaseModel):
|
||||
"""Request to trigger historical order import."""
|
||||
|
||||
# How far back to import orders (days)
|
||||
days_back: int = Field(90, ge=1, le=365, description="Days of order history to import")
|
||||
include_products: bool = Field(True, description="Also import products from Letzshop")
|
||||
|
||||
|
||||
class OrderSyncTriggerResponse(BaseModel):
|
||||
"""Response after triggering order sync job."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
job_id: int | None = None
|
||||
estimated_duration_minutes: int | None = None
|
||||
|
||||
|
||||
class OrderSyncProgressResponse(BaseModel):
|
||||
"""Response for order sync progress polling."""
|
||||
|
||||
job_id: int
|
||||
status: str # pending, running, completed, failed
|
||||
progress_percentage: int = 0
|
||||
current_phase: str | None = None # products, orders, finalizing
|
||||
|
||||
# Counts
|
||||
orders_imported: int = 0
|
||||
orders_total: int | None = None
|
||||
products_imported: int = 0
|
||||
|
||||
# Timing
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
estimated_remaining_seconds: int | None = None
|
||||
|
||||
# Error info if failed
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class OrderSyncCompleteRequest(BaseModel):
|
||||
"""Request to mark order sync as complete."""
|
||||
|
||||
job_id: int
|
||||
|
||||
|
||||
class OrderSyncCompleteResponse(BaseModel):
|
||||
"""Response after completing order sync step."""
|
||||
|
||||
success: bool
|
||||
step_completed: bool
|
||||
onboarding_completed: bool = False
|
||||
message: str | None = None
|
||||
redirect_url: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN SKIP
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OnboardingSkipRequest(BaseModel):
|
||||
"""Request to skip onboarding (admin only)."""
|
||||
|
||||
reason: str = Field(..., min_length=10, max_length=500)
|
||||
|
||||
|
||||
class OnboardingSkipResponse(BaseModel):
|
||||
"""Response after skipping onboarding."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
vendor_id: int
|
||||
skipped_at: datetime
|
||||
346
static/vendor/js/onboarding.js
vendored
Normal file
346
static/vendor/js/onboarding.js
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
// static/vendor/js/onboarding.js
|
||||
/**
|
||||
* Vendor Onboarding Wizard
|
||||
*
|
||||
* Handles the 4-step mandatory onboarding flow:
|
||||
* 1. Company Profile Setup
|
||||
* 2. Letzshop API Configuration
|
||||
* 3. Product & Order Import Configuration
|
||||
* 4. Order Sync (historical import)
|
||||
*/
|
||||
|
||||
function vendorOnboarding() {
|
||||
return {
|
||||
// State
|
||||
loading: true,
|
||||
saving: false,
|
||||
testing: false,
|
||||
error: null,
|
||||
|
||||
// Steps configuration
|
||||
steps: [
|
||||
{ id: 'company_profile', title: 'Company Profile' },
|
||||
{ id: 'letzshop_api', title: 'Letzshop API' },
|
||||
{ id: 'product_import', title: 'Product Import' },
|
||||
{ id: 'order_sync', title: 'Order Sync' },
|
||||
],
|
||||
|
||||
// Current state
|
||||
currentStep: 'company_profile',
|
||||
completedSteps: 0,
|
||||
status: null,
|
||||
|
||||
// Form data
|
||||
formData: {
|
||||
// Step 1: Company Profile
|
||||
company_name: '',
|
||||
brand_name: '',
|
||||
description: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
website: '',
|
||||
business_address: '',
|
||||
tax_number: '',
|
||||
default_language: 'fr',
|
||||
dashboard_language: 'fr',
|
||||
|
||||
// Step 2: Letzshop API
|
||||
api_key: '',
|
||||
shop_slug: '',
|
||||
|
||||
// Step 3: Product Import
|
||||
csv_url_fr: '',
|
||||
csv_url_en: '',
|
||||
csv_url_de: '',
|
||||
default_tax_rate: 17,
|
||||
delivery_method: 'package_delivery',
|
||||
preorder_days: 1,
|
||||
|
||||
// Step 4: Order Sync
|
||||
days_back: 90,
|
||||
},
|
||||
|
||||
// Letzshop connection test state
|
||||
connectionStatus: null, // null, 'success', 'failed'
|
||||
connectionError: null,
|
||||
|
||||
// Order sync state
|
||||
syncJobId: null,
|
||||
syncProgress: 0,
|
||||
syncPhase: '',
|
||||
ordersImported: 0,
|
||||
syncComplete: false,
|
||||
syncPollInterval: null,
|
||||
|
||||
// Computed
|
||||
get currentStepIndex() {
|
||||
return this.steps.findIndex(s => s.id === this.currentStep);
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadStatus();
|
||||
},
|
||||
|
||||
// Load current onboarding status
|
||||
async loadStatus() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.get('/api/v1/vendor/onboarding/status');
|
||||
this.status = response;
|
||||
this.currentStep = response.current_step;
|
||||
this.completedSteps = response.completed_steps_count;
|
||||
|
||||
// Pre-populate form data from status if available
|
||||
if (response.company_profile?.data) {
|
||||
Object.assign(this.formData, response.company_profile.data);
|
||||
}
|
||||
|
||||
// Check if we were in the middle of an order sync
|
||||
if (response.order_sync?.job_id && this.currentStep === 'order_sync') {
|
||||
this.syncJobId = response.order_sync.job_id;
|
||||
this.startSyncPolling();
|
||||
}
|
||||
|
||||
// Load step-specific data
|
||||
await this.loadStepData();
|
||||
} catch (err) {
|
||||
console.error('Failed to load onboarding status:', err);
|
||||
this.error = err.message || 'Failed to load onboarding status';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load data for current step
|
||||
async loadStepData() {
|
||||
try {
|
||||
if (this.currentStep === 'company_profile') {
|
||||
const data = await window.apiClient.get('/api/v1/vendor/onboarding/step/company-profile');
|
||||
if (data) {
|
||||
Object.assign(this.formData, data);
|
||||
}
|
||||
} else if (this.currentStep === 'product_import') {
|
||||
const data = await window.apiClient.get('/api/v1/vendor/onboarding/step/product-import');
|
||||
if (data) {
|
||||
Object.assign(this.formData, {
|
||||
csv_url_fr: data.csv_url_fr || '',
|
||||
csv_url_en: data.csv_url_en || '',
|
||||
csv_url_de: data.csv_url_de || '',
|
||||
default_tax_rate: data.default_tax_rate || 17,
|
||||
delivery_method: data.delivery_method || 'package_delivery',
|
||||
preorder_days: data.preorder_days || 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load step data:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// Check if a step is completed
|
||||
isStepCompleted(stepId) {
|
||||
if (!this.status) return false;
|
||||
const stepData = this.status[stepId];
|
||||
return stepData?.completed === true;
|
||||
},
|
||||
|
||||
// Go to previous step
|
||||
goToPreviousStep() {
|
||||
const prevIndex = this.currentStepIndex - 1;
|
||||
if (prevIndex >= 0) {
|
||||
this.currentStep = this.steps[prevIndex].id;
|
||||
this.loadStepData();
|
||||
}
|
||||
},
|
||||
|
||||
// Test Letzshop API connection
|
||||
async testLetzshopApi() {
|
||||
this.testing = true;
|
||||
this.connectionStatus = null;
|
||||
this.connectionError = null;
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.post('/api/v1/vendor/onboarding/step/letzshop-api/test', {
|
||||
api_key: this.formData.api_key,
|
||||
shop_slug: this.formData.shop_slug,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.connectionStatus = 'success';
|
||||
} else {
|
||||
this.connectionStatus = 'failed';
|
||||
this.connectionError = response.message;
|
||||
}
|
||||
} catch (err) {
|
||||
this.connectionStatus = 'failed';
|
||||
this.connectionError = err.message || 'Connection test failed';
|
||||
} finally {
|
||||
this.testing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Start order sync
|
||||
async startOrderSync() {
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.post('/api/v1/vendor/onboarding/step/order-sync/trigger', {
|
||||
days_back: parseInt(this.formData.days_back),
|
||||
include_products: true,
|
||||
});
|
||||
|
||||
if (response.success && response.job_id) {
|
||||
this.syncJobId = response.job_id;
|
||||
this.startSyncPolling();
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to start import');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start order sync:', err);
|
||||
this.error = err.message || 'Failed to start import';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Start polling for sync progress
|
||||
startSyncPolling() {
|
||||
this.syncPollInterval = setInterval(async () => {
|
||||
await this.pollSyncProgress();
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// Poll sync progress
|
||||
async pollSyncProgress() {
|
||||
try {
|
||||
const response = await window.apiClient.get(
|
||||
`/api/v1/vendor/onboarding/step/order-sync/progress/${this.syncJobId}`
|
||||
);
|
||||
|
||||
this.syncProgress = response.progress_percentage || 0;
|
||||
this.syncPhase = this.formatPhase(response.current_phase);
|
||||
this.ordersImported = response.orders_imported || 0;
|
||||
|
||||
if (response.status === 'completed' || response.status === 'failed') {
|
||||
this.stopSyncPolling();
|
||||
this.syncComplete = true;
|
||||
this.syncProgress = response.status === 'completed' ? 100 : this.syncProgress;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to poll sync progress:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// Stop sync polling
|
||||
stopSyncPolling() {
|
||||
if (this.syncPollInterval) {
|
||||
clearInterval(this.syncPollInterval);
|
||||
this.syncPollInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Format phase for display
|
||||
formatPhase(phase) {
|
||||
const phases = {
|
||||
fetching: 'Fetching orders from Letzshop...',
|
||||
orders: 'Processing orders...',
|
||||
products: 'Importing products...',
|
||||
finalizing: 'Finalizing import...',
|
||||
complete: 'Import complete!',
|
||||
};
|
||||
return phases[phase] || 'Processing...';
|
||||
},
|
||||
|
||||
// Save current step and continue
|
||||
async saveAndContinue() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
let endpoint = '';
|
||||
let payload = {};
|
||||
|
||||
switch (this.currentStep) {
|
||||
case 'company_profile':
|
||||
endpoint = '/api/v1/vendor/onboarding/step/company-profile';
|
||||
payload = {
|
||||
company_name: this.formData.company_name,
|
||||
brand_name: this.formData.brand_name,
|
||||
description: this.formData.description,
|
||||
contact_email: this.formData.contact_email,
|
||||
contact_phone: this.formData.contact_phone,
|
||||
website: this.formData.website,
|
||||
business_address: this.formData.business_address,
|
||||
tax_number: this.formData.tax_number,
|
||||
default_language: this.formData.default_language,
|
||||
dashboard_language: this.formData.dashboard_language,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'letzshop_api':
|
||||
endpoint = '/api/v1/vendor/onboarding/step/letzshop-api';
|
||||
payload = {
|
||||
api_key: this.formData.api_key,
|
||||
shop_slug: this.formData.shop_slug,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'product_import':
|
||||
endpoint = '/api/v1/vendor/onboarding/step/product-import';
|
||||
payload = {
|
||||
csv_url_fr: this.formData.csv_url_fr || null,
|
||||
csv_url_en: this.formData.csv_url_en || null,
|
||||
csv_url_de: this.formData.csv_url_de || null,
|
||||
default_tax_rate: parseInt(this.formData.default_tax_rate),
|
||||
delivery_method: this.formData.delivery_method,
|
||||
preorder_days: parseInt(this.formData.preorder_days),
|
||||
};
|
||||
break;
|
||||
|
||||
case 'order_sync':
|
||||
// Complete onboarding
|
||||
endpoint = '/api/v1/vendor/onboarding/step/order-sync/complete';
|
||||
payload = {
|
||||
job_id: this.syncJobId,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await window.apiClient.post(endpoint, payload);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Save failed');
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (response.onboarding_completed || response.redirect_url) {
|
||||
// Redirect to dashboard
|
||||
window.location.href = response.redirect_url || window.location.pathname.replace('/onboarding', '/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to next step
|
||||
if (response.next_step) {
|
||||
this.currentStep = response.next_step;
|
||||
this.completedSteps++;
|
||||
await this.loadStepData();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to save step:', err);
|
||||
this.error = err.message || 'Failed to save. Please try again.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Dark mode
|
||||
get dark() {
|
||||
return localStorage.getItem('dark') === 'true' ||
|
||||
(!localStorage.getItem('dark') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user