diff --git a/app/core/preview_token.py b/app/core/preview_token.py
new file mode 100644
index 00000000..2ed02475
--- /dev/null
+++ b/app/core/preview_token.py
@@ -0,0 +1,54 @@
+# app/core/preview_token.py
+"""
+Signed preview tokens for POC site previews.
+
+Generates time-limited JWT tokens that allow viewing storefront pages
+for stores without active subscriptions (POC sites). The token is
+validated by StorefrontAccessMiddleware to bypass the subscription gate.
+"""
+
+import logging
+from datetime import UTC, datetime, timedelta
+
+from jose import JWTError, jwt
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+PREVIEW_TOKEN_HOURS = 24
+ALGORITHM = "HS256"
+
+
+def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
+ """Create a signed preview token for a POC site.
+
+ Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
+ to a specific store_id. Shareable with clients for preview access.
+ """
+ payload = {
+ "sub": f"preview:{store_id}",
+ "store_id": store_id,
+ "store_code": store_code,
+ "site_id": site_id,
+ "preview": True,
+ "exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
+ "iat": datetime.now(UTC),
+ }
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
+
+
+def verify_preview_token(token: str, store_id: int) -> bool:
+ """Verify a preview token is valid and matches the store.
+
+ Returns True if:
+ - Token signature is valid
+ - Token has not expired
+ - Token has preview=True claim
+ - Token store_id matches the requested store
+ """
+ try:
+ payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
+ return payload.get("preview") is True and payload.get("store_id") == store_id
+ except JWTError:
+ return False
diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py
index 9c3d73ff..f2cd1c6f 100644
--- a/app/modules/hosting/routes/api/admin_sites.py
+++ b/app/modules/hosting/routes/api/admin_sites.py
@@ -44,6 +44,31 @@ def list_templates(
)
+class PreviewUrlResponse(BaseModel):
+ """Response with signed preview URL."""
+
+ preview_url: str
+ expires_in_hours: int = 24
+
+
+@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
+def get_preview_url(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Generate a signed preview URL for a hosted site."""
+ from app.core.preview_token import create_preview_token
+
+ site = hosted_site_service.get_by_id(db, site_id)
+ store = site.store
+ subdomain = store.subdomain or store.store_code
+ token = create_preview_token(store.id, subdomain, site.id)
+ return PreviewUrlResponse(
+ preview_url=f"/storefront/{subdomain}/?_preview={token}",
+ )
+
+
class BuildPocRequest(BaseModel):
"""Request to build a POC site from prospect + template."""
diff --git a/app/modules/hosting/routes/pages/public.py b/app/modules/hosting/routes/pages/public.py
index 673f429d..5396edcd 100644
--- a/app/modules/hosting/routes/pages/public.py
+++ b/app/modules/hosting/routes/pages/public.py
@@ -2,43 +2,40 @@
"""
Hosting Public Page Routes.
-Public-facing routes for POC site viewing:
-- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
+POC site preview via signed URL redirect to the storefront.
+The StorefrontAccessMiddleware validates the preview token and
+allows rendering without an active subscription.
"""
-from fastapi import APIRouter, Depends, Path, Request
-from fastapi.responses import HTMLResponse
+from fastapi import APIRouter, Depends, Path, Query
+from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
-from app.templates_config import templates
+from app.core.preview_token import create_preview_token
router = APIRouter()
@router.get(
"/hosting/sites/{site_id}/preview",
- response_class=HTMLResponse,
include_in_schema=False,
)
async def poc_site_viewer(
- request: Request,
site_id: int = Path(..., description="Hosted Site ID"),
+ page: str = Query("homepage", description="Page slug to preview"),
db: Session = Depends(get_db),
):
- """Render POC site viewer with HostWizard preview banner.
+ """Redirect to storefront with signed preview token.
- Renders CMS content directly (not via iframe to storefront) to
- bypass the subscription access gate for pre-launch POC sites.
+ Generates a time-limited JWT and redirects to the store's
+ storefront URL. The StorefrontAccessMiddleware validates the
+ token and bypasses the subscription check.
"""
- from app.modules.cms.models.content_page import ContentPage
- from app.modules.cms.models.store_theme import StoreTheme
from app.modules.hosting.models import HostedSite, HostedSiteStatus
- from app.modules.tenancy.models import StorePlatform
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
- # Only allow viewing for poc_ready or proposal_sent sites
if not site or site.status not in (
HostedSiteStatus.POC_READY,
HostedSiteStatus.PROPOSAL_SENT,
@@ -47,56 +44,31 @@ async def poc_site_viewer(
return HTMLResponse(content="
Site not available for preview ", status_code=404)
store = site.store
+ if not store:
+ return HTMLResponse(content="Store not found ", status_code=404)
+
+ # Generate signed preview token — use subdomain for URL routing
+ subdomain = store.subdomain or store.store_code
+ token = create_preview_token(store.id, subdomain, site.id)
+
+ # Get platform code for dev-mode URL prefix
+ from app.core.config import settings
+ from app.modules.tenancy.models import StorePlatform
- # Get platform_id for CMS query
store_platform = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == store.id)
.first()
- ) if store else None
- platform_id = store_platform.platform_id if store_platform else None
-
- # Load homepage (slug='homepage' from POC builder)
- page = None
- if platform_id:
- page = (
- db.query(ContentPage)
- .filter(
- ContentPage.platform_id == platform_id,
- ContentPage.store_id == store.id,
- ContentPage.slug == "homepage",
- ContentPage.is_published.is_(True),
- )
- .first()
- )
-
- # Load all nav pages for this store
- nav_pages = []
- if platform_id:
- nav_pages = (
- db.query(ContentPage)
- .filter(
- ContentPage.platform_id == platform_id,
- ContentPage.store_id == store.id,
- ContentPage.is_published.is_(True),
- ContentPage.show_in_header.is_(True),
- )
- .order_by(ContentPage.display_order)
- .all()
- )
-
- # Load theme
- theme = db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first() if store else None
-
- context = {
- "request": request,
- "site": site,
- "store": store,
- "page": page,
- "nav_pages": nav_pages,
- "theme": theme,
- }
- return templates.TemplateResponse(
- "hosting/public/poc-viewer.html",
- context,
)
+
+ # In dev mode, storefront needs /platforms/{code}/ prefix
+ if settings.debug and store_platform and store_platform.platform:
+ platform_code = store_platform.platform.code
+ base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
+ else:
+ base_url = f"/storefront/{subdomain}"
+
+ # Append page slug — storefront needs /{slug} (root has no catch-all)
+ base_url += f"/{page}"
+
+ return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)
diff --git a/app/modules/hosting/templates/hosting/public/poc-viewer.html b/app/modules/hosting/templates/hosting/public/poc-viewer.html
deleted file mode 100644
index 352a94e9..00000000
--- a/app/modules/hosting/templates/hosting/public/poc-viewer.html
+++ /dev/null
@@ -1,183 +0,0 @@
-
-
-
-
-
- {{ site.business_name }} - Preview by HostWizard
- {% if page and page.meta_description %}
-
- {% endif %}
-
-
-
-
-
-
- {# HostWizard Preview Banner #}
-
-
- HostWizard
- Preview for {{ site.business_name }}
-
-
-
-
- {# Main Content — rendered directly, no iframe #}
-
- {% if page and page.sections %}
- {# Section-based rendering (from POC builder templates) #}
- {% set lang = 'fr' %}
- {% set default_lang = 'fr' %}
-
- {% if page.sections.hero %}
- {% set hero = page.sections.hero %}
-
-
- {% set t = hero.get('title', {}) %}
- {% set tr = t.get('translations', {}) if t is mapping else {} %}
-
{{ tr.get(lang) or tr.get(default_lang) or site.business_name }}
- {% set st = hero.get('subtitle', {}) %}
- {% set str_ = st.get('translations', {}) if st is mapping else {} %}
- {% set subtitle_text = str_.get(lang) or str_.get(default_lang, '') %}
- {% if subtitle_text %}
-
{{ subtitle_text }}
- {% endif %}
- {% set hero_buttons = hero.get('buttons', []) %}
- {% if hero_buttons %}
-
- {% for btn in hero_buttons %}
- {% set bl = btn.get('label', {}) %}
- {% set bltr = bl.get('translations', {}) if bl is mapping else {} %}
- {% set btn_label = bltr.get(lang) or bltr.get(default_lang, 'Learn More') %}
-
- {{ btn_label }}
-
- {% endfor %}
-
- {% endif %}
-
-
- {% endif %}
-
- {% if page.sections.features %}
- {% set features = page.sections.features %}
-
-
- {% set ft = features.get('title', {}) %}
- {% set ftr = ft.get('translations', {}) if ft is mapping else {} %}
- {% set title = ftr.get(lang) or ftr.get(default_lang, '') %}
- {% if title %}
-
{{ title }}
- {% endif %}
- {% set feature_items = features.get('items', []) %}
- {% if feature_items %}
-
- {% for item in feature_items %}
- {% set it = item.get('title', {}) %}
- {% set itr = it.get('translations', {}) if it is mapping else {} %}
- {% set item_title = itr.get(lang) or itr.get(default_lang, '') %}
- {% set id_ = item.get('description', {}) %}
- {% set idr = id_.get('translations', {}) if id_ is mapping else {} %}
- {% set item_desc = idr.get(lang) or idr.get(default_lang, '') %}
-
-
- {{ item_title[0]|upper if item_title else '?' }}
-
-
{{ item_title }}
-
{{ item_desc }}
-
- {% endfor %}
-
- {% endif %}
-
-
- {% endif %}
-
- {% if page.sections.testimonials %}
- {% set testimonials = page.sections.testimonials %}
-
-
- {% set tt = testimonials.get('title', {}) %}
- {% set ttr = tt.get('translations', {}) if tt is mapping else {} %}
- {% set title = ttr.get(lang) or ttr.get(default_lang, '') %}
- {% if title %}
-
{{ title }}
- {% endif %}
-
Coming soon
-
-
- {% endif %}
-
- {% if page.sections.cta %}
- {% set cta = page.sections.cta %}
-
-
- {% set ct = cta.get('title', {}) %}
- {% set ctr = ct.get('translations', {}) if ct is mapping else {} %}
-
{{ ctr.get(lang) or ctr.get(default_lang, '') }}
- {% set cta_buttons = cta.get('buttons', []) %}
- {% if cta_buttons %}
-
- {% endif %}
-
-
- {% endif %}
-
- {% elif page and page.content %}
- {# Plain HTML content #}
-
- {{ page.content | safe }}
-
- {% else %}
-
-
No content yet. Build a POC to populate this site.
-
- {% endif %}
-
-
- {# Footer #}
-
-
-
{{ site.business_name }}
- {% if site.contact_email %}
-
{{ site.contact_email }}
- {% endif %}
- {% if site.contact_phone %}
-
{{ site.contact_phone }}
- {% endif %}
-
Powered by HostWizard
-
-
-
-
-
diff --git a/app/templates/storefront/base.html b/app/templates/storefront/base.html
index c626d3ef..e3f51e02 100644
--- a/app/templates/storefront/base.html
+++ b/app/templates/storefront/base.html
@@ -210,6 +210,18 @@
+ {# Preview mode banner (POC site previews via signed URL) #}
+ {% if request.state.is_preview|default(false) %}
+
+
+ {% endif %}
+
{# Mobile menu panel #}