diff --git a/alembic/versions/z6g7h8i9j0k1_add_main_platform.py b/alembic/versions/z6g7h8i9j0k1_add_main_platform.py
new file mode 100644
index 00000000..d25de645
--- /dev/null
+++ b/alembic/versions/z6g7h8i9j0k1_add_main_platform.py
@@ -0,0 +1,431 @@
+"""add main platform for marketing site
+
+Revision ID: z6g7h8i9j0k1
+Revises: z5f6g7h8i9j0
+Create Date: 2026-01-19 14:00:00.000000
+
+This migration adds the 'main' platform for the main marketing site:
+1. Inserts main platform record (wizamart.lu)
+2. Creates platform marketing pages (home, about, faq, pricing, contact)
+
+The 'main' platform serves as the marketing homepage at:
+- Development: localhost:9999/ (no /platforms/ prefix)
+- Production: wizamart.lu/
+
+All other platforms are accessed via:
+- Development: localhost:9999/platforms/{code}/
+- Production: {code}.lu or custom domain
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "z6g7h8i9j0k1"
+down_revision: Union[str, None] = "z5f6g7h8i9j0"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # =========================================================================
+ # 1. Insert Main Marketing platform
+ # =========================================================================
+ conn.execute(
+ sa.text("""
+ INSERT INTO platforms (code, name, description, domain, path_prefix, default_language,
+ supported_languages, is_active, is_public, theme_config, settings,
+ created_at, updated_at)
+ VALUES ('main', 'Wizamart', 'Main marketing site showcasing all Wizamart platforms',
+ 'wizamart.lu', NULL, 'fr', '["fr", "de", "en"]', true, true,
+ '{"primary_color": "#2563EB", "secondary_color": "#3B82F6"}',
+ '{"is_marketing_site": true}',
+ CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ """)
+ )
+
+ # Get the Main platform ID
+ result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'main'"))
+ main_platform_id = result.fetchone()[0]
+
+ # =========================================================================
+ # 2. Create platform marketing pages (is_platform_page=True)
+ # =========================================================================
+ platform_pages = [
+ {
+ "slug": "home",
+ "title": "Wizamart - E-commerce Solutions for Luxembourg",
+ "content": """
+
Build Your Business with Wizamart
+
All-in-one e-commerce, loyalty, and business solutions for Luxembourg merchants
+
+
+
+
+
+
Why Choose Wizamart?
+
+ - Made for Luxembourg: Built specifically for Luxembourg businesses with local payment methods, languages, and compliance.
+ - All-in-One: Use our platforms together or separately - they integrate seamlessly.
+ - Local Support: Real support from real people in Luxembourg.
+
+
""",
+ "meta_description": "Wizamart offers e-commerce, loyalty, and business solutions for Luxembourg merchants. OMS, Loyalty+, and Site Builder platforms.",
+ "show_in_header": False,
+ "show_in_footer": False,
+ "display_order": 0,
+ },
+ {
+ "slug": "about",
+ "title": "About Wizamart",
+ "content": """
+
About Wizamart
+
+
+
Our Mission
+
We're building the tools Luxembourg businesses need to thrive in the digital economy. From order management to customer loyalty, we provide the infrastructure that powers local commerce.
+
+
+
+
Our Story
+
Wizamart was founded with a simple idea: Luxembourg businesses deserve world-class e-commerce tools that understand their unique needs. Local languages, local payment methods, local compliance - built in from the start.
+
+
+
+
Our Team
+
We're a team of developers, designers, and business experts based in Luxembourg. We understand the local market because we're part of it.
+
+
+
+
Our Values
+
+ - Simplicity: Powerful tools that are easy to use
+ - Reliability: Your business depends on us - we take that seriously
+ - Local First: Built for Luxembourg, by Luxembourg
+ - Innovation: Always improving, always evolving
+
+
+
""",
+ "meta_description": "Learn about Wizamart, the Luxembourg-based company building e-commerce and business solutions for local merchants.",
+ "show_in_header": True,
+ "show_in_footer": True,
+ "display_order": 1,
+ },
+ {
+ "slug": "pricing",
+ "title": "Pricing - Wizamart",
+ "content": """
+
Choose Your Platform
+
Each platform has its own pricing. Choose the tools your business needs.
+
+
+
""",
+ "meta_description": "Wizamart pricing for OMS, Loyalty+, and bundled solutions. Plans starting at €49/month.",
+ "show_in_header": True,
+ "show_in_footer": True,
+ "display_order": 2,
+ },
+ {
+ "slug": "faq",
+ "title": "FAQ - Frequently Asked Questions",
+ "content": """
+
Frequently Asked Questions
+
+
+
General
+
+
+
What is Wizamart?
+
Wizamart is a suite of business tools for Luxembourg merchants, including order management (OMS), customer loyalty (Loyalty+), and website building (Site Builder).
+
+
+
+
Do I need to use all platforms?
+
No! Each platform works independently. Use one, two, or all three - whatever fits your business needs.
+
+
+
+
What languages are supported?
+
All platforms support French, German, and English - the three main languages of Luxembourg.
+
+
+
+
+
Billing & Pricing
+
+
+
Is there a free trial?
+
Yes! All platforms offer a 14-day free trial with no credit card required.
+
+
+
+
What payment methods do you accept?
+
We accept credit cards, SEPA direct debit, and bank transfers.
+
+
+
+
Can I cancel anytime?
+
Yes, you can cancel your subscription at any time. No long-term contracts required.
+
+
+
+
+
Support
+
+
+
How do I get help?
+
All plans include email support. Professional and Business plans include priority support with faster response times.
+
+
+
+
Do you offer onboarding?
+
Yes! We offer guided onboarding for all new customers to help you get started quickly.
+
+
+
""",
+ "meta_description": "Frequently asked questions about Wizamart platforms, pricing, billing, and support.",
+ "show_in_header": True,
+ "show_in_footer": True,
+ "display_order": 3,
+ },
+ {
+ "slug": "contact",
+ "title": "Contact Us - Wizamart",
+ "content": """
+
Contact Us
+
We'd love to hear from you. Get in touch with our team.
+
+
+
+
+
Office
+
Wizamart S.à r.l.
+ Luxembourg City
+ Luxembourg
+
+
""",
+ "meta_description": "Contact Wizamart for sales, support, or general inquiries. We're here to help your Luxembourg business succeed.",
+ "show_in_header": True,
+ "show_in_footer": True,
+ "display_order": 4,
+ },
+ {
+ "slug": "terms",
+ "title": "Terms of Service - Wizamart",
+ "content": """
+
Terms of Service
+
Last updated: January 2026
+
+
These Terms of Service govern your use of Wizamart platforms and services.
+
+
1. Acceptance of Terms
+
By accessing or using our services, you agree to be bound by these Terms.
+
+
2. Services
+
Wizamart provides e-commerce and business management tools including order management, loyalty programs, and website building services.
+
+
3. Account Registration
+
You must provide accurate information when creating an account and keep your login credentials secure.
+
+
4. Fees and Payment
+
Subscription fees are billed in advance on a monthly or annual basis. Prices are listed in EUR and include applicable VAT for Luxembourg customers.
+
+
5. Data Protection
+
We process personal data in accordance with our Privacy Policy and applicable data protection laws including GDPR.
+
+
6. Limitation of Liability
+
Our liability is limited to the amount paid for services in the 12 months preceding any claim.
+
+
7. Governing Law
+
These Terms are governed by Luxembourg law. Disputes shall be resolved in Luxembourg courts.
+
+
8. Contact
+
For questions about these Terms, contact us at legal@wizamart.lu
+
""",
+ "meta_description": "Wizamart Terms of Service. Read the terms and conditions for using our e-commerce and business platforms.",
+ "show_in_header": False,
+ "show_in_footer": True,
+ "show_in_legal": True,
+ "display_order": 10,
+ },
+ {
+ "slug": "privacy",
+ "title": "Privacy Policy - Wizamart",
+ "content": """
+
Privacy Policy
+
Last updated: January 2026
+
+
Introduction
+
Wizamart S.à r.l. ("we", "us") is committed to protecting your privacy. This policy explains how we collect, use, and protect your personal data.
+
+
Data Controller
+
Wizamart S.à r.l.
Luxembourg City, Luxembourg
Email: privacy@wizamart.lu
+
+
Data We Collect
+
+ - Account information (name, email, company details)
+ - Usage data (how you use our platforms)
+ - Payment information (processed by our payment providers)
+ - Support communications
+
+
+
How We Use Your Data
+
+ - To provide and improve our services
+ - To process payments and billing
+ - To communicate with you about your account
+ - To send marketing communications (with your consent)
+
+
+
Your Rights
+
Under GDPR, you have the right to:
+
+ - Access your personal data
+ - Rectify inaccurate data
+ - Request deletion of your data
+ - Data portability
+ - Object to processing
+
+
+
Contact
+
To exercise your rights or ask questions, contact our Data Protection Officer at privacy@wizamart.lu
+
""",
+ "meta_description": "Wizamart Privacy Policy. Learn how we collect, use, and protect your personal data in compliance with GDPR.",
+ "show_in_header": False,
+ "show_in_footer": True,
+ "show_in_legal": True,
+ "display_order": 11,
+ },
+ ]
+
+ for page in platform_pages:
+ show_in_legal = page.get("show_in_legal", False)
+ conn.execute(
+ sa.text("""
+ INSERT INTO content_pages (platform_id, vendor_id, slug, title, content, content_format,
+ meta_description, is_published, is_platform_page,
+ show_in_header, show_in_footer, show_in_legal, display_order,
+ created_at, updated_at)
+ VALUES (:platform_id, NULL, :slug, :title, :content, 'html',
+ :meta_description, true, true,
+ :show_in_header, :show_in_footer, :show_in_legal, :display_order,
+ CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ """),
+ {
+ "platform_id": main_platform_id,
+ "slug": page["slug"],
+ "title": page["title"],
+ "content": page["content"],
+ "meta_description": page["meta_description"],
+ "show_in_header": page["show_in_header"],
+ "show_in_footer": page["show_in_footer"],
+ "show_in_legal": show_in_legal,
+ "display_order": page["display_order"],
+ }
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+
+ # Get the Main platform ID
+ result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'main'"))
+ row = result.fetchone()
+ if row:
+ main_platform_id = row[0]
+
+ # Delete all content pages for main platform
+ conn.execute(
+ sa.text("DELETE FROM content_pages WHERE platform_id = :platform_id"),
+ {"platform_id": main_platform_id}
+ )
+
+ # Delete vendor_platforms entries for main (if any)
+ conn.execute(
+ sa.text("DELETE FROM vendor_platforms WHERE platform_id = :platform_id"),
+ {"platform_id": main_platform_id}
+ )
+
+ # Delete main platform
+ conn.execute(sa.text("DELETE FROM platforms WHERE code = 'main'"))
diff --git a/app/routes/platform_pages.py b/app/routes/platform_pages.py
index bc1ccff5..1b7fea18 100644
--- a/app/routes/platform_pages.py
+++ b/app/routes/platform_pages.py
@@ -90,16 +90,102 @@ async def homepage(
db: Session = Depends(get_db),
):
"""
- Platform marketing homepage.
+ Homepage handler.
- Displays:
- - Hero section with value proposition
- - Pricing tier cards
- - Add-ons section
- - Letzshop vendor finder
- - Call to action for signup
+ Handles two scenarios:
+ 1. Vendor on custom domain (vendor.com) → Show vendor landing page or redirect to shop
+ 2. Platform marketing site → Show platform homepage from CMS or default template
+
+ URL routing:
+ - localhost:9999/ → Main marketing site ('main' platform)
+ - localhost:9999/platforms/oms/ → OMS platform (middleware rewrites to /)
+ - oms.lu/ → OMS platform (domain-based)
+ - shop.mycompany.com/ → Vendor landing page (custom domain)
"""
+ from fastapi.responses import RedirectResponse
+
+ # Get platform and vendor from middleware
+ platform = getattr(request.state, "platform", None)
+ vendor = getattr(request.state, "vendor", None)
+
+ # Scenario 1: Vendor detected (custom domain like vendor.com)
+ if vendor:
+ logger.debug(f"[HOMEPAGE] Vendor detected: {vendor.subdomain}")
+
+ # Get platform_id (use platform from context or default to 1 for OMS)
+ platform_id = platform.id if platform else 1
+
+ # Try to find vendor landing page (slug='landing' or 'home')
+ landing_page = content_page_service.get_page_for_vendor(
+ db, platform_id=platform_id, slug="landing", vendor_id=vendor.id, include_unpublished=False
+ )
+
+ if not landing_page:
+ landing_page = content_page_service.get_page_for_vendor(
+ db, platform_id=platform_id, slug="home", vendor_id=vendor.id, include_unpublished=False
+ )
+
+ if landing_page:
+ # Render landing page with selected template
+ from app.routes.shop_pages import get_shop_context
+
+ template_name = landing_page.template or "default"
+ template_path = f"vendor/landing-{template_name}.html"
+
+ logger.info(f"[HOMEPAGE] Rendering vendor landing page: {template_path}")
+ return templates.TemplateResponse(
+ template_path, get_shop_context(request, db=db, page=landing_page)
+ )
+
+ # No landing page - redirect to shop
+ vendor_context = getattr(request.state, "vendor_context", None)
+ access_method = (
+ vendor_context.get("detection_method", "unknown")
+ if vendor_context
+ else "unknown"
+ )
+
+ if access_method == "path":
+ full_prefix = (
+ vendor_context.get("full_prefix", "/vendor/")
+ if vendor_context
+ else "/vendor/"
+ )
+ return RedirectResponse(
+ url=f"{full_prefix}{vendor.subdomain}/shop/", status_code=302
+ )
+ # Domain/subdomain - redirect to /shop/
+ return RedirectResponse(url="/shop/", status_code=302)
+
+ # Scenario 2: Platform marketing site (no vendor)
+ # Try to load platform homepage from CMS
+ platform_id = platform.id if platform else 1
+
+ cms_homepage = content_page_service.get_platform_page(
+ db, platform_id=platform_id, slug="home", include_unpublished=False
+ )
+
+ if not cms_homepage:
+ cms_homepage = content_page_service.get_platform_page(
+ db, platform_id=platform_id, slug="platform_homepage", include_unpublished=False
+ )
+
+ if cms_homepage:
+ # Use CMS-based homepage with template selection
+ context = get_platform_context(request, db)
+ context["page"] = cms_homepage
+ context["platform"] = platform
+
+ template_name = cms_homepage.template or "default"
+ template_path = f"platform/homepage-{template_name}.html"
+
+ logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
+ return templates.TemplateResponse(template_path, context)
+
+ # Fallback: Default wizamart homepage (no CMS content)
+ logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template")
context = get_platform_context(request, db)
+ context["platform"] = platform
# Fetch tiers for display (use API service internally)
from models.database.subscription import TIER_LIMITS, TierCode
diff --git a/main.py b/main.py
index e3f23b01..259ecbc8 100644
--- a/main.py
+++ b/main.py
@@ -373,320 +373,29 @@ async def vendor_root_path(
# ============================================================================
-# PLATFORM PUBLIC PAGES (Platform Homepage, About, FAQ, etc.)
+# PLATFORM ROUTING (via PlatformContextMiddleware)
+#
+# The PlatformContextMiddleware handles all platform routing by rewriting paths:
+# - /platforms/oms/ → rewrites to / (served by platform_pages router)
+# - /platforms/oms/pricing → rewrites to /pricing (served by platform_pages router)
+# - /platforms/loyalty/ → rewrites to / (served by platform_pages router)
+#
+# The middleware also sets request.state.platform so handlers know which
+# platform's content to serve. All platform page routes are defined in
+# app/routes/platform_pages.py which is included above.
+#
+# URL Structure (Development - localhost:9999):
+# - / → Main marketing site ('main' platform)
+# - /about → Main marketing site about page
+# - /platforms/oms/ → OMS platform homepage
+# - /platforms/oms/pricing → OMS platform pricing page
+# - /platforms/loyalty/ → Loyalty platform homepage
+#
+# URL Structure (Production - domain-based):
+# - wizamart.lu/ → Main marketing site
+# - oms.lu/ → OMS platform homepage
+# - loyalty.lu/ → Loyalty platform homepage
# ============================================================================
-logger.info("Registering platform public page routes:")
-logger.info(" - / (platform homepage)")
-logger.info(" - /{platform_code}/ (platform-prefixed homepage for dev mode)")
-logger.info(" - /{platform_code}/{slug} (platform-prefixed content pages)")
-logger.info(" - /{slug} (platform content pages: /about, /faq, /terms, /contact)")
-
-
-@app.get("/", response_class=HTMLResponse, include_in_schema=False)
-async def platform_homepage(request: Request, db: Session = Depends(get_db)):
- """
- Platform homepage at localhost:8000 or platform.com
-
- Uses multi-platform CMS with three-tier resolution:
- 1. Platform marketing pages (is_platform_page=True)
- 2. Vendor default pages (fallback)
- 3. Vendor override pages
-
- Falls back to default static template if not found.
- """
- from app.services.content_page_service import content_page_service
-
- logger.debug("[PLATFORM] Homepage requested")
-
- # Get platform from middleware (multi-platform support)
- platform = getattr(request.state, "platform", None)
-
- if platform:
- # Try to load platform homepage from CMS (platform marketing page)
- homepage = content_page_service.get_platform_page(
- db,
- platform_id=platform.id,
- slug="home",
- include_unpublished=False,
- )
-
- # Also try platform_homepage slug for backwards compatibility
- if not homepage:
- homepage = content_page_service.get_platform_page(
- db,
- platform_id=platform.id,
- slug="platform_homepage",
- include_unpublished=False,
- )
-
- # Load header and footer navigation (platform marketing pages)
- header_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, header_only=True, include_unpublished=False
- )
-
- footer_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, footer_only=True, include_unpublished=False
- )
- else:
- # Fallback for when no platform context (shouldn't happen normally)
- homepage = None
- header_pages = []
- footer_pages = []
-
- # Get language from request state and build i18n context
- language = getattr(request.state, "language", "fr")
- i18n_globals = get_jinja2_globals(language)
-
- if homepage:
- # Use template selection from CMS
- template_name = homepage.template or "default"
- template_path = f"platform/homepage-{template_name}.html"
-
- logger.info(f"[PLATFORM] Rendering CMS homepage with template: {template_path}")
-
- context = {
- "request": request,
- "page": homepage,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
-
- return templates.TemplateResponse(template_path, context)
-
- # Fallback to default static template
- logger.info("[PLATFORM] No CMS homepage found, using default template")
-
- context = {
- "request": request,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
-
- return templates.TemplateResponse("platform/homepage-default.html", context)
-
-
-# ============================================================================
-# PLATFORM-PREFIXED ROUTES (Development mode: /oms/, /loyalty/, etc.)
-# These routes handle path-based platform routing in development.
-# In production, domain-based routing means these won't be needed.
-# ============================================================================
-
-
-async def _serve_platform_homepage(request: Request, platform_code: str, db: Session):
- """Helper function to serve platform homepage."""
- from app.services.content_page_service import content_page_service
- from models.database.platform import Platform
-
- # Get platform from middleware or query directly
- platform = getattr(request.state, "platform", None)
- if not platform:
- platform = db.query(Platform).filter(
- Platform.code == platform_code.lower(),
- Platform.is_active == True
- ).first()
-
- if not platform:
- raise HTTPException(status_code=404, detail=f"Platform not found: {platform_code}")
-
- # Load platform homepage from CMS
- homepage = content_page_service.get_platform_page(
- db, platform_id=platform.id, slug="home", include_unpublished=False
- )
- if not homepage:
- homepage = content_page_service.get_platform_page(
- db, platform_id=platform.id, slug="platform_homepage", include_unpublished=False
- )
-
- # Load navigation
- header_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, header_only=True, include_unpublished=False
- )
- footer_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, footer_only=True, include_unpublished=False
- )
-
- # Build context
- language = getattr(request.state, "language", "fr")
- i18n_globals = get_jinja2_globals(language)
-
- if homepage:
- template_name = homepage.template or "default"
- template_path = f"platform/homepage-{template_name}.html"
- logger.info(f"[PLATFORM] Rendering {platform.code} homepage: {template_path}")
-
- context = {
- "request": request,
- "page": homepage,
- "platform": platform,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
- return templates.TemplateResponse(template_path, context)
-
- # Fallback
- logger.info(f"[PLATFORM] No CMS homepage for {platform.code}, using default")
- context = {
- "request": request,
- "platform": platform,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
- return templates.TemplateResponse("platform/homepage-default.html", context)
-
-
-async def _serve_platform_content_page(request: Request, platform_code: str, slug: str, db: Session):
- """Helper function to serve platform content page."""
- from app.services.content_page_service import content_page_service
- from models.database.platform import Platform
-
- # Get platform
- platform = getattr(request.state, "platform", None)
- if not platform:
- platform = db.query(Platform).filter(
- Platform.code == platform_code.lower(),
- Platform.is_active == True
- ).first()
-
- if not platform:
- raise HTTPException(status_code=404, detail=f"Platform not found: {platform_code}")
-
- # Load content page
- page = content_page_service.get_platform_page(
- db, platform_id=platform.id, slug=slug, include_unpublished=False
- )
- if not page:
- logger.warning(f"[PLATFORM] Page not found: /{platform_code}/{slug}")
- raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
-
- # Load navigation
- header_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, header_only=True, include_unpublished=False
- )
- footer_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, footer_only=True, include_unpublished=False
- )
-
- logger.info(f"[PLATFORM] Rendering {platform.code} page: {page.title} (/{slug})")
-
- language = getattr(request.state, "language", "fr")
- i18n_globals = get_jinja2_globals(language)
-
- context = {
- "request": request,
- "page": page,
- "platform": platform,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
- return templates.TemplateResponse("platform/content-page.html", context)
-
-
-# Explicit routes for OMS platform
-@app.get("/oms", response_class=RedirectResponse, include_in_schema=False)
-async def oms_redirect():
- """Redirect /oms to /oms/"""
- return RedirectResponse(url="/oms/", status_code=307)
-
-
-@app.get("/oms/", response_class=HTMLResponse, include_in_schema=False)
-async def oms_homepage(request: Request, db: Session = Depends(get_db)):
- """OMS platform homepage"""
- return await _serve_platform_homepage(request, "oms", db)
-
-
-@app.get("/oms/{slug}", response_class=HTMLResponse, include_in_schema=False)
-async def oms_content_page(request: Request, slug: str, db: Session = Depends(get_db)):
- """OMS platform content pages"""
- return await _serve_platform_content_page(request, "oms", slug, db)
-
-
-# Explicit routes for Loyalty platform
-@app.get("/loyalty", response_class=RedirectResponse, include_in_schema=False)
-async def loyalty_redirect():
- """Redirect /loyalty to /loyalty/"""
- return RedirectResponse(url="/loyalty/", status_code=307)
-
-
-@app.get("/loyalty/", response_class=HTMLResponse, include_in_schema=False)
-async def loyalty_homepage(request: Request, db: Session = Depends(get_db)):
- """Loyalty platform homepage"""
- return await _serve_platform_homepage(request, "loyalty", db)
-
-
-@app.get("/loyalty/{slug}", response_class=HTMLResponse, include_in_schema=False)
-async def loyalty_content_page(request: Request, slug: str, db: Session = Depends(get_db)):
- """Loyalty platform content pages"""
- return await _serve_platform_content_page(request, "loyalty", slug, db)
-
-
-# ============================================================================
-# GENERIC PLATFORM CONTENT PAGE (must be LAST - catches all /{slug})
-# ============================================================================
-
-@app.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
-async def platform_content_page(
- request: Request, slug: str, db: Session = Depends(get_db)
-):
- """
- Platform content pages: /about, /faq, /terms, /contact, etc.
-
- Uses multi-platform CMS with three-tier resolution.
- Returns 404 if page not found.
-
- This route MUST be defined LAST to avoid conflicts with other routes.
- """
- from app.services.content_page_service import content_page_service
-
- logger.debug(f"[PLATFORM] Content page requested: /{slug}")
-
- # Get platform from middleware (multi-platform support)
- platform = getattr(request.state, "platform", None)
-
- if not platform:
- logger.warning(f"[PLATFORM] No platform context for content page: {slug}")
- raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
-
- # Load platform marketing page from CMS
- page = content_page_service.get_platform_page(
- db,
- platform_id=platform.id,
- slug=slug,
- include_unpublished=False,
- )
-
- if not page:
- logger.warning(f"[PLATFORM] Content page not found: {slug}")
- raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
-
- # Load header and footer navigation (platform marketing pages)
- header_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, header_only=True, include_unpublished=False
- )
-
- footer_pages = content_page_service.list_platform_pages(
- db, platform_id=platform.id, footer_only=True, include_unpublished=False
- )
-
- logger.info(f"[PLATFORM] Rendering content page: {page.title} (/{slug})")
-
- # Get language from request state and build i18n context
- language = getattr(request.state, "language", "fr")
- i18n_globals = get_jinja2_globals(language)
-
- context = {
- "request": request,
- "page": page,
- "header_pages": header_pages,
- "footer_pages": footer_pages,
- }
- context.update(i18n_globals)
-
- return templates.TemplateResponse("platform/content-page.html", context)
logger.info("=" * 80)
@@ -701,73 +410,6 @@ for route in app.routes:
logger.info(f" {methods:<10} {route.path:<60}")
logger.info("=" * 80)
-# ============================================================================
-# API ROUTES (JSON Responses)
-# ============================================================================
-
-
-# Public Routes (no authentication required)
-@app.get("/", response_class=HTMLResponse, include_in_schema=False)
-async def root(request: Request, db: Session = Depends(get_db)):
- """
- Smart root handler:
- - If vendor detected (domain/subdomain): Show vendor landing page or redirect to shop
- - If no vendor (platform root): Redirect to documentation
- """
- vendor = getattr(request.state, "vendor", None)
- platform = getattr(request.state, "platform", None)
-
- if vendor:
- # Vendor context detected - serve landing page
- from app.services.content_page_service import content_page_service
-
- # Get platform_id (use platform from context or default to 1 for OMS)
- platform_id = platform.id if platform else 1
-
- # Try to find landing page (slug='landing' or 'home') with three-tier resolution
- landing_page = content_page_service.get_page_for_vendor(
- db, platform_id=platform_id, slug="landing", vendor_id=vendor.id, include_unpublished=False
- )
-
- if not landing_page:
- # Try 'home' slug as fallback
- landing_page = content_page_service.get_page_for_vendor(
- db, platform_id=platform_id, slug="home", vendor_id=vendor.id, include_unpublished=False
- )
-
- if landing_page:
- # Render landing page with selected template
- from app.routes.shop_pages import get_shop_context
-
- template_name = landing_page.template or "default"
- template_path = f"vendor/landing-{template_name}.html"
-
- return templates.TemplateResponse(
- template_path, get_shop_context(request, db=db, page=landing_page)
- )
- # No landing page - redirect to shop
- vendor_context = getattr(request.state, "vendor_context", None)
- access_method = (
- vendor_context.get("detection_method", "unknown")
- if vendor_context
- else "unknown"
- )
-
- if access_method == "path":
- full_prefix = (
- vendor_context.get("full_prefix", "/vendor/")
- if vendor_context
- else "/vendor/"
- )
- return RedirectResponse(
- url=f"{full_prefix}{vendor.subdomain}/shop/", status_code=302
- )
- # Domain/subdomain
- return RedirectResponse(url="/shop/", status_code=302)
- # No vendor - platform root
- return RedirectResponse(url="/documentation")
-
-
@app.get("/documentation", response_class=HTMLResponse, include_in_schema=False)
async def documentation():
"""Redirect to documentation"""
diff --git a/middleware/platform_context.py b/middleware/platform_context.py
index 972a632d..742eaf32 100644
--- a/middleware/platform_context.py
+++ b/middleware/platform_context.py
@@ -7,7 +7,11 @@ This middleware runs BEFORE VendorContextMiddleware to establish platform contex
Handles two routing modes:
1. Production: Domain-based (oms.lu, loyalty.lu → Platform detection)
-2. Development: Path-based (localhost:9999/oms/*, localhost:9999/loyalty/* → Platform detection)
+2. Development: Path-based (localhost:9999/platforms/oms/*, localhost:9999/platforms/loyalty/*)
+
+URL Structure:
+- Main marketing site: localhost:9999/ (no platform prefix) → uses 'main' platform
+- Platform sites: localhost:9999/platforms/{code}/ → uses specific platform
Also provides platform_clean_path for downstream middleware to use.
"""
@@ -16,7 +20,7 @@ import logging
from fastapi import Request
from sqlalchemy.orm import Session
-from starlette.middleware.base import BaseHTTPMiddleware
+# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
from app.core.config import settings
from app.core.database import get_db
@@ -24,8 +28,8 @@ from models.database.platform import Platform
logger = logging.getLogger(__name__)
-# Default platform code for backward compatibility
-DEFAULT_PLATFORM_CODE = "oms"
+# Default platform code for main marketing site
+DEFAULT_PLATFORM_CODE = "main"
class PlatformContextManager:
@@ -38,8 +42,15 @@ class PlatformContextManager:
Priority order:
1. Domain-based (production): oms.lu → platform code "oms"
- 2. Path-based (development): localhost:9999/oms/* → platform code "oms"
- 3. Default: localhost without path prefix → default platform
+ 2. Path-based (development): localhost:9999/platforms/oms/* → platform code "oms"
+ 3. Default: localhost without /platforms/ prefix → 'main' platform (marketing site)
+
+ URL Structure:
+ - / → Main marketing site ('main' platform)
+ - /about → Main marketing site about page
+ - /platforms/oms/ → OMS platform homepage
+ - /platforms/oms/pricing → OMS platform pricing
+ - /platforms/loyalty/ → Loyalty platform homepage
Returns dict with platform info or None if not detected.
"""
@@ -75,42 +86,33 @@ class PlatformContextManager:
"original_path": path,
}
- # Method 2: Path-based detection (development)
- # Check for path prefix like /oms/, /loyalty/
- if path.startswith("/"):
- path_parts = path[1:].split("/") # Remove leading / and split
- if path_parts and path_parts[0]:
- potential_platform_code = path_parts[0].lower()
- # Check if this could be a platform code (not vendor paths)
- if potential_platform_code not in [
- "vendor",
- "vendors",
- "admin",
- "api",
- "static",
- "media",
- "assets",
- "health",
- "docs",
- "redoc",
- "openapi.json",
- ]:
- return {
- "path_prefix": potential_platform_code,
- "detection_method": "path",
- "host": host,
- "original_path": path,
- "clean_path": "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/",
- }
+ # Method 2: Path-based detection (development) - ONLY for /platforms/ prefix
+ # Check for path prefix like /platforms/oms/, /platforms/loyalty/
+ if path.startswith("/platforms/"):
+ # Extract: /platforms/oms/pricing → code="oms", clean_path="/pricing"
+ path_after_platforms = path[11:] # Remove "/platforms/"
+ parts = path_after_platforms.split("/", 1)
+ platform_code = parts[0].lower()
- # Method 3: Default platform for localhost without prefix
+ if platform_code:
+ clean_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
+ return {
+ "path_prefix": platform_code,
+ "detection_method": "path",
+ "host": host,
+ "original_path": path,
+ "clean_path": clean_path,
+ }
+
+ # Method 3: Default platform for localhost without /platforms/ prefix
+ # This serves the main marketing site
if host_without_port in ["localhost", "127.0.0.1"]:
return {
"path_prefix": DEFAULT_PLATFORM_CODE,
"detection_method": "default",
"host": host,
"original_path": path,
- "clean_path": path,
+ "clean_path": path, # No path rewrite for main site
}
return None
@@ -248,54 +250,73 @@ class PlatformContextManager:
return False
-class PlatformContextMiddleware(BaseHTTPMiddleware):
+class PlatformContextMiddleware:
"""
- Middleware to inject platform context into request state.
+ ASGI Middleware to inject platform context and rewrite URL paths.
+
+ This middleware:
+ 1. Detects platform from domain (production) or path prefix (development)
+ 2. Rewrites the URL path to remove platform prefix for routing
+ 3. Stores platform info in request state for handlers
Runs BEFORE VendorContextMiddleware to establish platform context.
- Sets:
- request.state.platform: Platform object
- request.state.platform_context: Detection metadata
- request.state.platform_clean_path: Path without platform prefix
+ Sets in scope['state']:
+ platform: Platform object
+ platform_context: Detection metadata
+ platform_clean_path: Path without platform prefix
+ platform_original_path: Original path before rewrite
"""
- async def dispatch(self, request: Request, call_next):
- """
- Detect and inject platform context.
- """
- # Skip platform detection for static files
- if PlatformContextManager.is_static_file_request(request):
- logger.debug(
- f"[PLATFORM] Skipping platform detection for static file: {request.url.path}"
- )
- request.state.platform = None
- request.state.platform_context = None
- request.state.platform_clean_path = request.url.path
- return await call_next(request)
+ def __init__(self, app):
+ self.app = app
- # Skip platform detection for system endpoints
- if request.url.path in ["/health", "/docs", "/redoc", "/openapi.json"]:
- logger.debug(
- f"[PLATFORM] Skipping platform detection for system path: {request.url.path}"
- )
- request.state.platform = None
- request.state.platform_context = None
- request.state.platform_clean_path = request.url.path
- return await call_next(request)
+ async def __call__(self, scope, receive, send):
+ """ASGI interface - allows path rewriting before routing."""
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
- # Admin requests are global (no platform context)
- if PlatformContextManager.is_admin_request(request):
- logger.debug(
- f"[PLATFORM] Admin request - no platform context: {request.url.path}"
- )
- request.state.platform = None
- request.state.platform_context = None
- request.state.platform_clean_path = request.url.path
- return await call_next(request)
+ # Initialize state dict if not present
+ if "state" not in scope:
+ scope["state"] = {}
+
+ path = scope["path"]
+ host = ""
+ for header_name, header_value in scope.get("headers", []):
+ if header_name == b"host":
+ host = header_value.decode("utf-8")
+ break
+
+ # Skip for static files
+ if self._is_static_file(path):
+ scope["state"]["platform"] = None
+ scope["state"]["platform_context"] = None
+ scope["state"]["platform_clean_path"] = path
+ scope["state"]["platform_original_path"] = path
+ await self.app(scope, receive, send)
+ return
+
+ # Skip for system endpoints
+ if path in ["/health", "/docs", "/redoc", "/openapi.json"]:
+ scope["state"]["platform"] = None
+ scope["state"]["platform_context"] = None
+ scope["state"]["platform_clean_path"] = path
+ scope["state"]["platform_original_path"] = path
+ await self.app(scope, receive, send)
+ return
+
+ # Skip for admin requests
+ if self._is_admin_request(path, host):
+ scope["state"]["platform"] = None
+ scope["state"]["platform_context"] = None
+ scope["state"]["platform_clean_path"] = path
+ scope["state"]["platform_original_path"] = path
+ await self.app(scope, receive, send)
+ return
# Detect platform context
- platform_context = PlatformContextManager.detect_platform_context(request)
+ platform_context = self._detect_platform_context(path, host)
if platform_context:
db_gen = get_db()
@@ -306,52 +327,123 @@ class PlatformContextMiddleware(BaseHTTPMiddleware):
)
if platform:
- request.state.platform = platform
- request.state.platform_context = platform_context
- request.state.platform_clean_path = PlatformContextManager.extract_clean_path(
- request, platform_context
- )
+ clean_path = platform_context.get("clean_path", path)
+
+ # Store in scope state
+ scope["state"]["platform"] = platform
+ scope["state"]["platform_context"] = platform_context
+ scope["state"]["platform_clean_path"] = clean_path
+ scope["state"]["platform_original_path"] = path
+
+ # REWRITE THE PATH for routing
+ # This is the key: FastAPI will route based on this rewritten path
+ if platform_context.get("detection_method") == "path":
+ scope["path"] = clean_path
+ # Also update raw_path if present
+ if "raw_path" in scope:
+ scope["raw_path"] = clean_path.encode("utf-8")
logger.debug(
- "[PLATFORM_CONTEXT] Platform detected",
- extra={
- "platform_id": platform.id,
- "platform_code": platform.code,
- "platform_name": platform.name,
- "detection_method": platform_context.get("detection_method"),
- "original_path": request.url.path,
- "clean_path": request.state.platform_clean_path,
- },
+ f"[PLATFORM] Detected: {platform.code}, "
+ f"original={path}, routed={scope['path']}"
)
else:
- # Platform code detected but not found in database
- # This could be a vendor path like /vendors/...
- logger.debug(
- "[PLATFORM] Platform code not found, may be vendor path",
- extra={
- "context": platform_context,
- "detection_method": platform_context.get("detection_method"),
- },
- )
- request.state.platform = None
- request.state.platform_context = None
- request.state.platform_clean_path = request.url.path
+ # Platform code not found in database
+ scope["state"]["platform"] = None
+ scope["state"]["platform_context"] = None
+ scope["state"]["platform_clean_path"] = path
+ scope["state"]["platform_original_path"] = path
finally:
db.close()
else:
- logger.debug(
- "[PLATFORM] No platform context detected",
- extra={
- "path": request.url.path,
- "host": request.headers.get("host", ""),
- },
- )
- request.state.platform = None
- request.state.platform_context = None
- request.state.platform_clean_path = request.url.path
+ scope["state"]["platform"] = None
+ scope["state"]["platform_context"] = None
+ scope["state"]["platform_clean_path"] = path
+ scope["state"]["platform_original_path"] = path
- # Continue to next middleware
- return await call_next(request)
+ await self.app(scope, receive, send)
+
+ def _detect_platform_context(self, path: str, host: str) -> dict | None:
+ """
+ Detect platform from path or host.
+
+ URL Structure:
+ - / → Main marketing site ('main' platform)
+ - /about → Main marketing site about page
+ - /platforms/oms/ → OMS platform homepage
+ - /platforms/oms/pricing → OMS platform pricing
+ - /platforms/loyalty/ → Loyalty platform homepage
+ """
+ host_without_port = host.split(":")[0] if ":" in host else host
+
+ # Method 1: Domain-based (production)
+ if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
+ if "." in host_without_port:
+ parts = host_without_port.split(".")
+ if len(parts) == 2: # Root domain like oms.lu
+ return {
+ "domain": host_without_port,
+ "detection_method": "domain",
+ "host": host,
+ "original_path": path,
+ "clean_path": path, # No path rewrite for domain-based
+ }
+
+ # Method 2: Path-based (development) - ONLY for /platforms/ prefix
+ if path.startswith("/platforms/"):
+ # Extract: /platforms/oms/pricing → code="oms", clean_path="/pricing"
+ path_after_platforms = path[11:] # Remove "/platforms/"
+ parts = path_after_platforms.split("/", 1)
+ platform_code = parts[0].lower()
+
+ if platform_code:
+ clean_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
+ return {
+ "path_prefix": platform_code,
+ "detection_method": "path",
+ "host": host,
+ "original_path": path,
+ "clean_path": clean_path,
+ }
+
+ # Method 3: Default for localhost - serves main marketing site
+ if host_without_port in ["localhost", "127.0.0.1"]:
+ return {
+ "path_prefix": DEFAULT_PLATFORM_CODE,
+ "detection_method": "default",
+ "host": host,
+ "original_path": path,
+ "clean_path": path, # No path rewrite for main site
+ }
+
+ return None
+
+ def _is_static_file(self, path: str) -> bool:
+ """Check if path is for static files."""
+ path_lower = path.lower()
+ static_extensions = (
+ ".ico", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg",
+ ".woff", ".woff2", ".ttf", ".eot", ".webp", ".map", ".json",
+ ".xml", ".txt", ".pdf", ".webmanifest",
+ )
+ static_paths = ("/static/", "/media/", "/assets/", "/.well-known/")
+
+ if path_lower.endswith(static_extensions):
+ return True
+ if any(path_lower.startswith(p) for p in static_paths):
+ return True
+ if "favicon.ico" in path_lower:
+ return True
+ return False
+
+ def _is_admin_request(self, path: str, host: str) -> bool:
+ """Check if request is for admin interface."""
+ host_without_port = host.split(":")[0] if ":" in host else host
+ if host_without_port.startswith("admin."):
+ return True
+ if path.startswith("/admin"):
+ return True
+ return False
def get_current_platform(request: Request) -> Platform | None: