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

+
+ +
+

Our Platforms

+ +
+

Wizamart OMS

+

Order Management System for multi-channel selling. Manage orders, inventory, and fulfillment from one dashboard.

+ Learn More +
+ +
+

Loyalty+

+

Customer loyalty platform to reward your customers and increase retention. Points, rewards, and member tiers.

+ Learn More +
+ +
+

Site Builder

+

Create beautiful websites for your local business. No coding required.

+ Coming Soon +
+
+ +
+

Why Choose Wizamart?

+ +
""", + "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

+ +
+
""", + "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.

+ +
+
+

Wizamart OMS

+

Order Management System

+
From €49/month
+
    +
  • Multi-channel order management
  • +
  • Inventory tracking
  • +
  • Shipping integrations
  • +
  • Analytics dashboard
  • +
+ View OMS Pricing +
+ +
+

Loyalty+

+

Customer Loyalty Platform

+
From €49/month
+
    +
  • Points & rewards system
  • +
  • Member tiers
  • +
  • Analytics & insights
  • +
  • POS integrations
  • +
+ View Loyalty+ Pricing +
+ +
+

Bundle & Save

+

Use multiple platforms together

+
Save up to 20%
+
    +
  • Seamless integration
  • +
  • Unified dashboard
  • +
  • Single invoice
  • +
  • Priority support
  • +
+ Contact Sales +
+
+
""", + "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.

+ +
+
+

Sales

+

Interested in our platforms? Let's talk about how we can help your business.

+

sales@wizamart.lu

+
+ +
+

Support

+

Already a customer? Our support team is here to help.

+

support@wizamart.lu

+
+ +
+

General Inquiries

+

For everything else, reach out to our general inbox.

+

hello@wizamart.lu

+
+
+ +
+

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

+ + +

How We Use Your Data

+ + +

Your Rights

+

Under GDPR, you have the right to:

+ + +

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: