feat(hosting): signed preview URLs for POC sites
Replace the standalone POC viewer (duplicate rendering) with signed
JWT preview tokens that bypass StorefrontAccessMiddleware:
Architecture:
1. Admin clicks Preview → route generates signed JWT
2. Redirects to /storefront/{subdomain}/homepage?_preview=token
3. Middleware validates token signature + expiry + store_id
4. Sets request.state.is_preview = True, skips subscription check
5. Full storefront renders with HostWizard preview banner injected
New files:
- app/core/preview_token.py: create_preview_token/verify_preview_token
Changes:
- middleware/storefront_access.py: preview token bypass before sub check
- storefront/base.html: preview banner injection via is_preview state
- hosting/routes/pages/public.py: redirect with signed token (was direct render)
- hosting/routes/api/admin_sites.py: GET /sites/{id}/preview-url endpoint
Removed:
- hosting/templates/hosting/public/poc-viewer.html (replaced by storefront)
Benefits: one rendering path, all section types work, shareable 24h links.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -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
|
||||||
@@ -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):
|
class BuildPocRequest(BaseModel):
|
||||||
"""Request to build a POC site from prospect + template."""
|
"""Request to build a POC site from prospect + template."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,43 +2,40 @@
|
|||||||
"""
|
"""
|
||||||
Hosting Public Page Routes.
|
Hosting Public Page Routes.
|
||||||
|
|
||||||
Public-facing routes for POC site viewing:
|
POC site preview via signed URL redirect to the storefront.
|
||||||
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
|
The StorefrontAccessMiddleware validates the preview token and
|
||||||
|
allows rendering without an active subscription.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/hosting/sites/{site_id}/preview",
|
"/hosting/sites/{site_id}/preview",
|
||||||
response_class=HTMLResponse,
|
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def poc_site_viewer(
|
async def poc_site_viewer(
|
||||||
request: Request,
|
|
||||||
site_id: int = Path(..., description="Hosted Site ID"),
|
site_id: int = Path(..., description="Hosted Site ID"),
|
||||||
|
page: str = Query("homepage", description="Page slug to preview"),
|
||||||
db: Session = Depends(get_db),
|
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
|
Generates a time-limited JWT and redirects to the store's
|
||||||
bypass the subscription access gate for pre-launch POC sites.
|
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.hosting.models import HostedSite, HostedSiteStatus
|
||||||
from app.modules.tenancy.models import StorePlatform
|
|
||||||
|
|
||||||
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
|
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 (
|
if not site or site.status not in (
|
||||||
HostedSiteStatus.POC_READY,
|
HostedSiteStatus.POC_READY,
|
||||||
HostedSiteStatus.PROPOSAL_SENT,
|
HostedSiteStatus.PROPOSAL_SENT,
|
||||||
@@ -47,56 +44,31 @@ async def poc_site_viewer(
|
|||||||
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
||||||
|
|
||||||
store = site.store
|
store = site.store
|
||||||
|
if not store:
|
||||||
|
return HTMLResponse(content="<h1>Store not found</h1>", 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 = (
|
store_platform = (
|
||||||
db.query(StorePlatform)
|
db.query(StorePlatform)
|
||||||
.filter(StorePlatform.store_id == store.id)
|
.filter(StorePlatform.store_id == store.id)
|
||||||
.first()
|
.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)
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ site.business_name }} - Preview by HostWizard</title>
|
|
||||||
{% if page and page.meta_description %}
|
|
||||||
<meta name="description" content="{{ page.meta_description }}">
|
|
||||||
{% endif %}
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '{{ theme.colors.primary if theme and theme.colors else "#3b82f6" }}',
|
|
||||||
'primary-dark': '{{ theme.colors.secondary if theme and theme.colors else "#1e40af" }}',
|
|
||||||
accent: '{{ theme.colors.accent if theme and theme.colors else "#f59e0b" }}',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family={{ (theme.font_family_heading or "Inter")|replace(" ", "+") }}:wght@400;600;700;800&family={{ (theme.font_family_body or "Inter")|replace(" ", "+") }}:wght@300;400;500;600&display=swap');
|
|
||||||
body { font-family: '{{ theme.font_family_body if theme else "Inter" }}', sans-serif; }
|
|
||||||
h1, h2, h3, h4, h5, h6 { font-family: '{{ theme.font_family_heading if theme else "Inter" }}', sans-serif; }
|
|
||||||
.gradient-primary { background: linear-gradient(135deg, {{ theme.colors.primary if theme and theme.colors else "#3b82f6" }}, {{ theme.colors.secondary if theme and theme.colors else "#1e40af" }}); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-white text-gray-900">
|
|
||||||
|
|
||||||
{# HostWizard Preview Banner #}
|
|
||||||
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;">
|
|
||||||
<span style="font-weight:700;font-size:16px;">HostWizard</span>
|
|
||||||
<span style="opacity:0.9;">Preview for {{ site.business_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;">
|
|
||||||
{% for np in nav_pages %}
|
|
||||||
<a href="/hosting/sites/{{ site.id }}/preview?page={{ np.slug }}" style="color:white;text-decoration:none;padding:4px 12px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;font-size:13px;">{{ np.title }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Main Content — rendered directly, no iframe #}
|
|
||||||
<div style="margin-top:48px;">
|
|
||||||
{% 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 %}
|
|
||||||
<section class="relative py-24 lg:py-32 gradient-primary text-white">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
|
||||||
{% set t = hero.get('title', {}) %}
|
|
||||||
{% set tr = t.get('translations', {}) if t is mapping else {} %}
|
|
||||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">{{ tr.get(lang) or tr.get(default_lang) or site.business_name }}</h1>
|
|
||||||
{% 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 %}
|
|
||||||
<p class="text-xl md:text-2xl opacity-90 mb-10 max-w-2xl mx-auto">{{ subtitle_text }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% set hero_buttons = hero.get('buttons', []) %}
|
|
||||||
{% if hero_buttons %}
|
|
||||||
<div class="flex flex-wrap justify-center gap-4">
|
|
||||||
{% 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') %}
|
|
||||||
<a href="{{ btn.get('url', '#') }}" class="px-8 py-4 rounded-lg font-semibold text-lg {{ 'bg-white text-gray-900 hover:bg-gray-100' if btn.get('style') == 'primary' else 'border-2 border-white text-white hover:bg-white/10' }} transition-colors">
|
|
||||||
{{ btn_label }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page.sections.features %}
|
|
||||||
{% set features = page.sections.features %}
|
|
||||||
<section class="py-16 lg:py-24 bg-white">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
{% 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 %}
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-center mb-12">{{ title }}</h2>
|
|
||||||
{% endif %}
|
|
||||||
{% set feature_items = features.get('items', []) %}
|
|
||||||
{% if feature_items %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [feature_items|length, 4]|min }} gap-8">
|
|
||||||
{% 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, '') %}
|
|
||||||
<div class="bg-gray-50 rounded-xl p-8 text-center">
|
|
||||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
|
|
||||||
<span class="text-white text-2xl font-bold">{{ item_title[0]|upper if item_title else '?' }}</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold mb-2">{{ item_title }}</h3>
|
|
||||||
<p class="text-gray-600">{{ item_desc }}</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page.sections.testimonials %}
|
|
||||||
{% set testimonials = page.sections.testimonials %}
|
|
||||||
<section class="py-16 bg-gray-50">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
|
||||||
{% 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 %}
|
|
||||||
<h2 class="text-3xl font-bold mb-12">{{ title }}</h2>
|
|
||||||
{% endif %}
|
|
||||||
<p class="text-gray-400">Coming soon</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page.sections.cta %}
|
|
||||||
{% set cta = page.sections.cta %}
|
|
||||||
<section class="py-16 gradient-primary text-white">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 text-center">
|
|
||||||
{% set ct = cta.get('title', {}) %}
|
|
||||||
{% set ctr = ct.get('translations', {}) if ct is mapping else {} %}
|
|
||||||
<h2 class="text-3xl font-bold mb-8">{{ ctr.get(lang) or ctr.get(default_lang, '') }}</h2>
|
|
||||||
{% set cta_buttons = cta.get('buttons', []) %}
|
|
||||||
{% if cta_buttons %}
|
|
||||||
<div class="flex flex-wrap justify-center gap-4">
|
|
||||||
{% for btn in cta_buttons %}
|
|
||||||
{% set bl = btn.get('label', {}) %}
|
|
||||||
{% set bltr = bl.get('translations', {}) if bl is mapping else {} %}
|
|
||||||
<a href="{{ btn.get('url', '#') }}" class="px-8 py-4 bg-white text-gray-900 rounded-lg font-semibold text-lg hover:bg-gray-100 transition-colors">
|
|
||||||
{{ bltr.get(lang) or bltr.get(default_lang, 'Contact') }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% elif page and page.content %}
|
|
||||||
{# Plain HTML content #}
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-16 prose lg:prose-lg">
|
|
||||||
{{ page.content | safe }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-16 text-center text-gray-400">
|
|
||||||
<p>No content yet. Build a POC to populate this site.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Footer #}
|
|
||||||
<footer class="bg-gray-900 text-gray-400 py-12">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 text-center">
|
|
||||||
<p class="text-lg font-semibold text-white mb-2">{{ site.business_name }}</p>
|
|
||||||
{% if site.contact_email %}
|
|
||||||
<p>{{ site.contact_email }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if site.contact_phone %}
|
|
||||||
<p>{{ site.contact_phone }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<p class="mt-6 text-sm text-gray-500">Powered by <a href="https://hostwizard.lu" class="text-teal-400 hover:underline" target="_blank">HostWizard</a></p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -210,6 +210,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{# Preview mode banner (POC site previews via signed URL) #}
|
||||||
|
{% if request.state.is_preview|default(false) %}
|
||||||
|
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<span style="font-weight:700;font-size:16px;">HostWizard</span>
|
||||||
|
<span style="opacity:0.9;">Preview Mode</span>
|
||||||
|
</div>
|
||||||
|
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
|
||||||
|
</div>
|
||||||
|
<style>header { margin-top: 48px !important; }</style>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Mobile menu panel #}
|
{# Mobile menu panel #}
|
||||||
<div x-show="mobileMenuOpen"
|
<div x-show="mobileMenuOpen"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
store = getattr(request.state, "store", None)
|
store = getattr(request.state, "store", None)
|
||||||
|
|
||||||
|
# Case 0: Preview token bypass (POC site previews)
|
||||||
|
preview_token = request.query_params.get("_preview")
|
||||||
|
if preview_token and store:
|
||||||
|
from app.core.preview_token import verify_preview_token
|
||||||
|
|
||||||
|
if verify_preview_token(preview_token, store.id):
|
||||||
|
request.state.is_preview = True
|
||||||
|
request.state.subscription = None
|
||||||
|
request.state.subscription_tier = None
|
||||||
|
logger.info(
|
||||||
|
"[STOREFRONT_ACCESS] Preview token valid for store '%s'",
|
||||||
|
store.subdomain,
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# Case 1: No store detected at all
|
# Case 1: No store detected at all
|
||||||
if not store:
|
if not store:
|
||||||
return self._render_unavailable(request, "not_found")
|
return self._render_unavailable(request, "not_found")
|
||||||
|
|||||||
Reference in New Issue
Block a user