refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.services.feature_service import feature_service
from app.modules.billing.services.feature_service import feature_service
from models.schema.auth import UserContext
admin_features_router = APIRouter(
@@ -211,7 +211,7 @@ def get_feature(
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
from app.exceptions import FeatureNotFoundError
from app.modules.billing.exceptions import FeatureNotFoundError
raise FeatureNotFoundError(feature_code)

View File

@@ -0,0 +1,247 @@
# app/modules/billing/routes/api/public.py
"""
Public pricing API endpoints.
Provides subscription tier and add-on product information
for the marketing homepage and signup flow.
All endpoints are public (no authentication required).
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.billing.services.platform_pricing_service import platform_pricing_service
from app.modules.billing.models import TierCode
router = APIRouter(prefix="/pricing")
# =============================================================================
# Response Schemas
# =============================================================================
class TierFeature(BaseModel):
"""Feature included in a tier."""
code: str
name: str
description: str | None = None
class TierResponse(BaseModel):
"""Subscription tier details for public display."""
code: str
name: str
description: str | None
price_monthly: float # Price in euros
price_annual: float | None # Price in euros (null for enterprise)
price_monthly_cents: int
price_annual_cents: int | None
orders_per_month: int | None # None = unlimited
products_limit: int | None # None = unlimited
team_members: int | None # None = unlimited
order_history_months: int | None # None = unlimited
features: list[str]
is_popular: bool = False # Highlight as recommended
is_enterprise: bool = False # Contact sales
class Config:
from_attributes = True
class AddOnResponse(BaseModel):
"""Add-on product details for public display."""
code: str
name: str
description: str | None
category: str
price: float # Price in euros
price_cents: int
billing_period: str
quantity_unit: str | None
quantity_value: int | None
class Config:
from_attributes = True
class PricingResponse(BaseModel):
"""Complete pricing information."""
tiers: list[TierResponse]
addons: list[AddOnResponse]
trial_days: int
annual_discount_months: int # e.g., 2 = "2 months free"
# =============================================================================
# Feature Descriptions
# =============================================================================
FEATURE_DESCRIPTIONS = {
"letzshop_sync": "Letzshop Order Sync",
"inventory_basic": "Basic Inventory Management",
"inventory_locations": "Warehouse Locations",
"inventory_purchase_orders": "Purchase Orders",
"invoice_lu": "Luxembourg VAT Invoicing",
"invoice_eu_vat": "EU VAT Invoicing",
"invoice_bulk": "Bulk Invoicing",
"customer_view": "Customer List",
"customer_export": "Customer Export",
"analytics_dashboard": "Analytics Dashboard",
"accounting_export": "Accounting Export",
"api_access": "API Access",
"automation_rules": "Automation Rules",
"team_roles": "Team Roles & Permissions",
"white_label": "White-Label Option",
"multi_vendor": "Multi-Vendor Support",
"custom_integrations": "Custom Integrations",
"sla_guarantee": "SLA Guarantee",
"dedicated_support": "Dedicated Account Manager",
}
# =============================================================================
# Helper Functions
# =============================================================================
def _tier_to_response(tier, is_from_db: bool = True) -> TierResponse:
"""Convert a tier (from DB or hardcoded) to TierResponse."""
if is_from_db:
return TierResponse(
code=tier.code,
name=tier.name,
description=tier.description,
price_monthly=tier.price_monthly_cents / 100,
price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None,
price_monthly_cents=tier.price_monthly_cents,
price_annual_cents=tier.price_annual_cents,
orders_per_month=tier.orders_per_month,
products_limit=tier.products_limit,
team_members=tier.team_members,
order_history_months=tier.order_history_months,
features=tier.features or [],
is_popular=tier.code == TierCode.PROFESSIONAL.value,
is_enterprise=tier.code == TierCode.ENTERPRISE.value,
)
else:
# Hardcoded tier format
tier_enum = tier["tier_enum"]
limits = tier["limits"]
return TierResponse(
code=tier_enum.value,
name=limits["name"],
description=None,
price_monthly=limits["price_monthly_cents"] / 100,
price_annual=(limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
price_monthly_cents=limits["price_monthly_cents"],
price_annual_cents=limits.get("price_annual_cents"),
orders_per_month=limits.get("orders_per_month"),
products_limit=limits.get("products_limit"),
team_members=limits.get("team_members"),
order_history_months=limits.get("order_history_months"),
features=limits.get("features", []),
is_popular=tier_enum == TierCode.PROFESSIONAL,
is_enterprise=tier_enum == TierCode.ENTERPRISE,
)
def _addon_to_response(addon) -> AddOnResponse:
"""Convert an AddOnProduct to AddOnResponse."""
return AddOnResponse(
code=addon.code,
name=addon.name,
description=addon.description,
category=addon.category,
price=addon.price_cents / 100,
price_cents=addon.price_cents,
billing_period=addon.billing_period,
quantity_unit=addon.quantity_unit,
quantity_value=addon.quantity_value,
)
# =============================================================================
# Endpoints
# =============================================================================
@router.get("/tiers", response_model=list[TierResponse]) # public
def get_tiers(db: Session = Depends(get_db)) -> list[TierResponse]:
"""
Get all public subscription tiers.
Returns tiers from database if available, falls back to hardcoded TIER_LIMITS.
"""
# Try to get from database first
db_tiers = platform_pricing_service.get_public_tiers(db)
if db_tiers:
return [_tier_to_response(tier, is_from_db=True) for tier in db_tiers]
# Fallback to hardcoded tiers
from app.modules.billing.models import TIER_LIMITS
tiers = []
for tier_code in TIER_LIMITS:
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code.value)
if tier_data:
tiers.append(_tier_to_response(tier_data, is_from_db=False))
return tiers
@router.get("/tiers/{tier_code}", response_model=TierResponse) # public
def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse:
"""Get a specific tier by code."""
# Try database first
tier = platform_pricing_service.get_tier_by_code(db, tier_code)
if tier:
return _tier_to_response(tier, is_from_db=True)
# Fallback to hardcoded
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code)
if tier_data:
return _tier_to_response(tier_data, is_from_db=False)
raise ResourceNotFoundException(
resource_type="SubscriptionTier",
identifier=tier_code,
)
@router.get("/addons", response_model=list[AddOnResponse]) # public
def get_addons(db: Session = Depends(get_db)) -> list[AddOnResponse]:
"""
Get all available add-on products.
Returns add-ons from database, or empty list if none configured.
"""
addons = platform_pricing_service.get_active_addons(db)
return [_addon_to_response(addon) for addon in addons]
@router.get("", response_model=PricingResponse) # public
def get_pricing(db: Session = Depends(get_db)) -> PricingResponse:
"""
Get complete pricing information (tiers + add-ons).
This is the main endpoint for the pricing page.
"""
from app.core.config import settings
return PricingResponse(
tiers=get_tiers(db),
addons=get_addons(db),
trial_days=settings.stripe_trial_days,
annual_discount_months=2, # "2 months free" with annual billing
)

View File

@@ -24,8 +24,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.exceptions import FeatureNotFoundError
from app.services.feature_service import feature_service
from app.modules.billing.exceptions import FeatureNotFoundError
from app.modules.billing.services.feature_service import feature_service
from models.schema.auth import UserContext
vendor_features_router = APIRouter(
@@ -134,7 +134,7 @@ def get_available_features(
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
@@ -175,7 +175,7 @@ def get_features(
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.usage_service import usage_service
from app.modules.analytics.services.usage_service import usage_service
from models.schema.auth import UserContext
vendor_usage_router = APIRouter(

View File

@@ -1,18 +1,2 @@
# app/modules/billing/routes/pages/__init__.py
"""
Billing module page routes (HTML rendering).
Provides Jinja2 template rendering for billing management:
- Admin pages: Subscription tiers, subscriptions list, billing history
- Vendor pages: Billing dashboard, invoices
Note: These routes are placeholders. The actual page rendering
is currently handled by routes in app/api/v1/ and can be migrated here.
"""
__all__ = []
def __getattr__(name: str):
"""Lazy import routers to avoid circular dependencies."""
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
"""Billing module page routes."""

View File

@@ -0,0 +1,80 @@
# app/modules/billing/routes/pages/admin.py
"""
Billing Admin Page Routes (HTML rendering).
Admin pages for billing and subscription management:
- Subscription tiers
- Subscriptions list
- Billing history
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# BILLING & SUBSCRIPTIONS ROUTES
# ============================================================================
@router.get("/subscription-tiers", response_class=HTMLResponse, include_in_schema=False)
async def admin_subscription_tiers_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscription-tiers", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render subscription tiers management page.
Shows all subscription tiers with their limits and pricing.
"""
return templates.TemplateResponse(
"billing/admin/subscription-tiers.html",
get_admin_context(request, current_user),
)
@router.get("/subscriptions", response_class=HTMLResponse, include_in_schema=False)
async def admin_subscriptions_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscriptions", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor subscriptions management page.
Shows all vendor subscriptions with status and usage.
"""
return templates.TemplateResponse(
"billing/admin/subscriptions.html",
get_admin_context(request, current_user),
)
@router.get("/billing-history", response_class=HTMLResponse, include_in_schema=False)
async def admin_billing_history_page(
request: Request,
current_user: User = Depends(
require_menu_access("billing-history", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render billing history page.
Shows invoices and payments across all vendors.
"""
return templates.TemplateResponse(
"billing/admin/billing-history.html",
get_admin_context(request, current_user),
)

View File

@@ -0,0 +1,121 @@
# app/modules/billing/routes/pages/public.py
"""
Billing Public Page Routes (HTML rendering).
Public (unauthenticated) pages for pricing and signup:
- Pricing page
- Signup wizard
- Signup success
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.core.utils.page_context import get_public_context
from app.templates_config import templates
router = APIRouter()
def _get_tiers_data() -> list[dict]:
"""Build tier data for display in templates."""
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,
}
)
return tiers
# ============================================================================
# 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_public_context(request, db)
context["tiers"] = _get_tiers_data()
context["page_title"] = "Pricing"
return templates.TemplateResponse(
"billing/public/pricing.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_public_context(request, db)
context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier
context["is_annual"] = annual
context["tiers"] = _get_tiers_data()
return templates.TemplateResponse(
"billing/public/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_public_context(request, db)
context["page_title"] = "Welcome to Wizamart!"
context["vendor_code"] = vendor_code
return templates.TemplateResponse(
"billing/public/signup-success.html",
context,
)

View File

@@ -0,0 +1,62 @@
# app/modules/billing/routes/pages/vendor.py
"""
Billing Vendor Page Routes (HTML rendering).
Vendor pages for billing management:
- Billing dashboard
- Invoices
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# BILLING ROUTES
# ============================================================================
@router.get(
"/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_billing_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render billing and subscription management page.
JavaScript loads subscription status, tiers, and invoices via API.
"""
return templates.TemplateResponse(
"billing/vendor/billing.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/invoices", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_invoices_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render invoices management page.
JavaScript loads invoices via API.
"""
return templates.TemplateResponse(
"orders/vendor/invoices.html",
get_vendor_context(request, db, current_user, vendor_code),
)