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

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