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:
2026-04-02 22:41:34 +02:00
parent e492e5f71c
commit cff0af31be
6 changed files with 139 additions and 244 deletions

View File

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