feat: add platform marketing homepage with signup flow
Implement complete marketing homepage for Wizamart targeting Letzshop vendors in Luxembourg. Includes: - Marketing homepage with hero, pricing tiers, and add-ons - 4-step signup wizard with Stripe card collection (30-day trial) - Letzshop vendor lookup for shop claiming - Platform API endpoints for pricing, vendors, and signup - Stripe SetupIntent integration for trial with card upfront - Database fields for Letzshop vendor identity tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
"""add_letzshop_vendor_fields_and_trial_tracking
|
||||||
|
|
||||||
|
Revision ID: 404b3e2d2865
|
||||||
|
Revises: l0a1b2c3d4e5
|
||||||
|
Create Date: 2025-12-27 09:49:44.715243
|
||||||
|
|
||||||
|
Adds:
|
||||||
|
- vendors.letzshop_vendor_id - Link to Letzshop marketplace profile
|
||||||
|
- vendors.letzshop_vendor_slug - Letzshop shop URL slug
|
||||||
|
- vendor_subscriptions.card_collected_at - Track when card was collected for trial
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '404b3e2d2865'
|
||||||
|
down_revision: Union[str, None] = 'l0a1b2c3d4e5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add Letzshop vendor identity fields to vendors table
|
||||||
|
op.add_column('vendors', sa.Column('letzshop_vendor_id', sa.String(length=100), nullable=True))
|
||||||
|
op.add_column('vendors', sa.Column('letzshop_vendor_slug', sa.String(length=200), nullable=True))
|
||||||
|
op.create_index(op.f('ix_vendors_letzshop_vendor_id'), 'vendors', ['letzshop_vendor_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_vendors_letzshop_vendor_slug'), 'vendors', ['letzshop_vendor_slug'], unique=False)
|
||||||
|
|
||||||
|
# Add card collection tracking to vendor_subscriptions
|
||||||
|
op.add_column('vendor_subscriptions', sa.Column('card_collected_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove card collection tracking from vendor_subscriptions
|
||||||
|
op.drop_column('vendor_subscriptions', 'card_collected_at')
|
||||||
|
|
||||||
|
# Remove Letzshop vendor identity fields from vendors
|
||||||
|
op.drop_index(op.f('ix_vendors_letzshop_vendor_slug'), table_name='vendors')
|
||||||
|
op.drop_index(op.f('ix_vendors_letzshop_vendor_id'), table_name='vendors')
|
||||||
|
op.drop_column('vendors', 'letzshop_vendor_slug')
|
||||||
|
op.drop_column('vendors', 'letzshop_vendor_id')
|
||||||
@@ -10,7 +10,7 @@ This module provides:
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import admin, shop, vendor
|
from app.api.v1 import admin, platform, shop, vendor
|
||||||
from app.api.v1.shared import language, webhooks
|
from app.api.v1.shared import language, webhooks
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -36,6 +36,13 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"])
|
|||||||
|
|
||||||
api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"])
|
api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"])
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PLATFORM ROUTES (Public marketing and signup)
|
||||||
|
# Prefix: /api/v1/platform
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"])
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SHARED ROUTES (Cross-context utilities)
|
# SHARED ROUTES (Cross-context utilities)
|
||||||
# Prefix: /api/v1
|
# Prefix: /api/v1
|
||||||
|
|||||||
22
app/api/v1/platform/__init__.py
Normal file
22
app/api/v1/platform/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 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"])
|
||||||
222
app/api/v1/platform/letzshop_vendors.py
Normal file
222
app/api/v1/platform/letzshop_vendors.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from models.database.vendor import Vendor
|
||||||
|
|
||||||
|
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
|
||||||
|
description: str | None = None
|
||||||
|
logo_url: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
letzshop_url: str
|
||||||
|
is_claimed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def check_if_claimed(db: Session, letzshop_slug: str) -> bool:
|
||||||
|
"""Check if a Letzshop vendor is already claimed."""
|
||||||
|
return db.query(Vendor).filter(
|
||||||
|
Vendor.letzshop_vendor_slug == letzshop_slug,
|
||||||
|
Vendor.is_active == True,
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/letzshop-vendors", response_model=LetzshopVendorListResponse)
|
||||||
|
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,
|
||||||
|
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 (placeholder - will fetch from cache/API).
|
||||||
|
|
||||||
|
In production, this would fetch from a cached vendor list
|
||||||
|
that is periodically synced from Letzshop's public directory.
|
||||||
|
"""
|
||||||
|
# TODO: Implement actual Letzshop vendor listing
|
||||||
|
# For now, return placeholder data to allow UI development
|
||||||
|
|
||||||
|
# This is placeholder data - in production, we would:
|
||||||
|
# 1. Query our cached letzshop_vendor_cache table
|
||||||
|
# 2. Or fetch from Letzshop's public API if available
|
||||||
|
|
||||||
|
# Return empty list for now - the actual data will come from Phase 4
|
||||||
|
return LetzshopVendorListResponse(
|
||||||
|
vendors=[],
|
||||||
|
total=0,
|
||||||
|
page=page,
|
||||||
|
limit=limit,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse)
|
||||||
|
async def lookup_letzshop_vendor(
|
||||||
|
request: LetzshopLookupRequest,
|
||||||
|
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. Attempts to fetch vendor info from Letzshop
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already claimed
|
||||||
|
is_claimed = check_if_claimed(db, slug)
|
||||||
|
|
||||||
|
# TODO: Fetch actual vendor info from Letzshop (Phase 4)
|
||||||
|
# For now, return basic info based on the slug
|
||||||
|
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
||||||
|
|
||||||
|
vendor_info = LetzshopVendorInfo(
|
||||||
|
slug=slug,
|
||||||
|
name=slug.replace("-", " ").title(), # Placeholder name
|
||||||
|
letzshop_url=letzshop_url,
|
||||||
|
is_claimed=is_claimed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LetzshopLookupResponse(
|
||||||
|
found=True,
|
||||||
|
vendor=vendor_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
async def get_letzshop_vendor(
|
||||||
|
slug: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LetzshopVendorInfo:
|
||||||
|
"""
|
||||||
|
Get a specific Letzshop vendor by slug.
|
||||||
|
|
||||||
|
Returns 404 if vendor not found.
|
||||||
|
"""
|
||||||
|
slug = slug.lower()
|
||||||
|
is_claimed = check_if_claimed(db, slug)
|
||||||
|
|
||||||
|
# TODO: Fetch actual vendor info from cache/API (Phase 4)
|
||||||
|
# For now, return placeholder based on slug
|
||||||
|
|
||||||
|
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
||||||
|
|
||||||
|
return LetzshopVendorInfo(
|
||||||
|
slug=slug,
|
||||||
|
name=slug.replace("-", " ").title(),
|
||||||
|
letzshop_url=letzshop_url,
|
||||||
|
is_claimed=is_claimed,
|
||||||
|
)
|
||||||
282
app/api/v1/platform/pricing.py
Normal file
282
app/api/v1/platform/pricing.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from models.database.subscription import (
|
||||||
|
AddOnProduct,
|
||||||
|
BillingPeriod,
|
||||||
|
SubscriptionTier,
|
||||||
|
TIER_LIMITS,
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tiers", response_model=list[TierResponse])
|
||||||
|
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 = (
|
||||||
|
db.query(SubscriptionTier)
|
||||||
|
.filter(
|
||||||
|
SubscriptionTier.is_active == True,
|
||||||
|
SubscriptionTier.is_public == True,
|
||||||
|
)
|
||||||
|
.order_by(SubscriptionTier.display_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if db_tiers:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for tier in db_tiers
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fallback to hardcoded tiers
|
||||||
|
tiers = []
|
||||||
|
for tier_code, limits in TIER_LIMITS.items():
|
||||||
|
tiers.append(
|
||||||
|
TierResponse(
|
||||||
|
code=tier_code.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_code == TierCode.PROFESSIONAL,
|
||||||
|
is_enterprise=tier_code == TierCode.ENTERPRISE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tiers/{tier_code}", response_model=TierResponse)
|
||||||
|
def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse:
|
||||||
|
"""Get a specific tier by code."""
|
||||||
|
# Try database first
|
||||||
|
tier = (
|
||||||
|
db.query(SubscriptionTier)
|
||||||
|
.filter(
|
||||||
|
SubscriptionTier.code == tier_code,
|
||||||
|
SubscriptionTier.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if tier:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback to hardcoded
|
||||||
|
try:
|
||||||
|
tier_enum = TierCode(tier_code)
|
||||||
|
limits = TIER_LIMITS[tier_enum]
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/addons", response_model=list[AddOnResponse])
|
||||||
|
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 = (
|
||||||
|
db.query(AddOnProduct)
|
||||||
|
.filter(AddOnProduct.is_active == True)
|
||||||
|
.order_by(AddOnProduct.category, AddOnProduct.display_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for addon in addons
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pricing", response_model=PricingResponse)
|
||||||
|
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
|
||||||
|
)
|
||||||
541
app/api/v1/platform/signup.py
Normal file
541
app/api/v1/platform/signup.py
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# 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)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.stripe_service import stripe_service
|
||||||
|
from models.database.subscription import (
|
||||||
|
SubscriptionStatus,
|
||||||
|
TierCode,
|
||||||
|
VendorSubscription,
|
||||||
|
)
|
||||||
|
from models.database.vendor import Vendor, VendorUser, VendorUserType
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# In-memory signup session storage (for simplicity)
|
||||||
|
# In production, use Redis or database table
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_signup_sessions: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_id() -> str:
|
||||||
|
"""Generate a secure session ID."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(session_id: str) -> dict | None:
|
||||||
|
"""Get a signup session by ID."""
|
||||||
|
return _signup_sessions.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def save_session(session_id: str, data: dict) -> None:
|
||||||
|
"""Save signup session data."""
|
||||||
|
_signup_sessions[session_id] = {
|
||||||
|
**data,
|
||||||
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(session_id: str) -> None:
|
||||||
|
"""Delete a signup session."""
|
||||||
|
_signup_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/start", response_model=SignupStartResponse)
|
||||||
|
async def start_signup(
|
||||||
|
request: SignupStartRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SignupStartResponse:
|
||||||
|
"""
|
||||||
|
Start the signup process.
|
||||||
|
|
||||||
|
Step 1: User selects a tier and billing period.
|
||||||
|
Creates a signup session to track the flow.
|
||||||
|
"""
|
||||||
|
# Validate tier code
|
||||||
|
try:
|
||||||
|
tier = TierCode(request.tier_code)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid tier code: {request.tier_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session_id = create_session_id()
|
||||||
|
save_session(session_id, {
|
||||||
|
"step": "tier_selected",
|
||||||
|
"tier_code": tier.value,
|
||||||
|
"is_annual": request.is_annual,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Started signup session {session_id} for tier {tier.value}")
|
||||||
|
|
||||||
|
return SignupStartResponse(
|
||||||
|
session_id=session_id,
|
||||||
|
tier_code=tier.value,
|
||||||
|
is_annual=request.is_annual,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/claim-vendor", response_model=ClaimVendorResponse)
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
session = get_session(request.session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Signup session not found")
|
||||||
|
|
||||||
|
# Check if vendor is already claimed
|
||||||
|
existing = db.query(Vendor).filter(
|
||||||
|
Vendor.letzshop_vendor_slug == request.letzshop_slug,
|
||||||
|
Vendor.is_active == True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This Letzshop vendor is already claimed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update session with vendor info
|
||||||
|
session["letzshop_slug"] = request.letzshop_slug
|
||||||
|
session["letzshop_vendor_id"] = request.letzshop_vendor_id
|
||||||
|
session["step"] = "vendor_claimed"
|
||||||
|
|
||||||
|
# TODO: Fetch actual vendor name from Letzshop API
|
||||||
|
vendor_name = request.letzshop_slug.replace("-", " ").title()
|
||||||
|
session["vendor_name"] = vendor_name
|
||||||
|
|
||||||
|
save_session(request.session_id, session)
|
||||||
|
|
||||||
|
logger.info(f"Claimed vendor {request.letzshop_slug} for session {request.session_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)
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
session = get_session(request.session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Signup session not found")
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
from models.database.user import User
|
||||||
|
|
||||||
|
existing_user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="An account with this email already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Company
|
||||||
|
from models.database.company import Company
|
||||||
|
|
||||||
|
company = Company(
|
||||||
|
name=request.company_name,
|
||||||
|
contact_email=request.email,
|
||||||
|
contact_phone=request.phone,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create User
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=request.email,
|
||||||
|
hashed_password=get_password_hash(request.password),
|
||||||
|
first_name=request.first_name,
|
||||||
|
last_name=request.last_name,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Generate vendor code
|
||||||
|
vendor_code = request.company_name.upper().replace(" ", "_")[:20]
|
||||||
|
# Ensure unique
|
||||||
|
base_code = vendor_code
|
||||||
|
counter = 1
|
||||||
|
while db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first():
|
||||||
|
vendor_code = f"{base_code}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Generate subdomain
|
||||||
|
subdomain = request.company_name.lower().replace(" ", "-")
|
||||||
|
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
|
||||||
|
base_subdomain = subdomain
|
||||||
|
counter = 1
|
||||||
|
while db.query(Vendor).filter(Vendor.subdomain == subdomain).first():
|
||||||
|
subdomain = f"{base_subdomain}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create Vendor
|
||||||
|
vendor = Vendor(
|
||||||
|
company_id=company.id,
|
||||||
|
vendor_code=vendor_code,
|
||||||
|
subdomain=subdomain,
|
||||||
|
name=request.company_name,
|
||||||
|
contact_email=request.email,
|
||||||
|
contact_phone=request.phone,
|
||||||
|
is_active=True,
|
||||||
|
letzshop_vendor_slug=session.get("letzshop_slug"),
|
||||||
|
letzshop_vendor_id=session.get("letzshop_vendor_id"),
|
||||||
|
)
|
||||||
|
db.add(vendor)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create VendorUser (owner)
|
||||||
|
vendor_user = VendorUser(
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
user_id=user.id,
|
||||||
|
user_type=VendorUserType.OWNER.value,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(vendor_user)
|
||||||
|
|
||||||
|
# Create Stripe Customer
|
||||||
|
stripe_customer_id = stripe_service.create_customer(
|
||||||
|
vendor=vendor,
|
||||||
|
email=request.email,
|
||||||
|
name=f"{request.first_name} {request.last_name}",
|
||||||
|
metadata={
|
||||||
|
"company_name": request.company_name,
|
||||||
|
"tier": session.get("tier_code"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create VendorSubscription (in trial status, without Stripe subscription yet)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
trial_end = now + timedelta(days=settings.stripe_trial_days)
|
||||||
|
|
||||||
|
subscription = VendorSubscription(
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
tier=session.get("tier_code", TierCode.ESSENTIAL.value),
|
||||||
|
status=SubscriptionStatus.TRIAL.value,
|
||||||
|
period_start=now,
|
||||||
|
period_end=trial_end,
|
||||||
|
trial_ends_at=trial_end,
|
||||||
|
is_annual=session.get("is_annual", False),
|
||||||
|
stripe_customer_id=stripe_customer_id,
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["vendor_id"] = vendor.id
|
||||||
|
session["vendor_code"] = vendor_code
|
||||||
|
session["stripe_customer_id"] = stripe_customer_id
|
||||||
|
session["step"] = "account_created"
|
||||||
|
save_session(request.session_id, session)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created account for {request.email}: "
|
||||||
|
f"user_id={user.id}, vendor_id={vendor.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateAccountResponse(
|
||||||
|
session_id=request.session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
stripe_customer_id=stripe_customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error creating account: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create account")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse)
|
||||||
|
async def setup_payment(
|
||||||
|
request: SetupPaymentRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SetupPaymentResponse:
|
||||||
|
"""
|
||||||
|
Create Stripe SetupIntent for card collection.
|
||||||
|
|
||||||
|
Step 4: Collect card details without charging.
|
||||||
|
The card will be charged after the trial period ends.
|
||||||
|
"""
|
||||||
|
session = get_session(request.session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Signup session not found")
|
||||||
|
|
||||||
|
if "stripe_customer_id" not in session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Account not created. Please complete step 3 first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe_customer_id = session["stripe_customer_id"]
|
||||||
|
|
||||||
|
# Create SetupIntent
|
||||||
|
setup_intent = stripe_service.create_setup_intent(
|
||||||
|
customer_id=stripe_customer_id,
|
||||||
|
metadata={
|
||||||
|
"session_id": request.session_id,
|
||||||
|
"vendor_id": str(session.get("vendor_id")),
|
||||||
|
"tier": session.get("tier_code"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
session["setup_intent_id"] = setup_intent.id
|
||||||
|
session["step"] = "payment_pending"
|
||||||
|
save_session(request.session_id, session)
|
||||||
|
|
||||||
|
logger.info(f"Created SetupIntent {setup_intent.id} for session {request.session_id}")
|
||||||
|
|
||||||
|
return SetupPaymentResponse(
|
||||||
|
session_id=request.session_id,
|
||||||
|
client_secret=setup_intent.client_secret,
|
||||||
|
stripe_customer_id=stripe_customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/complete", response_model=CompleteSignupResponse)
|
||||||
|
async def complete_signup(
|
||||||
|
request: CompleteSignupRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CompleteSignupResponse:
|
||||||
|
"""
|
||||||
|
Complete signup after card collection.
|
||||||
|
|
||||||
|
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||||
|
"""
|
||||||
|
session = get_session(request.session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Signup session not found")
|
||||||
|
|
||||||
|
vendor_id = session.get("vendor_id")
|
||||||
|
stripe_customer_id = session.get("stripe_customer_id")
|
||||||
|
|
||||||
|
if not vendor_id or not stripe_customer_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Incomplete signup. Please start again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve SetupIntent to get payment method
|
||||||
|
setup_intent = stripe_service.get_setup_intent(request.setup_intent_id)
|
||||||
|
|
||||||
|
if setup_intent.status != "succeeded":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Card setup not completed. Please try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_method_id = setup_intent.payment_method
|
||||||
|
|
||||||
|
# Attach payment method to customer
|
||||||
|
stripe_service.attach_payment_method_to_customer(
|
||||||
|
customer_id=stripe_customer_id,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
set_as_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update subscription record with card collection time
|
||||||
|
subscription = (
|
||||||
|
db.query(VendorSubscription)
|
||||||
|
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if subscription:
|
||||||
|
subscription.card_collected_at = datetime.now(UTC)
|
||||||
|
subscription.stripe_payment_method_id = payment_method_id
|
||||||
|
|
||||||
|
# TODO: Create actual Stripe subscription with trial
|
||||||
|
# For now, just mark as trial with card collected
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||||
|
vendor_code = vendor.vendor_code if vendor else session.get("vendor_code")
|
||||||
|
|
||||||
|
# Clean up session
|
||||||
|
delete_session(request.session_id)
|
||||||
|
|
||||||
|
trial_ends_at = subscription.trial_ends_at if subscription else datetime.now(UTC) + timedelta(days=30)
|
||||||
|
|
||||||
|
logger.info(f"Completed signup for vendor {vendor_id}")
|
||||||
|
|
||||||
|
return CompleteSignupResponse(
|
||||||
|
success=True,
|
||||||
|
vendor_code=vendor_code,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
redirect_url=f"/vendors/{vendor_code}/dashboard",
|
||||||
|
trial_ends_at=trial_ends_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error completing signup: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to complete signup")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup/session/{session_id}")
|
||||||
|
async def get_signup_session(session_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get signup session status.
|
||||||
|
|
||||||
|
Useful for resuming an incomplete signup.
|
||||||
|
"""
|
||||||
|
session = get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
# 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"),
|
||||||
|
}
|
||||||
@@ -123,7 +123,7 @@ class Settings(BaseSettings):
|
|||||||
stripe_secret_key: str = ""
|
stripe_secret_key: str = ""
|
||||||
stripe_publishable_key: str = ""
|
stripe_publishable_key: str = ""
|
||||||
stripe_webhook_secret: str = ""
|
stripe_webhook_secret: str = ""
|
||||||
stripe_trial_days: int = 14
|
stripe_trial_days: int = 30 # 1-month free trial (card collected upfront but not charged)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DEMO/SEED DATA CONFIGURATION
|
# DEMO/SEED DATA CONFIGURATION
|
||||||
|
|||||||
250
app/routes/platform_pages.py
Normal file
250
app/routes/platform_pages.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# app/routes/platform_pages.py
|
||||||
|
"""
|
||||||
|
Platform public page routes.
|
||||||
|
|
||||||
|
These routes serve the marketing homepage, pricing page,
|
||||||
|
Letzshop vendor finder, and signup wizard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Get the templates directory
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
TEMPLATES_DIR = BASE_DIR / "app" / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_context(request: Request, db: Session) -> dict:
|
||||||
|
"""Build context for platform pages."""
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"platform_name": "Wizamart",
|
||||||
|
"platform_domain": settings.platform_domain,
|
||||||
|
"stripe_publishable_key": settings.stripe_publishable_key,
|
||||||
|
"trial_days": settings.stripe_trial_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Homepage
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse, name="platform_homepage")
|
||||||
|
async def homepage(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Platform marketing homepage.
|
||||||
|
|
||||||
|
Displays:
|
||||||
|
- Hero section with value proposition
|
||||||
|
- Pricing tier cards
|
||||||
|
- Add-ons section
|
||||||
|
- Letzshop vendor finder
|
||||||
|
- Call to action for signup
|
||||||
|
"""
|
||||||
|
context = get_platform_context(request, db)
|
||||||
|
|
||||||
|
# Fetch tiers for display (use API service internally)
|
||||||
|
from models.database.subscription import TIER_LIMITS, TierCode
|
||||||
|
|
||||||
|
tiers = []
|
||||||
|
for tier_code, limits in TIER_LIMITS.items():
|
||||||
|
tiers.append({
|
||||||
|
"code": tier_code.value,
|
||||||
|
"name": limits["name"],
|
||||||
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||||
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
||||||
|
"orders_per_month": limits.get("orders_per_month"),
|
||||||
|
"products_limit": limits.get("products_limit"),
|
||||||
|
"team_members": limits.get("team_members"),
|
||||||
|
"features": limits.get("features", []),
|
||||||
|
"is_popular": tier_code == TierCode.PROFESSIONAL,
|
||||||
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
||||||
|
})
|
||||||
|
|
||||||
|
context["tiers"] = tiers
|
||||||
|
|
||||||
|
# Add-ons (hardcoded for now, will come from DB)
|
||||||
|
context["addons"] = [
|
||||||
|
{
|
||||||
|
"code": "domain",
|
||||||
|
"name": "Custom Domain",
|
||||||
|
"description": "Use your own domain (mydomain.com)",
|
||||||
|
"price": 15,
|
||||||
|
"billing_period": "year",
|
||||||
|
"icon": "globe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ssl_premium",
|
||||||
|
"name": "Premium SSL",
|
||||||
|
"description": "EV certificate for trust badges",
|
||||||
|
"price": 49,
|
||||||
|
"billing_period": "year",
|
||||||
|
"icon": "shield-check",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "email",
|
||||||
|
"name": "Email Package",
|
||||||
|
"description": "Professional email addresses",
|
||||||
|
"price": 5,
|
||||||
|
"billing_period": "month",
|
||||||
|
"icon": "mail",
|
||||||
|
"options": [
|
||||||
|
{"quantity": 5, "price": 5},
|
||||||
|
{"quantity": 10, "price": 9},
|
||||||
|
{"quantity": 25, "price": 19},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"platform/homepage-wizamart.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Pricing Page
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pricing", response_class=HTMLResponse, name="platform_pricing")
|
||||||
|
async def pricing_page(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Standalone pricing page with detailed tier comparison.
|
||||||
|
"""
|
||||||
|
context = get_platform_context(request, db)
|
||||||
|
|
||||||
|
# Reuse tier data from homepage
|
||||||
|
from models.database.subscription import TIER_LIMITS, TierCode
|
||||||
|
|
||||||
|
tiers = []
|
||||||
|
for tier_code, limits in TIER_LIMITS.items():
|
||||||
|
tiers.append({
|
||||||
|
"code": tier_code.value,
|
||||||
|
"name": limits["name"],
|
||||||
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||||
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
||||||
|
"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_code == TierCode.PROFESSIONAL,
|
||||||
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
||||||
|
})
|
||||||
|
|
||||||
|
context["tiers"] = tiers
|
||||||
|
context["page_title"] = "Pricing"
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"platform/pricing.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Find Your Shop (Letzshop Vendor Browser)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/find-shop", response_class=HTMLResponse, name="platform_find_shop")
|
||||||
|
async def find_shop_page(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Letzshop vendor browser page.
|
||||||
|
|
||||||
|
Allows vendors to search for and claim their Letzshop shop.
|
||||||
|
"""
|
||||||
|
context = get_platform_context(request, db)
|
||||||
|
context["page_title"] = "Find Your Letzshop Shop"
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"platform/find-shop.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Signup Wizard
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup", response_class=HTMLResponse, name="platform_signup")
|
||||||
|
async def signup_page(
|
||||||
|
request: Request,
|
||||||
|
tier: str | None = None,
|
||||||
|
annual: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Multi-step signup wizard.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- tier: Pre-selected tier code
|
||||||
|
- annual: Pre-select annual billing
|
||||||
|
"""
|
||||||
|
context = get_platform_context(request, db)
|
||||||
|
context["page_title"] = "Start Your Free Trial"
|
||||||
|
context["selected_tier"] = tier
|
||||||
|
context["is_annual"] = annual
|
||||||
|
|
||||||
|
# Get tiers for tier selection step
|
||||||
|
from models.database.subscription import TIER_LIMITS, TierCode
|
||||||
|
|
||||||
|
tiers = []
|
||||||
|
for tier_code, limits in TIER_LIMITS.items():
|
||||||
|
tiers.append({
|
||||||
|
"code": tier_code.value,
|
||||||
|
"name": limits["name"],
|
||||||
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
||||||
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
context["tiers"] = tiers
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"platform/signup.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup/success", response_class=HTMLResponse, name="platform_signup_success")
|
||||||
|
async def signup_success_page(
|
||||||
|
request: Request,
|
||||||
|
vendor_code: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Signup success page.
|
||||||
|
|
||||||
|
Shown after successful account creation.
|
||||||
|
"""
|
||||||
|
context = get_platform_context(request, db)
|
||||||
|
context["page_title"] = "Welcome to Wizamart!"
|
||||||
|
context["vendor_code"] = vendor_code
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"platform/signup-success.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
@@ -442,6 +442,118 @@ class StripeService:
|
|||||||
logger.error(f"Webhook signature verification failed: {e}")
|
logger.error(f"Webhook signature verification failed: {e}")
|
||||||
raise ValueError("Invalid webhook signature")
|
raise ValueError("Invalid webhook signature")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# SetupIntent & Payment Method Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_setup_intent(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> stripe.SetupIntent:
|
||||||
|
"""
|
||||||
|
Create a SetupIntent to collect card without charging.
|
||||||
|
|
||||||
|
Used for trial signups where we collect card upfront
|
||||||
|
but don't charge until trial ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe customer ID
|
||||||
|
metadata: Optional metadata to attach
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stripe SetupIntent object with client_secret for frontend
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise ValueError("Stripe is not configured")
|
||||||
|
|
||||||
|
setup_intent = stripe.SetupIntent.create(
|
||||||
|
customer=customer_id,
|
||||||
|
payment_method_types=["card"],
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Created SetupIntent {setup_intent.id} for customer {customer_id}")
|
||||||
|
return setup_intent
|
||||||
|
|
||||||
|
def attach_payment_method_to_customer(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
set_as_default: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Attach a payment method to customer and optionally set as default.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe customer ID
|
||||||
|
payment_method_id: Payment method ID from confirmed SetupIntent
|
||||||
|
set_as_default: Whether to set as default payment method
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise ValueError("Stripe is not configured")
|
||||||
|
|
||||||
|
# Attach the payment method to the customer
|
||||||
|
stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
|
||||||
|
|
||||||
|
if set_as_default:
|
||||||
|
stripe.Customer.modify(
|
||||||
|
customer_id,
|
||||||
|
invoice_settings={"default_payment_method": payment_method_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Attached payment method {payment_method_id} to customer {customer_id} "
|
||||||
|
f"(default={set_as_default})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_subscription_with_trial(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
price_id: str,
|
||||||
|
trial_days: int = 30,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> stripe.Subscription:
|
||||||
|
"""
|
||||||
|
Create subscription with trial period.
|
||||||
|
|
||||||
|
Customer must have a default payment method attached.
|
||||||
|
Card will be charged automatically after trial ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe customer ID (must have default payment method)
|
||||||
|
price_id: Stripe price ID for the subscription tier
|
||||||
|
trial_days: Number of trial days (default 30)
|
||||||
|
metadata: Optional metadata to attach
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stripe Subscription object
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise ValueError("Stripe is not configured")
|
||||||
|
|
||||||
|
subscription = stripe.Subscription.create(
|
||||||
|
customer=customer_id,
|
||||||
|
items=[{"price": price_id}],
|
||||||
|
trial_period_days=trial_days,
|
||||||
|
metadata=metadata or {},
|
||||||
|
# Use default payment method for future charges
|
||||||
|
default_payment_method=None, # Uses customer's default
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created subscription {subscription.id} with {trial_days}-day trial "
|
||||||
|
f"for customer {customer_id}"
|
||||||
|
)
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
def get_setup_intent(self, setup_intent_id: str) -> stripe.SetupIntent:
|
||||||
|
"""Get a SetupIntent by ID."""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise ValueError("Stripe is not configured")
|
||||||
|
|
||||||
|
return stripe.SetupIntent.retrieve(setup_intent_id)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Price/Product Management
|
# Price/Product Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
{# Dynamic page title #}
|
{# Dynamic page title #}
|
||||||
<title>{% block title %}Multi-Vendor Marketplace Platform{% endblock %}</title>
|
<title>{% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %}</title>
|
||||||
|
|
||||||
{# SEO Meta Tags #}
|
{# SEO Meta Tags #}
|
||||||
<meta name="description" content="{% block meta_description %}Leading multi-vendor marketplace platform connecting vendors with customers worldwide.{% endblock %}">
|
<meta name="description" content="{% block meta_description %}Lightweight OMS for Letzshop vendors in Luxembourg. Order management, inventory, and invoicing made simple.{% endblock %}">
|
||||||
<meta name="keywords" content="{% block meta_keywords %}marketplace, multi-vendor, e-commerce, online shopping{% endblock %}">
|
<meta name="keywords" content="{% block meta_keywords %}letzshop, order management, oms, luxembourg, e-commerce, invoicing, inventory{% endblock %}">
|
||||||
|
|
||||||
{# Favicon #}
|
{# Favicon #}
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
||||||
@@ -57,22 +57,33 @@
|
|||||||
{# Logo / Brand #}
|
{# Logo / Brand #}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a href="/" class="flex items-center space-x-3">
|
<a href="/" class="flex items-center space-x-3">
|
||||||
<div class="w-8 h-8 rounded-lg gradient-primary flex items-center justify-center">
|
<div class="w-8 h-8 rounded-lg bg-gradient-to-r from-indigo-600 to-purple-600 flex items-center justify-center">
|
||||||
<span class="text-white font-bold text-xl">M</span>
|
<span class="text-white font-bold text-xl">W</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Marketplace
|
Wizamart
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Desktop Navigation #}
|
{# Desktop Navigation #}
|
||||||
<div class="hidden md:flex items-center space-x-8">
|
<div class="hidden md:flex items-center space-x-8">
|
||||||
|
{# Main Navigation #}
|
||||||
|
<a href="/pricing" class="text-gray-700 dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-400 font-medium transition-colors">
|
||||||
|
Pricing
|
||||||
|
</a>
|
||||||
|
<a href="/find-shop" class="text-gray-700 dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-400 font-medium transition-colors">
|
||||||
|
Find Your Shop
|
||||||
|
</a>
|
||||||
|
<a href="/signup" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
|
||||||
{# Dynamic header navigation from CMS #}
|
{# Dynamic header navigation from CMS #}
|
||||||
{% if header_pages %}
|
{% if header_pages %}
|
||||||
{% for page in header_pages %}
|
{% for page in header_pages %}
|
||||||
<a href="/{{ page.slug }}"
|
<a href="/{{ page.slug }}"
|
||||||
class="text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors">
|
class="text-gray-700 dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-400 font-medium transition-colors">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -137,15 +148,15 @@
|
|||||||
{# Brand Column #}
|
{# Brand Column #}
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<div class="flex items-center space-x-3 mb-4">
|
<div class="flex items-center space-x-3 mb-4">
|
||||||
<div class="w-8 h-8 rounded-lg gradient-primary flex items-center justify-center">
|
<div class="w-8 h-8 rounded-lg bg-gradient-to-r from-indigo-600 to-purple-600 flex items-center justify-center">
|
||||||
<span class="text-white font-bold text-xl">M</span>
|
<span class="text-white font-bold text-xl">W</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
<span class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Marketplace
|
Wizamart
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
Connecting vendors with customers worldwide. Build your online store today.
|
Lightweight OMS for Letzshop sellers. Manage orders, inventory, and invoicing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,7 +210,7 @@
|
|||||||
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
© 2024 Marketplace Platform. All rights reserved.
|
© 2025 Wizamart. Built for Luxembourg e-commerce.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex space-x-6 mt-4 md:mt-0">
|
<div class="flex space-x-6 mt-4 md:mt-0">
|
||||||
<a href="/privacy" class="text-gray-600 dark:text-gray-400 hover:text-primary text-sm transition-colors">
|
<a href="/privacy" class="text-gray-600 dark:text-gray-400 hover:text-primary text-sm transition-colors">
|
||||||
|
|||||||
168
app/templates/platform/find-shop.html
Normal file
168
app/templates/platform/find-shop.html
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{# app/templates/platform/find-shop.html #}
|
||||||
|
{# Letzshop Vendor Finder Page #}
|
||||||
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Find Your Shop - Wizamart{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="vendorFinderData()" class="py-16 lg:py-24">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Find Your Letzshop Shop
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
Enter your Letzshop shop URL or search by name to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Search Form #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchQuery"
|
||||||
|
@keyup.enter="lookupVendor()"
|
||||||
|
placeholder="Enter Letzshop URL or shop name..."
|
||||||
|
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="lookupVendor()"
|
||||||
|
:disabled="loading || !searchQuery.trim()"
|
||||||
|
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center min-w-[140px]">
|
||||||
|
<template x-if="loading">
|
||||||
|
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loading">
|
||||||
|
<span>Search</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Examples #}
|
||||||
|
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<strong>Examples:</strong>
|
||||||
|
<ul class="list-disc list-inside mt-1">
|
||||||
|
<li>https://letzshop.lu/vendors/my-shop</li>
|
||||||
|
<li>letzshop.lu/vendors/my-shop</li>
|
||||||
|
<li>my-shop (just the shop name)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Results #}
|
||||||
|
<template x-if="result">
|
||||||
|
<div class="mt-8 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<template x-if="result.found">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white" x-text="result.vendor.name"></h2>
|
||||||
|
<a :href="result.vendor.letzshop_url" target="_blank"
|
||||||
|
class="text-indigo-600 dark:text-indigo-400 hover:underline mt-1 inline-block"
|
||||||
|
x-text="result.vendor.letzshop_url"></a>
|
||||||
|
|
||||||
|
<template x-if="result.vendor.description">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-4" x-text="result.vendor.description"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="result.vendor.logo_url">
|
||||||
|
<img :src="result.vendor.logo_url" :alt="result.vendor.name"
|
||||||
|
class="w-20 h-20 rounded-xl object-cover border border-gray-200 dark:border-gray-700"/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex items-center gap-4">
|
||||||
|
<template x-if="!result.vendor.is_claimed">
|
||||||
|
<a :href="'/signup?letzshop=' + result.vendor.slug"
|
||||||
|
class="px-8 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl transition-colors">
|
||||||
|
Claim This Shop & Start Free Trial
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template x-if="result.vendor.is_claimed">
|
||||||
|
<div class="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-xl">
|
||||||
|
This shop has already been claimed. If this is your shop, please
|
||||||
|
<a href="/contact" class="text-indigo-600 hover:underline">contact support</a>.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!result.found">
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Shop Not Found</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400" x-text="result.error || 'We could not find a Letzshop shop with that URL. Please check and try again.'"></p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/signup" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
Or sign up without a Letzshop connection →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Help Section #}
|
||||||
|
<div class="mt-12 text-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Need Help?</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Don't have a Letzshop account yet? No problem!
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a href="https://letzshop.lu" target="_blank"
|
||||||
|
class="px-6 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Create a Letzshop Account
|
||||||
|
</a>
|
||||||
|
<a href="/signup"
|
||||||
|
class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition-colors">
|
||||||
|
Sign Up Without Letzshop
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function vendorFinderData() {
|
||||||
|
return {
|
||||||
|
searchQuery: '',
|
||||||
|
result: null,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async lookupVendor() {
|
||||||
|
if (!this.searchQuery.trim()) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.result = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: this.searchQuery })
|
||||||
|
});
|
||||||
|
|
||||||
|
this.result = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lookup error:', error);
|
||||||
|
this.result = { found: false, error: 'Failed to lookup. Please try again.' };
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
407
app/templates/platform/homepage-wizamart.html
Normal file
407
app/templates/platform/homepage-wizamart.html
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
{# app/templates/platform/homepage-wizamart.html #}
|
||||||
|
{# Wizamart Marketing Homepage - Letzshop OMS Platform #}
|
||||||
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %}
|
||||||
|
{% block meta_description %}Lightweight OMS for Letzshop vendors. Manage orders, inventory, and invoicing. Start your 30-day free trial today.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="homepageData()" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
|
||||||
|
{# =========================================================================
|
||||||
|
HERO SECTION
|
||||||
|
========================================================================= #}
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
|
||||||
|
<div class="text-center">
|
||||||
|
{# Badge #}
|
||||||
|
<div class="inline-flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-full text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
30-Day Free Trial - No Credit Card Required to Start
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Headline #}
|
||||||
|
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
|
||||||
|
Lightweight OMS for
|
||||||
|
<span class="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600">Letzshop Sellers</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{# Subheadline #}
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
|
||||||
|
Order management, inventory, and invoicing built for Luxembourg e-commerce.
|
||||||
|
Stop juggling spreadsheets. Start running your business.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# CTA Buttons #}
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a href="/signup"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
|
||||||
|
Start Free Trial
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="#find-shop"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-semibold rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 transition-all">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
Find Your Letzshop Shop
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Background Decoration #}
|
||||||
|
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||||
|
<div class="absolute -top-1/2 -right-1/4 w-96 h-96 bg-indigo-200 dark:bg-indigo-900/20 rounded-full blur-3xl opacity-50"></div>
|
||||||
|
<div class="absolute -bottom-1/2 -left-1/4 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# =========================================================================
|
||||||
|
PRICING SECTION
|
||||||
|
========================================================================= #}
|
||||||
|
<section id="pricing" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section Header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Simple, Transparent Pricing
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Choose the plan that fits your business. All plans include a {{ trial_days }}-day free trial.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Billing Toggle #}
|
||||||
|
<div class="flex items-center justify-center mt-8 space-x-4">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">Monthly</span>
|
||||||
|
<button @click="annual = !annual"
|
||||||
|
class="relative w-14 h-7 rounded-full transition-colors"
|
||||||
|
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
|
||||||
|
<span class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||||||
|
:class="annual ? 'translate-x-7' : ''"></span>
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
|
||||||
|
Annual
|
||||||
|
<span class="text-green-600 text-sm font-medium ml-1">Save 2 months!</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pricing Cards Grid #}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{% for tier in tiers %}
|
||||||
|
<div class="relative bg-gray-50 dark:bg-gray-900 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
|
||||||
|
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
|
||||||
|
|
||||||
|
{# Popular Badge #}
|
||||||
|
{% if tier.is_popular %}
|
||||||
|
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
MOST POPULAR
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Tier Name #}
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
|
||||||
|
|
||||||
|
{# Price #}
|
||||||
|
<div class="mb-6">
|
||||||
|
<template x-if="!annual">
|
||||||
|
<div>
|
||||||
|
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="annual">
|
||||||
|
<div>
|
||||||
|
{% if tier.price_annual %}
|
||||||
|
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ tier.price_annual }}/year
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">Custom</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Features List #}
|
||||||
|
<ul class="space-y-3 mb-8">
|
||||||
|
{# Orders #}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/month{% else %}Unlimited orders{% endif %}
|
||||||
|
</li>
|
||||||
|
{# Products #}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.products_limit %}{{ tier.products_limit }} products{% else %}Unlimited products{% endif %}
|
||||||
|
</li>
|
||||||
|
{# Team Members #}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.team_members %}{{ tier.team_members }} team member{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited team{% endif %}
|
||||||
|
</li>
|
||||||
|
{# Letzshop Sync #}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Letzshop order sync
|
||||||
|
</li>
|
||||||
|
{# Tier-specific features #}
|
||||||
|
{% if 'invoice_eu_vat' in tier.features %}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
EU VAT invoicing
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'analytics_dashboard' in tier.features %}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Analytics dashboard
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'api_access' in tier.features %}
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
API access
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# CTA Button #}
|
||||||
|
{% if tier.is_enterprise %}
|
||||||
|
<a href="/contact?tier=enterprise"
|
||||||
|
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Contact Sales
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/signup?tier={{ tier.code }}"
|
||||||
|
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
||||||
|
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
|
||||||
|
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# =========================================================================
|
||||||
|
ADD-ONS SECTION
|
||||||
|
========================================================================= #}
|
||||||
|
<section id="addons" class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section Header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Enhance Your Platform
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Add custom branding, professional email, and enhanced security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Add-ons Grid #}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{% for addon in addons %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
|
||||||
|
{# Icon #}
|
||||||
|
<div class="w-14 h-14 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center mb-6">
|
||||||
|
{% if addon.icon == 'globe' %}
|
||||||
|
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
{% elif addon.icon == 'shield-check' %}
|
||||||
|
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
{% elif addon.icon == 'mail' %}
|
||||||
|
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Name & Description #}
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ addon.name }}</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ addon.description }}</p>
|
||||||
|
|
||||||
|
{# Price #}
|
||||||
|
<div class="flex items-baseline">
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ addon.price }}</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 ml-1">/{{ addon.billing_period }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Options for email packages #}
|
||||||
|
{% if addon.options %}
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{% for opt in addon.options %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ opt.quantity }} addresses: {{ opt.price }}/month
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# =========================================================================
|
||||||
|
LETZSHOP VENDOR FINDER
|
||||||
|
========================================================================= #}
|
||||||
|
<section id="find-shop" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section Header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Find Your Letzshop Shop
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
Already selling on Letzshop? Enter your shop URL to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Search Form #}
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-8 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="shopUrl"
|
||||||
|
placeholder="Enter your Letzshop URL (e.g., letzshop.lu/vendors/my-shop)"
|
||||||
|
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="lookupVendor()"
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center">
|
||||||
|
<template x-if="loading">
|
||||||
|
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
Find My Shop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Result #}
|
||||||
|
<template x-if="vendorResult">
|
||||||
|
<div class="mt-6 p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<template x-if="vendorResult.found">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="vendorResult.vendor.name"></h3>
|
||||||
|
<a :href="vendorResult.vendor.letzshop_url" target="_blank" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline" x-text="vendorResult.vendor.letzshop_url"></a>
|
||||||
|
</div>
|
||||||
|
<template x-if="!vendorResult.vendor.is_claimed">
|
||||||
|
<a :href="'/signup?letzshop=' + vendorResult.vendor.slug"
|
||||||
|
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
|
||||||
|
Claim This Shop
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template x-if="vendorResult.vendor.is_claimed">
|
||||||
|
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
|
||||||
|
Already Claimed
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!vendorResult.found">
|
||||||
|
<div class="text-center text-gray-600 dark:text-gray-400">
|
||||||
|
<p x-text="vendorResult.error || 'Shop not found. Please check your URL and try again.'"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Help Text #}
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
Don't have a Letzshop account? <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">Sign up with Letzshop first</a>, then come back to connect your shop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# =========================================================================
|
||||||
|
FINAL CTA SECTION
|
||||||
|
========================================================================= #}
|
||||||
|
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||||
|
Ready to Streamline Your Orders?
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-indigo-100 mb-10">
|
||||||
|
Join Letzshop vendors who trust Wizamart for their order management.
|
||||||
|
Start your {{ trial_days }}-day free trial today.
|
||||||
|
</p>
|
||||||
|
<a href="/signup"
|
||||||
|
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
|
||||||
|
Start Free Trial
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function homepageData() {
|
||||||
|
return {
|
||||||
|
annual: false,
|
||||||
|
shopUrl: '',
|
||||||
|
vendorResult: null,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async lookupVendor() {
|
||||||
|
if (!this.shopUrl.trim()) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.vendorResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: this.shopUrl })
|
||||||
|
});
|
||||||
|
|
||||||
|
this.vendorResult = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lookup error:', error);
|
||||||
|
this.vendorResult = { found: false, error: 'Failed to lookup. Please try again.' };
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
119
app/templates/platform/pricing.html
Normal file
119
app/templates/platform/pricing.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{# app/templates/platform/pricing.html #}
|
||||||
|
{# Standalone Pricing Page #}
|
||||||
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Pricing - Wizamart{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="{ annual: false }" class="py-16 lg:py-24">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Choose Your Plan
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
All plans include a {{ trial_days }}-day free trial. No credit card required to start.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Billing Toggle #}
|
||||||
|
<div class="flex items-center justify-center mt-8 space-x-4">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">Monthly</span>
|
||||||
|
<button @click="annual = !annual"
|
||||||
|
class="relative w-14 h-7 rounded-full transition-colors"
|
||||||
|
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
|
||||||
|
<span class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||||||
|
:class="annual ? 'translate-x-7' : ''"></span>
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
|
||||||
|
Annual
|
||||||
|
<span class="text-green-600 text-sm font-medium ml-1">Save 2 months!</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pricing Cards #}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{% for tier in tiers %}
|
||||||
|
<div class="relative bg-white dark:bg-gray-800 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
|
||||||
|
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
|
||||||
|
|
||||||
|
{% if tier.is_popular %}
|
||||||
|
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">RECOMMENDED</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<template x-if="!annual">
|
||||||
|
<div>
|
||||||
|
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span>
|
||||||
|
<span class="text-gray-500">/month</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="annual">
|
||||||
|
<div>
|
||||||
|
{% if tier.price_annual %}
|
||||||
|
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}</span>
|
||||||
|
<span class="text-gray-500">/month</span>
|
||||||
|
<div class="text-sm text-gray-500">Billed {{ tier.price_annual }}/year</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">Custom Pricing</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/month{% else %}Unlimited orders{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.products_limit %}{{ tier.products_limit }} products{% else %}Unlimited products{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.team_members %}{{ tier.team_members }} team member{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited team{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% if tier.order_history_months %}{{ tier.order_history_months }} months history{% else %}Unlimited history{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if tier.is_enterprise %}
|
||||||
|
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
||||||
|
Contact Sales
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a :href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
||||||
|
class="block w-full py-3 font-semibold rounded-xl text-center transition-colors
|
||||||
|
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 text-indigo-700 hover:bg-indigo-200{% endif %}">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Back to Home #}
|
||||||
|
<div class="text-center mt-12">
|
||||||
|
<a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
← Back to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
81
app/templates/platform/signup-success.html
Normal file
81
app/templates/platform/signup-success.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{# app/templates/platform/signup-success.html #}
|
||||||
|
{# Signup Success Page #}
|
||||||
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Welcome to Wizamart!{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-screen py-16 bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div class="max-w-lg mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
|
||||||
|
{# Success Icon #}
|
||||||
|
<div class="w-24 h-24 mx-auto mb-8 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Welcome Message #}
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Welcome to Wizamart!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
Your account has been created and your {{ trial_days }}-day free trial has started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Next Steps #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-left mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">What's Next?</h2>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li class="flex items-start">
|
||||||
|
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||||
|
<strong>Connect Letzshop:</strong> Add your API key to start syncing orders automatically.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span>
|
||||||
|
</div>
|
||||||
|
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||||
|
<strong>Set Up Invoicing:</strong> Configure your invoice settings for Luxembourg compliance.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span>
|
||||||
|
</div>
|
||||||
|
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||||
|
<strong>Import Products:</strong> Sync your product catalog from Letzshop.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# CTA Button #}
|
||||||
|
{% if vendor_code %}
|
||||||
|
<a href="/vendor/{{ vendor_code }}/dashboard"
|
||||||
|
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all hover:scale-105">
|
||||||
|
Go to Dashboard
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/login"
|
||||||
|
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all">
|
||||||
|
Login to Dashboard
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Support Link #}
|
||||||
|
<p class="mt-8 text-gray-500 dark:text-gray-400">
|
||||||
|
Need help getting started?
|
||||||
|
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">Contact our support team</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
515
app/templates/platform/signup.html
Normal file
515
app/templates/platform/signup.html
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
{# app/templates/platform/signup.html #}
|
||||||
|
{# Multi-step Signup Wizard #}
|
||||||
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Start Your Free Trial - Wizamart{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
{# Stripe.js for payment #}
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="signupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
{# Progress Steps #}
|
||||||
|
<div class="mb-12">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
||||||
|
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
||||||
|
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
||||||
|
<template x-if="currentStep > index + 1">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="currentStep <= index + 1">
|
||||||
|
<span x-text="index + 1"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
||||||
|
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
||||||
|
x-text="stepName"></span>
|
||||||
|
<template x-if="index < 3">
|
||||||
|
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
||||||
|
<div class="h-full bg-indigo-600 rounded transition-all"
|
||||||
|
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Form Card #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
|
||||||
|
{# ===============================================================
|
||||||
|
STEP 1: SELECT PLAN
|
||||||
|
=============================================================== #}
|
||||||
|
<div x-show="currentStep === 1" class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
|
||||||
|
|
||||||
|
{# Billing Toggle #}
|
||||||
|
<div class="flex items-center justify-center mb-8 space-x-4">
|
||||||
|
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
|
||||||
|
<button @click="isAnnual = !isAnnual"
|
||||||
|
class="relative w-12 h-6 rounded-full transition-colors"
|
||||||
|
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
||||||
|
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
|
||||||
|
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
||||||
|
</button>
|
||||||
|
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
||||||
|
Annual <span class="text-green-600 text-xs">Save 17%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tier Options #}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for tier in tiers %}
|
||||||
|
{% if not tier.is_enterprise %}
|
||||||
|
<label class="block">
|
||||||
|
<input type="radio" name="tier" value="{{ tier.code }}"
|
||||||
|
x-model="selectedTier" class="hidden peer"/>
|
||||||
|
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
|
||||||
|
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
|
||||||
|
border-gray-200 dark:border-gray-700 hover:border-gray-300">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
|
||||||
|
•
|
||||||
|
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<template x-if="!isAnnual">
|
||||||
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly }}/mo</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="isAnnual">
|
||||||
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}/mo</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Free Trial Note #}
|
||||||
|
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||||
|
<p class="text-sm text-green-800 dark:text-green-300">
|
||||||
|
<strong>{{ trial_days }}-day free trial.</strong>
|
||||||
|
We'll collect your payment info, but you won't be charged until the trial ends.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="startSignup()"
|
||||||
|
:disabled="!selectedTier || loading"
|
||||||
|
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ===============================================================
|
||||||
|
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
||||||
|
=============================================================== #}
|
||||||
|
<div x-show="currentStep === 2" class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="letzshopUrl"
|
||||||
|
placeholder="letzshop.lu/vendors/your-shop"
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template x-if="letzshopVendor">
|
||||||
|
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||||
|
<p class="text-green-800 dark:text-green-300">
|
||||||
|
Found: <strong x-text="letzshopVendor.name"></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="letzshopError">
|
||||||
|
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
||||||
|
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex gap-4">
|
||||||
|
<button @click="currentStep = 1"
|
||||||
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button @click="claimVendor()"
|
||||||
|
:disabled="loading"
|
||||||
|
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
|
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ===============================================================
|
||||||
|
STEP 3: CREATE ACCOUNT
|
||||||
|
=============================================================== #}
|
||||||
|
<div x-show="currentStep === 3" class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Create Your Account</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
|
||||||
|
<input type="text" x-model="account.firstName" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
|
||||||
|
<input type="text" x-model="account.lastName" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company Name</label>
|
||||||
|
<input type="text" x-model="account.companyName" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||||
|
<input type="email" x-model="account.email" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||||
|
<input type="password" x-model="account.password" required minlength="8"
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="accountError">
|
||||||
|
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
||||||
|
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex gap-4">
|
||||||
|
<button @click="currentStep = 2"
|
||||||
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button @click="createAccount()"
|
||||||
|
:disabled="loading || !isAccountValid()"
|
||||||
|
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
|
Continue to Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ===============================================================
|
||||||
|
STEP 4: PAYMENT
|
||||||
|
=============================================================== #}
|
||||||
|
<div x-show="currentStep === 4" class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
||||||
|
|
||||||
|
{# Stripe Card Element #}
|
||||||
|
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
||||||
|
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex gap-4">
|
||||||
|
<button @click="currentStep = 3"
|
||||||
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button @click="submitPayment()"
|
||||||
|
:disabled="loading || paymentProcessing"
|
||||||
|
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
|
<template x-if="paymentProcessing">
|
||||||
|
<span>Processing...</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!paymentProcessing">
|
||||||
|
<span>Start Free Trial</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function signupWizard() {
|
||||||
|
return {
|
||||||
|
currentStep: 1,
|
||||||
|
loading: false,
|
||||||
|
sessionId: null,
|
||||||
|
|
||||||
|
// Step 1: Plan
|
||||||
|
selectedTier: '{{ selected_tier or "professional" }}',
|
||||||
|
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||||
|
|
||||||
|
// Step 2: Letzshop
|
||||||
|
letzshopUrl: '',
|
||||||
|
letzshopVendor: null,
|
||||||
|
letzshopError: null,
|
||||||
|
|
||||||
|
// Step 3: Account
|
||||||
|
account: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
companyName: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
accountError: null,
|
||||||
|
|
||||||
|
// Step 4: Payment
|
||||||
|
stripe: null,
|
||||||
|
cardElement: null,
|
||||||
|
paymentProcessing: false,
|
||||||
|
clientSecret: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Check URL params for pre-selection
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('tier')) {
|
||||||
|
this.selectedTier = params.get('tier');
|
||||||
|
}
|
||||||
|
if (params.get('annual') === 'true') {
|
||||||
|
this.isAnnual = true;
|
||||||
|
}
|
||||||
|
if (params.get('letzshop')) {
|
||||||
|
this.letzshopUrl = params.get('letzshop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Stripe when we get to step 4
|
||||||
|
this.$watch('currentStep', (step) => {
|
||||||
|
if (step === 4) {
|
||||||
|
this.initStripe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSignup() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/platform/signup/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tier_code: this.selectedTier,
|
||||||
|
is_annual: this.isAnnual
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
this.sessionId = data.session_id;
|
||||||
|
this.currentStep = 2;
|
||||||
|
} else {
|
||||||
|
alert(data.detail || 'Failed to start signup');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to start signup. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimVendor() {
|
||||||
|
if (this.letzshopUrl.trim()) {
|
||||||
|
this.loading = true;
|
||||||
|
this.letzshopError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First lookup the vendor
|
||||||
|
const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: this.letzshopUrl })
|
||||||
|
});
|
||||||
|
|
||||||
|
const lookupData = await lookupResponse.json();
|
||||||
|
|
||||||
|
if (lookupData.found && !lookupData.vendor.is_claimed) {
|
||||||
|
this.letzshopVendor = lookupData.vendor;
|
||||||
|
|
||||||
|
// Claim the vendor
|
||||||
|
const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: this.sessionId,
|
||||||
|
letzshop_slug: lookupData.vendor.slug
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimResponse.ok) {
|
||||||
|
const claimData = await claimResponse.json();
|
||||||
|
this.account.companyName = claimData.vendor_name || '';
|
||||||
|
this.currentStep = 3;
|
||||||
|
} else {
|
||||||
|
const error = await claimResponse.json();
|
||||||
|
this.letzshopError = error.detail || 'Failed to claim vendor';
|
||||||
|
}
|
||||||
|
} else if (lookupData.vendor?.is_claimed) {
|
||||||
|
this.letzshopError = 'This shop has already been claimed.';
|
||||||
|
} else {
|
||||||
|
this.letzshopError = lookupData.error || 'Shop not found.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
this.letzshopError = 'Failed to lookup vendor.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Skip this step
|
||||||
|
this.currentStep = 3;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isAccountValid() {
|
||||||
|
return this.account.firstName.trim() &&
|
||||||
|
this.account.lastName.trim() &&
|
||||||
|
this.account.companyName.trim() &&
|
||||||
|
this.account.email.trim() &&
|
||||||
|
this.account.password.length >= 8;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAccount() {
|
||||||
|
this.loading = true;
|
||||||
|
this.accountError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/platform/signup/create-account', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: this.sessionId,
|
||||||
|
email: this.account.email,
|
||||||
|
password: this.account.password,
|
||||||
|
first_name: this.account.firstName,
|
||||||
|
last_name: this.account.lastName,
|
||||||
|
company_name: this.account.companyName
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
this.currentStep = 4;
|
||||||
|
} else {
|
||||||
|
this.accountError = data.detail || 'Failed to create account';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
this.accountError = 'Failed to create account. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async initStripe() {
|
||||||
|
{% if stripe_publishable_key %}
|
||||||
|
this.stripe = Stripe('{{ stripe_publishable_key }}');
|
||||||
|
const elements = this.stripe.elements();
|
||||||
|
|
||||||
|
this.cardElement = elements.create('card', {
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#374151',
|
||||||
|
'::placeholder': { color: '#9CA3AF' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cardElement.mount('#card-element');
|
||||||
|
this.cardElement.on('change', (event) => {
|
||||||
|
const displayError = document.getElementById('card-errors');
|
||||||
|
displayError.textContent = event.error ? event.error.message : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get SetupIntent
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/platform/signup/setup-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: this.sessionId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
this.clientSecret = data.client_secret;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting SetupIntent:', error);
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
console.warn('Stripe not configured');
|
||||||
|
{% endif %}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitPayment() {
|
||||||
|
if (!this.stripe || !this.clientSecret) {
|
||||||
|
alert('Payment not configured. Please contact support.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paymentProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { setupIntent, error } = await this.stripe.confirmCardSetup(
|
||||||
|
this.clientSecret,
|
||||||
|
{ payment_method: { card: this.cardElement } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
document.getElementById('card-errors').textContent = error.message;
|
||||||
|
this.paymentProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signup
|
||||||
|
const response = await fetch('/api/v1/platform/signup/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: this.sessionId,
|
||||||
|
setup_intent_id: setupIntent.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/signup/success?vendor_code=' + data.vendor_code;
|
||||||
|
} else {
|
||||||
|
alert(data.detail || 'Failed to complete signup');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment error:', error);
|
||||||
|
alert('Payment failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.paymentProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
495
docs/implementation/platform-marketing-homepage.md
Normal file
495
docs/implementation/platform-marketing-homepage.md
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
# Platform Marketing Homepage
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Wizamart marketing homepage serves as the main public entry point for Letzshop vendors looking to use the order management platform. It provides a complete self-service signup flow with Stripe payment integration.
|
||||||
|
|
||||||
|
**Target Audience:** Letzshop vendors in Luxembourg seeking order management solutions.
|
||||||
|
|
||||||
|
**Key Value Proposition:** "Lightweight OMS for Letzshop Sellers" - Order management, inventory, and invoicing built for Luxembourg e-commerce.
|
||||||
|
|
||||||
|
## Features Summary
|
||||||
|
|
||||||
|
| Feature | URL | Description |
|
||||||
|
|---------|-----|-------------|
|
||||||
|
| Marketing Homepage | `/` | Hero, pricing, add-ons, vendor finder |
|
||||||
|
| Pricing Page | `/pricing` | Detailed tier comparison |
|
||||||
|
| Find Your Shop | `/find-shop` | Letzshop vendor lookup |
|
||||||
|
| Signup Wizard | `/signup` | 4-step registration flow |
|
||||||
|
| Signup Success | `/signup/success` | Welcome & next steps |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing Tiers
|
||||||
|
|
||||||
|
Based on `docs/marketing/pricing.md`:
|
||||||
|
|
||||||
|
| Tier | Monthly | Annual | Orders/mo | Products | Users | Key Features |
|
||||||
|
|------|---------|--------|-----------|----------|-------|--------------|
|
||||||
|
| **Essential** | €49 | €490 | 100 | 200 | 1 | LU invoicing, basic inventory |
|
||||||
|
| **Professional** | €99 | €990 | 500 | Unlimited | 3 | EU VAT, warehouse locations |
|
||||||
|
| **Business** | €199 | €1,990 | 2,000 | Unlimited | 10 | Analytics, API, automation |
|
||||||
|
| **Enterprise** | €399+ | Custom | Unlimited | Unlimited | Unlimited | White-label, SLA, dedicated support |
|
||||||
|
|
||||||
|
**Annual Discount:** 2 months free (17% savings)
|
||||||
|
|
||||||
|
**Trial Period:** 30 days with card collection upfront (no charge until trial ends)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add-On Products
|
||||||
|
|
||||||
|
| Add-On | Price | Billing | Description |
|
||||||
|
|--------|-------|---------|-------------|
|
||||||
|
| Custom Domain | €15 | Annual | Use your own domain (mydomain.com) |
|
||||||
|
| Premium SSL | €49 | Annual | EV certificate for trust badges |
|
||||||
|
| Email Package (5) | €5 | Monthly | 5 professional email addresses |
|
||||||
|
| Email Package (10) | €9 | Monthly | 10 professional email addresses |
|
||||||
|
| Email Package (25) | €19 | Monthly | 25 professional email addresses |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Descriptions
|
||||||
|
|
||||||
|
### 1. Marketing Homepage (`/`)
|
||||||
|
|
||||||
|
**Template:** `app/templates/platform/homepage-wizamart.html`
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
|
||||||
|
1. **Hero Section**
|
||||||
|
- Headline: "Lightweight OMS for Letzshop Sellers"
|
||||||
|
- Subheadline: Order management, inventory, and invoicing
|
||||||
|
- CTAs: "Start Free Trial" and "Find Your Letzshop Shop"
|
||||||
|
- Badge: "30-Day Free Trial - No Credit Card Required to Start"
|
||||||
|
|
||||||
|
2. **Pricing Section**
|
||||||
|
- 4 tier cards (Essential, Professional, Business, Enterprise)
|
||||||
|
- Monthly/Annual toggle with savings indicator
|
||||||
|
- Feature lists per tier
|
||||||
|
- "Start Free Trial" buttons linked to signup
|
||||||
|
|
||||||
|
3. **Add-Ons Section**
|
||||||
|
- 3 add-on cards (Domain, SSL, Email)
|
||||||
|
- Icon, description, and pricing for each
|
||||||
|
|
||||||
|
4. **Letzshop Vendor Finder**
|
||||||
|
- Search input for shop URL
|
||||||
|
- Real-time lookup via API
|
||||||
|
- "Claim This Shop" button for unclaimed vendors
|
||||||
|
|
||||||
|
5. **Final CTA Section**
|
||||||
|
- Gradient background
|
||||||
|
- Strong call to action for trial signup
|
||||||
|
|
||||||
|
### 2. Pricing Page (`/pricing`)
|
||||||
|
|
||||||
|
**Template:** `app/templates/platform/pricing.html`
|
||||||
|
|
||||||
|
Standalone page with:
|
||||||
|
- Large tier cards
|
||||||
|
- Monthly/Annual toggle
|
||||||
|
- Detailed feature lists
|
||||||
|
- Back to home link
|
||||||
|
|
||||||
|
### 3. Find Your Shop (`/find-shop`)
|
||||||
|
|
||||||
|
**Template:** `app/templates/platform/find-shop.html`
|
||||||
|
|
||||||
|
- URL input with examples
|
||||||
|
- Real-time Letzshop vendor lookup
|
||||||
|
- Claim button for unclaimed shops
|
||||||
|
- Help section with alternatives
|
||||||
|
|
||||||
|
### 4. Signup Wizard (`/signup`)
|
||||||
|
|
||||||
|
**Template:** `app/templates/platform/signup.html`
|
||||||
|
|
||||||
|
**4-Step Flow:**
|
||||||
|
|
||||||
|
| Step | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 1 | Select Plan | Choose tier + billing period |
|
||||||
|
| 2 | Claim Shop | Optional Letzshop connection |
|
||||||
|
| 3 | Create Account | User details + company info |
|
||||||
|
| 4 | Payment | Stripe card collection |
|
||||||
|
|
||||||
|
**URL Parameters:**
|
||||||
|
- `?tier=professional` - Pre-select tier
|
||||||
|
- `?annual=true` - Pre-select annual billing
|
||||||
|
- `?letzshop=my-shop` - Pre-fill Letzshop slug
|
||||||
|
|
||||||
|
### 5. Signup Success (`/signup/success`)
|
||||||
|
|
||||||
|
**Template:** `app/templates/platform/signup-success.html`
|
||||||
|
|
||||||
|
- Success confirmation
|
||||||
|
- Next steps checklist
|
||||||
|
- Dashboard link
|
||||||
|
- Support contact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints under `/api/v1/platform/`:
|
||||||
|
|
||||||
|
### Pricing Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/platform/tiers
|
||||||
|
Returns all public subscription tiers
|
||||||
|
Response: TierResponse[]
|
||||||
|
|
||||||
|
GET /api/v1/platform/tiers/{tier_code}
|
||||||
|
Returns specific tier by code
|
||||||
|
Response: TierResponse
|
||||||
|
|
||||||
|
GET /api/v1/platform/addons
|
||||||
|
Returns all active add-on products
|
||||||
|
Response: AddOnResponse[]
|
||||||
|
|
||||||
|
GET /api/v1/platform/pricing
|
||||||
|
Returns complete pricing info (tiers + addons + trial_days)
|
||||||
|
Response: PricingResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Letzshop Vendor Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/platform/letzshop-vendors
|
||||||
|
Query params: ?search=&category=&city=&page=1&limit=20
|
||||||
|
Returns paginated vendor list (placeholder for future)
|
||||||
|
Response: LetzshopVendorListResponse
|
||||||
|
|
||||||
|
POST /api/v1/platform/letzshop-vendors/lookup
|
||||||
|
Body: { "url": "letzshop.lu/vendors/my-shop" }
|
||||||
|
Returns vendor info from URL lookup
|
||||||
|
Response: LetzshopLookupResponse
|
||||||
|
|
||||||
|
GET /api/v1/platform/letzshop-vendors/{slug}
|
||||||
|
Returns vendor info by slug
|
||||||
|
Response: LetzshopVendorInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signup Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/platform/signup/start
|
||||||
|
Body: { "tier_code": "professional", "is_annual": false }
|
||||||
|
Creates signup session
|
||||||
|
Response: { "session_id": "...", "tier_code": "...", "is_annual": false }
|
||||||
|
|
||||||
|
POST /api/v1/platform/signup/claim-vendor
|
||||||
|
Body: { "session_id": "...", "letzshop_slug": "my-shop" }
|
||||||
|
Claims Letzshop vendor for session
|
||||||
|
Response: { "session_id": "...", "letzshop_slug": "...", "vendor_name": "..." }
|
||||||
|
|
||||||
|
POST /api/v1/platform/signup/create-account
|
||||||
|
Body: {
|
||||||
|
"session_id": "...",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securepassword",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"company_name": "My Company"
|
||||||
|
}
|
||||||
|
Creates User, Company, Vendor, Stripe Customer
|
||||||
|
Response: { "session_id": "...", "user_id": 1, "vendor_id": 1, "stripe_customer_id": "cus_..." }
|
||||||
|
|
||||||
|
POST /api/v1/platform/signup/setup-payment
|
||||||
|
Body: { "session_id": "..." }
|
||||||
|
Creates Stripe SetupIntent
|
||||||
|
Response: { "session_id": "...", "client_secret": "seti_...", "stripe_customer_id": "cus_..." }
|
||||||
|
|
||||||
|
POST /api/v1/platform/signup/complete
|
||||||
|
Body: { "session_id": "...", "setup_intent_id": "seti_..." }
|
||||||
|
Completes signup, attaches payment method
|
||||||
|
Response: { "success": true, "vendor_code": "...", "vendor_id": 1, "redirect_url": "...", "trial_ends_at": "..." }
|
||||||
|
|
||||||
|
GET /api/v1/platform/signup/session/{session_id}
|
||||||
|
Returns session status for resuming signup
|
||||||
|
Response: { "session_id": "...", "step": "...", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
|
||||||
|
### Migration: `404b3e2d2865_add_letzshop_vendor_fields_and_trial_tracking`
|
||||||
|
|
||||||
|
**Vendor Table:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE vendors ADD COLUMN letzshop_vendor_id VARCHAR(100) UNIQUE;
|
||||||
|
ALTER TABLE vendors ADD COLUMN letzshop_vendor_slug VARCHAR(200);
|
||||||
|
CREATE INDEX ix_vendors_letzshop_vendor_id ON vendors(letzshop_vendor_id);
|
||||||
|
CREATE INDEX ix_vendors_letzshop_vendor_slug ON vendors(letzshop_vendor_slug);
|
||||||
|
```
|
||||||
|
|
||||||
|
**VendorSubscription Table:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE vendor_subscriptions ADD COLUMN card_collected_at DATETIME;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Changes
|
||||||
|
|
||||||
|
**`models/database/vendor.py`:**
|
||||||
|
```python
|
||||||
|
# Letzshop Vendor Identity
|
||||||
|
letzshop_vendor_id = Column(String(100), unique=True, nullable=True, index=True)
|
||||||
|
letzshop_vendor_slug = Column(String(200), nullable=True, index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`models/database/subscription.py`:**
|
||||||
|
```python
|
||||||
|
# Card collection tracking
|
||||||
|
card_collected_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Change
|
||||||
|
|
||||||
|
**`app/core/config.py`:**
|
||||||
|
```python
|
||||||
|
stripe_trial_days: int = 30 # Changed from 14 to 30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stripe Integration
|
||||||
|
|
||||||
|
### Trial Flow with Card Collection
|
||||||
|
|
||||||
|
The signup uses Stripe **SetupIntent** (not PaymentIntent) to collect card details without immediate charge:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User selects tier → POST /signup/start
|
||||||
|
└── Creates signup session
|
||||||
|
|
||||||
|
2. User claims Letzshop shop (optional) → POST /signup/claim-vendor
|
||||||
|
└── Links Letzshop vendor to session
|
||||||
|
|
||||||
|
3. User creates account → POST /signup/create-account
|
||||||
|
├── Creates User in database
|
||||||
|
├── Creates Company in database
|
||||||
|
├── Creates Vendor in database
|
||||||
|
├── Creates Stripe Customer
|
||||||
|
└── Creates VendorSubscription (status: trial)
|
||||||
|
|
||||||
|
4. User enters card → POST /signup/setup-payment
|
||||||
|
└── Creates Stripe SetupIntent
|
||||||
|
└── Returns client_secret for frontend
|
||||||
|
|
||||||
|
5. Frontend confirms card → stripe.confirmCardSetup(client_secret)
|
||||||
|
└── Validates card (no charge)
|
||||||
|
|
||||||
|
6. Signup completes → POST /signup/complete
|
||||||
|
├── Retrieves SetupIntent
|
||||||
|
├── Attaches PaymentMethod to Customer
|
||||||
|
├── Sets as default payment method
|
||||||
|
├── Records card_collected_at
|
||||||
|
└── Subscription starts 30-day trial
|
||||||
|
|
||||||
|
7. After 30 days → Stripe automatically charges card
|
||||||
|
```
|
||||||
|
|
||||||
|
### New StripeService Methods
|
||||||
|
|
||||||
|
**`app/services/stripe_service.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_setup_intent(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> stripe.SetupIntent:
|
||||||
|
"""
|
||||||
|
Create a SetupIntent to collect card without charging.
|
||||||
|
Used for trial signups where we collect card upfront.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def attach_payment_method_to_customer(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
payment_method_id: str,
|
||||||
|
set_as_default: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Attach a payment method to customer and set as default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_subscription_with_trial(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
price_id: str,
|
||||||
|
trial_days: int = 30,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> stripe.Subscription:
|
||||||
|
"""
|
||||||
|
Create subscription with trial period.
|
||||||
|
Card will be charged automatically after trial ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_setup_intent(self, setup_intent_id: str) -> stripe.SetupIntent:
|
||||||
|
"""Get a SetupIntent by ID."""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── api/
|
||||||
|
│ └── v1/
|
||||||
|
│ └── platform/
|
||||||
|
│ ├── __init__.py # Router aggregation
|
||||||
|
│ ├── pricing.py # Tier & addon endpoints
|
||||||
|
│ ├── letzshop_vendors.py # Vendor lookup endpoints
|
||||||
|
│ └── signup.py # Signup flow endpoints
|
||||||
|
├── routes/
|
||||||
|
│ └── platform_pages.py # Page routes (/, /pricing, etc.)
|
||||||
|
├── services/
|
||||||
|
│ └── stripe_service.py # SetupIntent methods (updated)
|
||||||
|
└── templates/
|
||||||
|
└── platform/
|
||||||
|
├── base.html # Base template (Wizamart branding)
|
||||||
|
├── homepage-wizamart.html # Marketing homepage
|
||||||
|
├── pricing.html # Pricing page
|
||||||
|
├── find-shop.html # Letzshop finder
|
||||||
|
├── signup.html # Signup wizard
|
||||||
|
└── signup-success.html # Success page
|
||||||
|
|
||||||
|
models/database/
|
||||||
|
├── vendor.py # letzshop_vendor_id, slug fields
|
||||||
|
└── subscription.py # card_collected_at field
|
||||||
|
|
||||||
|
alembic/versions/
|
||||||
|
└── 404b3e2d2865_add_letzshop_vendor_fields_and_trial_.py
|
||||||
|
|
||||||
|
main.py # Platform routes registered
|
||||||
|
app/api/main.py # Platform API router added
|
||||||
|
app/core/config.py # stripe_trial_days = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Technology
|
||||||
|
|
||||||
|
- **Tailwind CSS** - Utility-first styling
|
||||||
|
- **Alpine.js** - Reactive components
|
||||||
|
- **Stripe.js** - Payment form (Stripe Elements)
|
||||||
|
|
||||||
|
### JavaScript Components (Embedded)
|
||||||
|
|
||||||
|
**Homepage (`homepageData()`):**
|
||||||
|
- `annual` - Billing toggle state
|
||||||
|
- `shopUrl` - Letzshop URL input
|
||||||
|
- `vendorResult` - Lookup result
|
||||||
|
- `lookupVendor()` - API call for lookup
|
||||||
|
|
||||||
|
**Signup Wizard (`signupWizard()`):**
|
||||||
|
- `currentStep` - Wizard step (1-4)
|
||||||
|
- `sessionId` - Backend session ID
|
||||||
|
- `selectedTier` - Selected tier code
|
||||||
|
- `isAnnual` - Annual billing toggle
|
||||||
|
- `letzshopUrl/Vendor` - Letzshop claim
|
||||||
|
- `account` - User form data
|
||||||
|
- `stripe/cardElement` - Stripe integration
|
||||||
|
- `startSignup()` - Step 1 submission
|
||||||
|
- `claimVendor()` - Step 2 submission
|
||||||
|
- `createAccount()` - Step 3 submission
|
||||||
|
- `initStripe()` - Initialize Stripe Elements
|
||||||
|
- `submitPayment()` - Step 4 submission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Requirements
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for payment step
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_... # For webhook handling
|
||||||
|
|
||||||
|
# Trial period (defaults to 30)
|
||||||
|
STRIPE_TRIAL_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stripe Dashboard Setup
|
||||||
|
|
||||||
|
1. Create Products for each tier (Essential, Professional, Business)
|
||||||
|
2. Create Prices for monthly and annual billing
|
||||||
|
3. Configure Customer Portal
|
||||||
|
4. Set up webhook endpoint for subscription events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. **Homepage:** Visit `http://localhost:8000/`
|
||||||
|
2. **Pricing Toggle:** Click Monthly/Annual switch
|
||||||
|
3. **Vendor Lookup:** Enter a Letzshop URL in finder
|
||||||
|
4. **Signup Flow:**
|
||||||
|
- Click "Start Free Trial"
|
||||||
|
- Select tier
|
||||||
|
- Skip or enter Letzshop URL
|
||||||
|
- Fill account form
|
||||||
|
- Enter test card (4242 4242 4242 4242)
|
||||||
|
- Complete signup
|
||||||
|
|
||||||
|
### Test Cards (Stripe)
|
||||||
|
|
||||||
|
| Card | Scenario |
|
||||||
|
|------|----------|
|
||||||
|
| 4242 4242 4242 4242 | Success |
|
||||||
|
| 4000 0000 0000 0002 | Decline |
|
||||||
|
| 4000 0000 0000 3220 | 3D Secure required |
|
||||||
|
|
||||||
|
### API Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get pricing
|
||||||
|
curl http://localhost:8000/api/v1/platform/pricing
|
||||||
|
|
||||||
|
# Lookup vendor
|
||||||
|
curl -X POST http://localhost:8000/api/v1/platform/letzshop-vendors/lookup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "letzshop.lu/vendors/test-shop"}'
|
||||||
|
|
||||||
|
# Start signup
|
||||||
|
curl -X POST http://localhost:8000/api/v1/platform/signup/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tier_code": "professional", "is_annual": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Letzshop Vendor Cache**
|
||||||
|
- Periodic sync of Letzshop vendor directory
|
||||||
|
- Browsable list instead of URL lookup only
|
||||||
|
|
||||||
|
2. **Email Verification**
|
||||||
|
- Verify email before trial starts
|
||||||
|
- Confirmation email with onboarding links
|
||||||
|
|
||||||
|
3. **Referral Program**
|
||||||
|
- Affiliate/referral codes
|
||||||
|
- Partner commission tracking
|
||||||
|
|
||||||
|
4. **A/B Testing**
|
||||||
|
- Test different pricing presentations
|
||||||
|
- Optimize conversion rates
|
||||||
|
|
||||||
|
5. **Analytics**
|
||||||
|
- Track signup funnel drop-off
|
||||||
|
- Monitor tier selection patterns
|
||||||
|
|
||||||
|
6. **Enterprise Contact Form**
|
||||||
|
- Lead capture for enterprise tier
|
||||||
|
- Sales team notification
|
||||||
8
main.py
8
main.py
@@ -37,7 +37,7 @@ from app.exceptions import ServiceUnavailableException
|
|||||||
from app.exceptions.handler import setup_exception_handlers
|
from app.exceptions.handler import setup_exception_handlers
|
||||||
|
|
||||||
# Import page routers
|
# Import page routers
|
||||||
from app.routes import admin_pages, shop_pages, vendor_pages
|
from app.routes import admin_pages, platform_pages, shop_pages, vendor_pages
|
||||||
from middleware.context import ContextMiddleware
|
from middleware.context import ContextMiddleware
|
||||||
from middleware.language import LanguageMiddleware
|
from middleware.language import LanguageMiddleware
|
||||||
from middleware.logging import LoggingMiddleware
|
from middleware.logging import LoggingMiddleware
|
||||||
@@ -244,6 +244,12 @@ logger.info("=" * 80)
|
|||||||
logger.info("ROUTE REGISTRATION")
|
logger.info("ROUTE REGISTRATION")
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
|
|
||||||
|
# Platform marketing pages (homepage, pricing, signup)
|
||||||
|
logger.info("Registering platform page routes: /*, /pricing, /find-shop, /signup")
|
||||||
|
app.include_router(
|
||||||
|
platform_pages.router, prefix="", tags=["platform-pages"], include_in_schema=False
|
||||||
|
)
|
||||||
|
|
||||||
# Admin pages
|
# Admin pages
|
||||||
logger.info("Registering admin page routes: /admin/*")
|
logger.info("Registering admin page routes: /admin/*")
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ nav:
|
|||||||
- Order Item Exceptions: implementation/order-item-exceptions.md
|
- Order Item Exceptions: implementation/order-item-exceptions.md
|
||||||
- Product Suppliers Table: implementation/product-suppliers-table.md
|
- Product Suppliers Table: implementation/product-suppliers-table.md
|
||||||
- Subscription Workflow: implementation/subscription-workflow-plan.md
|
- Subscription Workflow: implementation/subscription-workflow-plan.md
|
||||||
|
- Platform Marketing Homepage: implementation/platform-marketing-homepage.md
|
||||||
- Unified Order View: implementation/unified-order-view.md
|
- Unified Order View: implementation/unified-order-view.md
|
||||||
- VAT Invoice Feature: implementation/vat-invoice-feature.md
|
- VAT Invoice Feature: implementation/vat-invoice-feature.md
|
||||||
- OMS Feature Plan: implementation/oms-feature-plan.md
|
- OMS Feature Plan: implementation/oms-feature-plan.md
|
||||||
|
|||||||
@@ -452,6 +452,9 @@ class VendorSubscription(Base, TimestampMixin):
|
|||||||
# Trial info
|
# Trial info
|
||||||
trial_ends_at = Column(DateTime(timezone=True), nullable=True)
|
trial_ends_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Card collection tracking (for trials that require card upfront)
|
||||||
|
card_collected_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Usage counters (reset each billing period)
|
# Usage counters (reset each billing period)
|
||||||
orders_this_period = Column(Integer, default=0, nullable=False)
|
orders_this_period = Column(Integer, default=0, nullable=False)
|
||||||
orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True)
|
orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ class Vendor(Base, TimestampMixin):
|
|||||||
letzshop_csv_url_en = Column(String) # URL for English CSV in Letzshop
|
letzshop_csv_url_en = Column(String) # URL for English CSV in Letzshop
|
||||||
letzshop_csv_url_de = Column(String) # URL for German CSV in Letzshop
|
letzshop_csv_url_de = Column(String) # URL for German CSV in Letzshop
|
||||||
|
|
||||||
|
# Letzshop Vendor Identity (for linking to Letzshop marketplace profile)
|
||||||
|
letzshop_vendor_id = Column(
|
||||||
|
String(100), unique=True, nullable=True, index=True
|
||||||
|
) # Letzshop's vendor identifier
|
||||||
|
letzshop_vendor_slug = Column(
|
||||||
|
String(200), nullable=True, index=True
|
||||||
|
) # Letzshop shop URL slug (e.g., "my-shop" from letzshop.lu/vendors/my-shop)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Letzshop Feed Settings (atalanda namespace)
|
# Letzshop Feed Settings (atalanda namespace)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user