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