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:
@@ -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>
|
||||
Reference in New Issue
Block a user