feat: implement /platforms/ URL prefix routing strategy

- Update middleware to use /platforms/{code}/ prefix for dev routing
- Change DEFAULT_PLATFORM_CODE from 'oms' to 'main'
- Add 'main' platform for main marketing site (wizamart.lu)
- Remove hardcoded /oms and /loyalty routes from main.py
- Update platform_pages.py homepage to handle vendor landing pages

URL structure:
- localhost:9999/ → Main marketing site ('main' platform)
- localhost:9999/platforms/oms/ → OMS platform
- localhost:9999/platforms/loyalty/ → Loyalty platform
- oms.lu/ → OMS platform (production)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 18:25:56 +01:00
parent 3875ad91df
commit a2407ae418
4 changed files with 749 additions and 498 deletions

View File

@@ -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": """<div class="hero-section">
<h1>Build Your Business with Wizamart</h1>
<p class="lead">All-in-one e-commerce, loyalty, and business solutions for Luxembourg merchants</p>
</div>
<div class="platforms-showcase">
<h2>Our Platforms</h2>
<div class="platform-card">
<h3>Wizamart OMS</h3>
<p>Order Management System for multi-channel selling. Manage orders, inventory, and fulfillment from one dashboard.</p>
<a href="/platforms/oms/" class="btn">Learn More</a>
</div>
<div class="platform-card">
<h3>Loyalty+</h3>
<p>Customer loyalty platform to reward your customers and increase retention. Points, rewards, and member tiers.</p>
<a href="/platforms/loyalty/" class="btn">Learn More</a>
</div>
<div class="platform-card">
<h3>Site Builder</h3>
<p>Create beautiful websites for your local business. No coding required.</p>
<span class="badge">Coming Soon</span>
</div>
</div>
<div class="why-wizamart">
<h2>Why Choose Wizamart?</h2>
<ul>
<li><strong>Made for Luxembourg:</strong> Built specifically for Luxembourg businesses with local payment methods, languages, and compliance.</li>
<li><strong>All-in-One:</strong> Use our platforms together or separately - they integrate seamlessly.</li>
<li><strong>Local Support:</strong> Real support from real people in Luxembourg.</li>
</ul>
</div>""",
"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": """<div class="about-page">
<h1>About Wizamart</h1>
<div class="mission">
<h2>Our Mission</h2>
<p>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.</p>
</div>
<div class="story">
<h2>Our Story</h2>
<p>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.</p>
</div>
<div class="team">
<h2>Our Team</h2>
<p>We're a team of developers, designers, and business experts based in Luxembourg. We understand the local market because we're part of it.</p>
</div>
<div class="values">
<h2>Our Values</h2>
<ul>
<li><strong>Simplicity:</strong> Powerful tools that are easy to use</li>
<li><strong>Reliability:</strong> Your business depends on us - we take that seriously</li>
<li><strong>Local First:</strong> Built for Luxembourg, by Luxembourg</li>
<li><strong>Innovation:</strong> Always improving, always evolving</li>
</ul>
</div>
</div>""",
"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": """<div class="pricing-page">
<h1>Choose Your Platform</h1>
<p class="lead">Each platform has its own pricing. Choose the tools your business needs.</p>
<div class="platform-pricing">
<div class="platform-pricing-card">
<h3>Wizamart OMS</h3>
<p>Order Management System</p>
<div class="price-range">From €49/month</div>
<ul>
<li>Multi-channel order management</li>
<li>Inventory tracking</li>
<li>Shipping integrations</li>
<li>Analytics dashboard</li>
</ul>
<a href="/platforms/oms/pricing" class="btn">View OMS Pricing</a>
</div>
<div class="platform-pricing-card">
<h3>Loyalty+</h3>
<p>Customer Loyalty Platform</p>
<div class="price-range">From €49/month</div>
<ul>
<li>Points & rewards system</li>
<li>Member tiers</li>
<li>Analytics & insights</li>
<li>POS integrations</li>
</ul>
<a href="/platforms/loyalty/pricing" class="btn">View Loyalty+ Pricing</a>
</div>
<div class="platform-pricing-card">
<h3>Bundle & Save</h3>
<p>Use multiple platforms together</p>
<div class="price-range">Save up to 20%</div>
<ul>
<li>Seamless integration</li>
<li>Unified dashboard</li>
<li>Single invoice</li>
<li>Priority support</li>
</ul>
<a href="/contact" class="btn">Contact Sales</a>
</div>
</div>
</div>""",
"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": """<div class="faq-page">
<h1>Frequently Asked Questions</h1>
<div class="faq-section">
<h2>General</h2>
<div class="faq-item">
<h3>What is Wizamart?</h3>
<p>Wizamart is a suite of business tools for Luxembourg merchants, including order management (OMS), customer loyalty (Loyalty+), and website building (Site Builder).</p>
</div>
<div class="faq-item">
<h3>Do I need to use all platforms?</h3>
<p>No! Each platform works independently. Use one, two, or all three - whatever fits your business needs.</p>
</div>
<div class="faq-item">
<h3>What languages are supported?</h3>
<p>All platforms support French, German, and English - the three main languages of Luxembourg.</p>
</div>
</div>
<div class="faq-section">
<h2>Billing & Pricing</h2>
<div class="faq-item">
<h3>Is there a free trial?</h3>
<p>Yes! All platforms offer a 14-day free trial with no credit card required.</p>
</div>
<div class="faq-item">
<h3>What payment methods do you accept?</h3>
<p>We accept credit cards, SEPA direct debit, and bank transfers.</p>
</div>
<div class="faq-item">
<h3>Can I cancel anytime?</h3>
<p>Yes, you can cancel your subscription at any time. No long-term contracts required.</p>
</div>
</div>
<div class="faq-section">
<h2>Support</h2>
<div class="faq-item">
<h3>How do I get help?</h3>
<p>All plans include email support. Professional and Business plans include priority support with faster response times.</p>
</div>
<div class="faq-item">
<h3>Do you offer onboarding?</h3>
<p>Yes! We offer guided onboarding for all new customers to help you get started quickly.</p>
</div>
</div>
</div>""",
"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": """<div class="contact-page">
<h1>Contact Us</h1>
<p class="lead">We'd love to hear from you. Get in touch with our team.</p>
<div class="contact-options">
<div class="contact-card">
<h3>Sales</h3>
<p>Interested in our platforms? Let's talk about how we can help your business.</p>
<p><a href="mailto:sales@wizamart.lu">sales@wizamart.lu</a></p>
</div>
<div class="contact-card">
<h3>Support</h3>
<p>Already a customer? Our support team is here to help.</p>
<p><a href="mailto:support@wizamart.lu">support@wizamart.lu</a></p>
</div>
<div class="contact-card">
<h3>General Inquiries</h3>
<p>For everything else, reach out to our general inbox.</p>
<p><a href="mailto:hello@wizamart.lu">hello@wizamart.lu</a></p>
</div>
</div>
<div class="address">
<h3>Office</h3>
<p>Wizamart S.à r.l.<br>
Luxembourg City<br>
Luxembourg</p>
</div>
</div>""",
"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": """<div class="terms-page">
<h1>Terms of Service</h1>
<p class="last-updated">Last updated: January 2026</p>
<p>These Terms of Service govern your use of Wizamart platforms and services.</p>
<h2>1. Acceptance of Terms</h2>
<p>By accessing or using our services, you agree to be bound by these Terms.</p>
<h2>2. Services</h2>
<p>Wizamart provides e-commerce and business management tools including order management, loyalty programs, and website building services.</p>
<h2>3. Account Registration</h2>
<p>You must provide accurate information when creating an account and keep your login credentials secure.</p>
<h2>4. Fees and Payment</h2>
<p>Subscription fees are billed in advance on a monthly or annual basis. Prices are listed in EUR and include applicable VAT for Luxembourg customers.</p>
<h2>5. Data Protection</h2>
<p>We process personal data in accordance with our Privacy Policy and applicable data protection laws including GDPR.</p>
<h2>6. Limitation of Liability</h2>
<p>Our liability is limited to the amount paid for services in the 12 months preceding any claim.</p>
<h2>7. Governing Law</h2>
<p>These Terms are governed by Luxembourg law. Disputes shall be resolved in Luxembourg courts.</p>
<h2>8. Contact</h2>
<p>For questions about these Terms, contact us at legal@wizamart.lu</p>
</div>""",
"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": """<div class="privacy-page">
<h1>Privacy Policy</h1>
<p class="last-updated">Last updated: January 2026</p>
<h2>Introduction</h2>
<p>Wizamart S.à r.l. ("we", "us") is committed to protecting your privacy. This policy explains how we collect, use, and protect your personal data.</p>
<h2>Data Controller</h2>
<p>Wizamart S.à r.l.<br>Luxembourg City, Luxembourg<br>Email: privacy@wizamart.lu</p>
<h2>Data We Collect</h2>
<ul>
<li>Account information (name, email, company details)</li>
<li>Usage data (how you use our platforms)</li>
<li>Payment information (processed by our payment providers)</li>
<li>Support communications</li>
</ul>
<h2>How We Use Your Data</h2>
<ul>
<li>To provide and improve our services</li>
<li>To process payments and billing</li>
<li>To communicate with you about your account</li>
<li>To send marketing communications (with your consent)</li>
</ul>
<h2>Your Rights</h2>
<p>Under GDPR, you have the right to:</p>
<ul>
<li>Access your personal data</li>
<li>Rectify inaccurate data</li>
<li>Request deletion of your data</li>
<li>Data portability</li>
<li>Object to processing</li>
</ul>
<h2>Contact</h2>
<p>To exercise your rights or ask questions, contact our Data Protection Officer at privacy@wizamart.lu</p>
</div>""",
"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'"))

View File

@@ -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

402
main.py
View File

@@ -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"""

View File

@@ -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",
]:
# 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()
if platform_code:
clean_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
return {
"path_prefix": potential_platform_code,
"path_prefix": platform_code,
"detection_method": "path",
"host": host,
"original_path": path,
"clean_path": "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/",
"clean_path": clean_path,
}
# Method 3: Default platform for localhost without prefix
# 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: