refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
# app/api/v1/platform/__init__.py
|
||||
"""
|
||||
Platform public API endpoints.
|
||||
|
||||
These endpoints are publicly accessible (no authentication required)
|
||||
and serve the marketing homepage, pricing pages, and signup flows.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.platform import pricing, letzshop_vendors, signup
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Public pricing and tier info
|
||||
router.include_router(pricing.router, tags=["platform-pricing"])
|
||||
|
||||
# Letzshop vendor lookup
|
||||
router.include_router(letzshop_vendors.router, tags=["platform-vendors"])
|
||||
|
||||
# Signup flow
|
||||
router.include_router(signup.router, tags=["platform-signup"])
|
||||
@@ -1,271 +0,0 @@
|
||||
# app/api/v1/platform/letzshop_vendors.py
|
||||
"""
|
||||
Letzshop vendor lookup API endpoints.
|
||||
|
||||
Allows potential vendors to find themselves in the Letzshop marketplace
|
||||
and claim their shop during signup.
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
|
||||
from app.services.platform_signup_service import platform_signup_service
|
||||
from app.modules.marketplace.models import LetzshopVendorCache
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LetzshopVendorInfo(BaseModel):
|
||||
"""Letzshop vendor information for display."""
|
||||
|
||||
letzshop_id: str | None = None
|
||||
slug: str
|
||||
name: str
|
||||
company_name: str | None = None
|
||||
description: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
categories: list[str] = []
|
||||
background_image_url: str | None = None
|
||||
social_media_links: list[str] = []
|
||||
letzshop_url: str
|
||||
is_claimed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
|
||||
"""Create from cache entry."""
|
||||
return cls(
|
||||
letzshop_id=cache.letzshop_id,
|
||||
slug=cache.slug,
|
||||
name=cache.name,
|
||||
company_name=cache.company_name,
|
||||
description=cache.get_description(lang),
|
||||
email=cache.email,
|
||||
phone=cache.phone,
|
||||
website=cache.website,
|
||||
address=cache.get_full_address(),
|
||||
city=cache.city,
|
||||
categories=cache.categories or [],
|
||||
background_image_url=cache.background_image_url,
|
||||
social_media_links=cache.social_media_links or [],
|
||||
letzshop_url=cache.letzshop_url,
|
||||
is_claimed=cache.is_claimed,
|
||||
)
|
||||
|
||||
|
||||
class LetzshopVendorListResponse(BaseModel):
|
||||
"""Paginated list of Letzshop vendors."""
|
||||
|
||||
vendors: list[LetzshopVendorInfo]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class LetzshopLookupRequest(BaseModel):
|
||||
"""Request to lookup a Letzshop vendor by URL."""
|
||||
|
||||
url: str # e.g., https://letzshop.lu/vendors/my-shop or just "my-shop"
|
||||
|
||||
|
||||
class LetzshopLookupResponse(BaseModel):
|
||||
"""Response from Letzshop vendor lookup."""
|
||||
|
||||
found: bool
|
||||
vendor: LetzshopVendorInfo | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def extract_slug_from_url(url_or_slug: str) -> str:
|
||||
"""
|
||||
Extract vendor slug from Letzshop URL or return as-is if already a slug.
|
||||
|
||||
Handles:
|
||||
- https://letzshop.lu/vendors/my-shop
|
||||
- https://letzshop.lu/en/vendors/my-shop
|
||||
- letzshop.lu/vendors/my-shop
|
||||
- my-shop
|
||||
"""
|
||||
# Clean up the input
|
||||
url_or_slug = url_or_slug.strip()
|
||||
|
||||
# If it looks like a URL, extract the slug
|
||||
if "letzshop" in url_or_slug.lower() or "/" in url_or_slug:
|
||||
# Remove protocol if present
|
||||
url_or_slug = re.sub(r"^https?://", "", url_or_slug)
|
||||
|
||||
# Match pattern like letzshop.lu/[lang/]vendors/SLUG[/...]
|
||||
match = re.search(r"letzshop\.lu/(?:[a-z]{2}/)?vendors?/([^/?#]+)", url_or_slug, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# If just a path like vendors/my-shop
|
||||
match = re.search(r"vendors?/([^/?#]+)", url_or_slug)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Return as-is (assume it's already a slug)
|
||||
return url_or_slug.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors", response_model=LetzshopVendorListResponse) # public
|
||||
async def list_letzshop_vendors(
|
||||
search: Annotated[str | None, Query(description="Search by name")] = None,
|
||||
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
||||
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
||||
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorListResponse:
|
||||
"""
|
||||
List Letzshop vendors from cached directory.
|
||||
|
||||
The cache is periodically synced from Letzshop's public GraphQL API.
|
||||
Run the sync task manually or wait for scheduled sync if cache is empty.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
vendors, total = sync_service.search_cached_vendors(
|
||||
search=search,
|
||||
city=city,
|
||||
category=category,
|
||||
only_unclaimed=only_unclaimed,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
has_more=(page * limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public
|
||||
async def lookup_letzshop_vendor(
|
||||
request: LetzshopLookupRequest,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopLookupResponse:
|
||||
"""
|
||||
Lookup a Letzshop vendor by URL or slug.
|
||||
|
||||
This endpoint:
|
||||
1. Extracts the slug from the provided URL
|
||||
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
|
||||
3. Checks if the vendor is already claimed on our platform
|
||||
4. Returns vendor info for signup pre-fill
|
||||
"""
|
||||
try:
|
||||
slug = extract_slug_from_url(request.url)
|
||||
|
||||
if not slug:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Could not extract vendor slug from URL",
|
||||
)
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Vendor not found on Letzshop",
|
||||
)
|
||||
|
||||
return LetzshopLookupResponse(
|
||||
found=True,
|
||||
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up Letzshop vendor: {e}")
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Failed to lookup vendor",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public
|
||||
async def get_letzshop_vendor(
|
||||
slug: str,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorInfo:
|
||||
"""
|
||||
Get a specific Letzshop vendor by slug.
|
||||
|
||||
Returns 404 if vendor not found in cache or on Letzshop.
|
||||
"""
|
||||
slug = slug.lower()
|
||||
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||
|
||||
return LetzshopVendorInfo.from_cache(cache_entry, lang)
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors-stats") # public
|
||||
async def get_letzshop_vendor_stats(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Get statistics about the Letzshop vendor cache.
|
||||
|
||||
Returns total, active, claimed, and unclaimed vendor counts.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
return sync_service.get_sync_stats()
|
||||
@@ -1,247 +0,0 @@
|
||||
# app/api/v1/platform/pricing.py
|
||||
"""
|
||||
Public pricing API endpoints.
|
||||
|
||||
Provides subscription tier and add-on product information
|
||||
for the marketing homepage and signup flow.
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from app.services.platform_pricing_service import platform_pricing_service
|
||||
from app.modules.billing.models import TierCode
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TierFeature(BaseModel):
|
||||
"""Feature included in a tier."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TierResponse(BaseModel):
|
||||
"""Subscription tier details for public display."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
price_monthly: float # Price in euros
|
||||
price_annual: float | None # Price in euros (null for enterprise)
|
||||
price_monthly_cents: int
|
||||
price_annual_cents: int | None
|
||||
orders_per_month: int | None # None = unlimited
|
||||
products_limit: int | None # None = unlimited
|
||||
team_members: int | None # None = unlimited
|
||||
order_history_months: int | None # None = unlimited
|
||||
features: list[str]
|
||||
is_popular: bool = False # Highlight as recommended
|
||||
is_enterprise: bool = False # Contact sales
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AddOnResponse(BaseModel):
|
||||
"""Add-on product details for public display."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
category: str
|
||||
price: float # Price in euros
|
||||
price_cents: int
|
||||
billing_period: str
|
||||
quantity_unit: str | None
|
||||
quantity_value: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PricingResponse(BaseModel):
|
||||
"""Complete pricing information."""
|
||||
|
||||
tiers: list[TierResponse]
|
||||
addons: list[AddOnResponse]
|
||||
trial_days: int
|
||||
annual_discount_months: int # e.g., 2 = "2 months free"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature Descriptions
|
||||
# =============================================================================
|
||||
|
||||
FEATURE_DESCRIPTIONS = {
|
||||
"letzshop_sync": "Letzshop Order Sync",
|
||||
"inventory_basic": "Basic Inventory Management",
|
||||
"inventory_locations": "Warehouse Locations",
|
||||
"inventory_purchase_orders": "Purchase Orders",
|
||||
"invoice_lu": "Luxembourg VAT Invoicing",
|
||||
"invoice_eu_vat": "EU VAT Invoicing",
|
||||
"invoice_bulk": "Bulk Invoicing",
|
||||
"customer_view": "Customer List",
|
||||
"customer_export": "Customer Export",
|
||||
"analytics_dashboard": "Analytics Dashboard",
|
||||
"accounting_export": "Accounting Export",
|
||||
"api_access": "API Access",
|
||||
"automation_rules": "Automation Rules",
|
||||
"team_roles": "Team Roles & Permissions",
|
||||
"white_label": "White-Label Option",
|
||||
"multi_vendor": "Multi-Vendor Support",
|
||||
"custom_integrations": "Custom Integrations",
|
||||
"sla_guarantee": "SLA Guarantee",
|
||||
"dedicated_support": "Dedicated Account Manager",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _tier_to_response(tier, is_from_db: bool = True) -> TierResponse:
|
||||
"""Convert a tier (from DB or hardcoded) to TierResponse."""
|
||||
if is_from_db:
|
||||
return TierResponse(
|
||||
code=tier.code,
|
||||
name=tier.name,
|
||||
description=tier.description,
|
||||
price_monthly=tier.price_monthly_cents / 100,
|
||||
price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None,
|
||||
price_monthly_cents=tier.price_monthly_cents,
|
||||
price_annual_cents=tier.price_annual_cents,
|
||||
orders_per_month=tier.orders_per_month,
|
||||
products_limit=tier.products_limit,
|
||||
team_members=tier.team_members,
|
||||
order_history_months=tier.order_history_months,
|
||||
features=tier.features or [],
|
||||
is_popular=tier.code == TierCode.PROFESSIONAL.value,
|
||||
is_enterprise=tier.code == TierCode.ENTERPRISE.value,
|
||||
)
|
||||
else:
|
||||
# Hardcoded tier format
|
||||
tier_enum = tier["tier_enum"]
|
||||
limits = tier["limits"]
|
||||
return TierResponse(
|
||||
code=tier_enum.value,
|
||||
name=limits["name"],
|
||||
description=None,
|
||||
price_monthly=limits["price_monthly_cents"] / 100,
|
||||
price_annual=(limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
||||
price_monthly_cents=limits["price_monthly_cents"],
|
||||
price_annual_cents=limits.get("price_annual_cents"),
|
||||
orders_per_month=limits.get("orders_per_month"),
|
||||
products_limit=limits.get("products_limit"),
|
||||
team_members=limits.get("team_members"),
|
||||
order_history_months=limits.get("order_history_months"),
|
||||
features=limits.get("features", []),
|
||||
is_popular=tier_enum == TierCode.PROFESSIONAL,
|
||||
is_enterprise=tier_enum == TierCode.ENTERPRISE,
|
||||
)
|
||||
|
||||
|
||||
def _addon_to_response(addon) -> AddOnResponse:
|
||||
"""Convert an AddOnProduct to AddOnResponse."""
|
||||
return AddOnResponse(
|
||||
code=addon.code,
|
||||
name=addon.name,
|
||||
description=addon.description,
|
||||
category=addon.category,
|
||||
price=addon.price_cents / 100,
|
||||
price_cents=addon.price_cents,
|
||||
billing_period=addon.billing_period,
|
||||
quantity_unit=addon.quantity_unit,
|
||||
quantity_value=addon.quantity_value,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/tiers", response_model=list[TierResponse]) # public
|
||||
def get_tiers(db: Session = Depends(get_db)) -> list[TierResponse]:
|
||||
"""
|
||||
Get all public subscription tiers.
|
||||
|
||||
Returns tiers from database if available, falls back to hardcoded TIER_LIMITS.
|
||||
"""
|
||||
# Try to get from database first
|
||||
db_tiers = platform_pricing_service.get_public_tiers(db)
|
||||
|
||||
if db_tiers:
|
||||
return [_tier_to_response(tier, is_from_db=True) for tier in db_tiers]
|
||||
|
||||
# Fallback to hardcoded tiers
|
||||
from app.modules.billing.models import TIER_LIMITS
|
||||
|
||||
tiers = []
|
||||
for tier_code in TIER_LIMITS:
|
||||
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code.value)
|
||||
if tier_data:
|
||||
tiers.append(_tier_to_response(tier_data, is_from_db=False))
|
||||
|
||||
return tiers
|
||||
|
||||
|
||||
@router.get("/tiers/{tier_code}", response_model=TierResponse) # public
|
||||
def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse:
|
||||
"""Get a specific tier by code."""
|
||||
# Try database first
|
||||
tier = platform_pricing_service.get_tier_by_code(db, tier_code)
|
||||
|
||||
if tier:
|
||||
return _tier_to_response(tier, is_from_db=True)
|
||||
|
||||
# Fallback to hardcoded
|
||||
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code)
|
||||
if tier_data:
|
||||
return _tier_to_response(tier_data, is_from_db=False)
|
||||
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="SubscriptionTier",
|
||||
identifier=tier_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/addons", response_model=list[AddOnResponse]) # public
|
||||
def get_addons(db: Session = Depends(get_db)) -> list[AddOnResponse]:
|
||||
"""
|
||||
Get all available add-on products.
|
||||
|
||||
Returns add-ons from database, or empty list if none configured.
|
||||
"""
|
||||
addons = platform_pricing_service.get_active_addons(db)
|
||||
return [_addon_to_response(addon) for addon in addons]
|
||||
|
||||
|
||||
@router.get("/pricing", response_model=PricingResponse) # public
|
||||
def get_pricing(db: Session = Depends(get_db)) -> PricingResponse:
|
||||
"""
|
||||
Get complete pricing information (tiers + add-ons).
|
||||
|
||||
This is the main endpoint for the pricing page.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
return PricingResponse(
|
||||
tiers=get_tiers(db),
|
||||
addons=get_addons(db),
|
||||
trial_days=settings.stripe_trial_days,
|
||||
annual_discount_months=2, # "2 months free" with annual billing
|
||||
)
|
||||
@@ -1,277 +0,0 @@
|
||||
# app/api/v1/platform/signup.py
|
||||
"""
|
||||
Platform signup API endpoints.
|
||||
|
||||
Handles the multi-step signup flow:
|
||||
1. Start signup (select tier)
|
||||
2. Claim Letzshop vendor (optional)
|
||||
3. Create account
|
||||
4. Setup payment (collect card via SetupIntent)
|
||||
5. Complete signup (create subscription with trial)
|
||||
|
||||
All endpoints are public (no authentication required).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.services.platform_signup_service import platform_signup_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SignupStartRequest(BaseModel):
|
||||
"""Start signup - select tier."""
|
||||
|
||||
tier_code: str
|
||||
is_annual: bool = False
|
||||
|
||||
|
||||
class SignupStartResponse(BaseModel):
|
||||
"""Response from signup start."""
|
||||
|
||||
session_id: str
|
||||
tier_code: str
|
||||
is_annual: bool
|
||||
|
||||
|
||||
class ClaimVendorRequest(BaseModel):
|
||||
"""Claim Letzshop vendor."""
|
||||
|
||||
session_id: str
|
||||
letzshop_slug: str
|
||||
letzshop_vendor_id: str | None = None
|
||||
|
||||
|
||||
class ClaimVendorResponse(BaseModel):
|
||||
"""Response from vendor claim."""
|
||||
|
||||
session_id: str
|
||||
letzshop_slug: str
|
||||
vendor_name: str | None
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
"""Create account."""
|
||||
|
||||
session_id: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
company_name: str
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class CreateAccountResponse(BaseModel):
|
||||
"""Response from account creation."""
|
||||
|
||||
session_id: str
|
||||
user_id: int
|
||||
vendor_id: int
|
||||
stripe_customer_id: str
|
||||
|
||||
|
||||
class SetupPaymentRequest(BaseModel):
|
||||
"""Request payment setup."""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class SetupPaymentResponse(BaseModel):
|
||||
"""Response with Stripe SetupIntent client secret."""
|
||||
|
||||
session_id: str
|
||||
client_secret: str
|
||||
stripe_customer_id: str
|
||||
|
||||
|
||||
class CompleteSignupRequest(BaseModel):
|
||||
"""Complete signup after card collection."""
|
||||
|
||||
session_id: str
|
||||
setup_intent_id: str
|
||||
|
||||
|
||||
class CompleteSignupResponse(BaseModel):
|
||||
"""Response from signup completion."""
|
||||
|
||||
success: bool
|
||||
vendor_code: str
|
||||
vendor_id: int
|
||||
redirect_url: str
|
||||
trial_ends_at: str
|
||||
access_token: str | None = None # JWT token for automatic login
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/signup/start", response_model=SignupStartResponse) # public
|
||||
async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
||||
"""
|
||||
Start the signup process.
|
||||
|
||||
Step 1: User selects a tier and billing period.
|
||||
Creates a signup session to track the flow.
|
||||
"""
|
||||
session_id = platform_signup_service.create_session(
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
)
|
||||
|
||||
return SignupStartResponse(
|
||||
session_id=session_id,
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup/claim-vendor", response_model=ClaimVendorResponse) # public
|
||||
async def claim_letzshop_vendor(
|
||||
request: ClaimVendorRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClaimVendorResponse:
|
||||
"""
|
||||
Claim a Letzshop vendor.
|
||||
|
||||
Step 2 (optional): User claims their Letzshop shop.
|
||||
This pre-fills vendor info during account creation.
|
||||
"""
|
||||
vendor_name = platform_signup_service.claim_vendor(
|
||||
db=db,
|
||||
session_id=request.session_id,
|
||||
letzshop_slug=request.letzshop_slug,
|
||||
letzshop_vendor_id=request.letzshop_vendor_id,
|
||||
)
|
||||
|
||||
return ClaimVendorResponse(
|
||||
session_id=request.session_id,
|
||||
letzshop_slug=request.letzshop_slug,
|
||||
vendor_name=vendor_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup/create-account", response_model=CreateAccountResponse) # public
|
||||
async def create_account(
|
||||
request: CreateAccountRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CreateAccountResponse:
|
||||
"""
|
||||
Create user and vendor accounts.
|
||||
|
||||
Step 3: User provides account details.
|
||||
Creates User, Company, Vendor, and Stripe Customer.
|
||||
"""
|
||||
result = platform_signup_service.create_account(
|
||||
db=db,
|
||||
session_id=request.session_id,
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
company_name=request.company_name,
|
||||
phone=request.phone,
|
||||
)
|
||||
|
||||
return CreateAccountResponse(
|
||||
session_id=request.session_id,
|
||||
user_id=result.user_id,
|
||||
vendor_id=result.vendor_id,
|
||||
stripe_customer_id=result.stripe_customer_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public
|
||||
async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
||||
"""
|
||||
Create Stripe SetupIntent for card collection.
|
||||
|
||||
Step 4: Collect card details without charging.
|
||||
The card will be charged after the trial period ends.
|
||||
"""
|
||||
client_secret, stripe_customer_id = platform_signup_service.setup_payment(
|
||||
session_id=request.session_id,
|
||||
)
|
||||
|
||||
return SetupPaymentResponse(
|
||||
session_id=request.session_id,
|
||||
client_secret=client_secret,
|
||||
stripe_customer_id=stripe_customer_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup/complete", response_model=CompleteSignupResponse) # public
|
||||
async def complete_signup(
|
||||
request: CompleteSignupRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CompleteSignupResponse:
|
||||
"""
|
||||
Complete signup after card collection.
|
||||
|
||||
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
||||
"""
|
||||
result = platform_signup_service.complete_signup(
|
||||
db=db,
|
||||
session_id=request.session_id,
|
||||
setup_intent_id=request.setup_intent_id,
|
||||
)
|
||||
|
||||
# Set HTTP-only cookie for page navigation (same as login does)
|
||||
# This enables the user to access vendor pages immediately after signup
|
||||
if result.access_token:
|
||||
response.set_cookie(
|
||||
key="vendor_token",
|
||||
value=result.access_token,
|
||||
httponly=True, # JavaScript cannot access (XSS protection)
|
||||
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=3600 * 24, # 24 hours
|
||||
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
|
||||
)
|
||||
logger.info(f"Set vendor_token cookie for new vendor {result.vendor_code}")
|
||||
|
||||
return CompleteSignupResponse(
|
||||
success=result.success,
|
||||
vendor_code=result.vendor_code,
|
||||
vendor_id=result.vendor_id,
|
||||
redirect_url=result.redirect_url,
|
||||
trial_ends_at=result.trial_ends_at,
|
||||
access_token=result.access_token,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/signup/session/{session_id}") # public
|
||||
async def get_signup_session(session_id: str) -> dict:
|
||||
"""
|
||||
Get signup session status.
|
||||
|
||||
Useful for resuming an incomplete signup.
|
||||
"""
|
||||
session = platform_signup_service.get_session_or_raise(session_id)
|
||||
|
||||
# Return safe subset of session data
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"step": session.get("step"),
|
||||
"tier_code": session.get("tier_code"),
|
||||
"is_annual": session.get("is_annual"),
|
||||
"letzshop_slug": session.get("letzshop_slug"),
|
||||
"vendor_name": session.get("vendor_name"),
|
||||
"created_at": session.get("created_at"),
|
||||
}
|
||||
Reference in New Issue
Block a user