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:
2025-12-27 21:46:26 +01:00
parent 64fd8b5194
commit 409a2eaa05
15 changed files with 2549 additions and 2 deletions

View 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')

View File

@@ -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
View 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))

View File

@@ -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",
{

View 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)

View File

@@ -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
View 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>

View 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)

View File

@@ -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

View File

@@ -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",
]

View 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

View File

@@ -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}')>"

View File

@@ -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
View 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
View 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);
},
};
}