Compare commits

..

9 Commits

Author SHA1 Message Date
dfd42c1b10 docs: add proposal for POC content mapping (scraped → template)
Some checks failed
CI / ruff (push) Successful in 16s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Details the gap between scraped content (21 paragraphs, 30 headings,
13 images) and what appears on POC pages (only placeholder fields).

Phase 1 plan: programmatic mapping of scraped headings/paragraphs/
images into template sections (hero subtitle, gallery, about text).
Phase 2: AI-powered content enhancement (deferred, provider TBD).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:14:17 +02:00
297b8a8d5a fix(hosting): preview link rewriting prepends storefront base_url
CTA buttons in section macros use short paths like /contact which
resolve to site root instead of the storefront path. The preview
JS now detects short paths (not starting with /platforms/, /storefront/,
etc.) and prepends the store's base_url before adding _preview token.

Example: /contact → /platforms/hosting/storefront/batirenovation-strasbourg/contact?_preview=...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:03:04 +02:00
91fb4b0757 fix(hosting): propagate preview token in nav links + enrich pages with scraped content
Preview token propagation:
- JavaScript in storefront base.html appends _preview query param to
  all internal links when in preview mode, so clicking nav items
  (Services, Contact, etc.) preserves the preview bypass

Scraped content enrichment:
- POC builder now appends first 5 scraped paragraphs to about/services/
  projects pages, so the POC shows actual content from the prospect's
  site instead of just generic template text
- Extracts tagline from second scraped heading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:55:19 +02:00
f4386e97ee fix(cms): testimonials dict.items() collision in section macro
testimonials.items in Jinja2 calls dict.items() method instead of
accessing the 'items' key when sections are raw JSON dicts (POC builder
pages). Fixed by using .get('items', []) with mapping check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:54:36 +02:00
e8c9fc7e7d fix(hosting): template buttons use 'text' to match CMS section macros
The hero and CTA section macros expect button.text.translations but
template JSONs used button.label.translations. Changed all 5 template
homepage files: label → text in button objects.

Also fixed existing CMS pages in DB (page 56) to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:51:19 +02:00
d591200df8 fix(cms): storefront renders sections for landing pages + fix nav URLs
Two critical fixes for POC site rendering:

1. Storefront content page route now selects template based on
   page.template field: 'full' → landing-full.html (section-based),
   'modern'/'minimal' → their respective templates. Default stays
   content-page.html (plain HTML). Previously always used content-page
   which ignores page.sections JSON.

2. Storefront base_url uses store.subdomain (lowercase, hyphens)
   instead of store.store_code (uppercase, underscores) for URL
   building. Nav links now point to correct paths that the store
   context middleware can resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:42:45 +02:00
83af32eb88 fix(hosting): POC builder works with existing sites
The Build POC button on site detail now passes site_id to the POC
builder, which populates the existing site's store with CMS content
instead of trying to create a new site (which failed with duplicate
slug error).

- poc_builder_service.build_poc() accepts optional site_id param
- If site_id given: uses existing site, skips hosted_site_service.create()
- If not given: creates new site (standalone POC build)
- API schema: added site_id to BuildPocRequest
- Frontend: passes this.site.id in the build request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:10:39 +02:00
2a49e3d30f fix(hosting): fix Build POC button visibility
Same issue as Create Site button — bg-teal-600 not in Tailwind purge.
Switched to bg-purple-600 and removed $icon('sparkles') which may
not exist in the icon set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:57:55 +02:00
6e40e16017 fix(hosting): cascade soft-delete Store when deleting HostedSite
When deleting a hosted site, the associated Store is now soft-deleted
(sets deleted_at). This frees the subdomain for reuse via the partial
unique index (WHERE deleted_at IS NULL).

Previously the Store was orphaned, blocking subdomain reuse.
Closes docs/proposals/hosting-cascade-delete.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:49:40 +02:00
14 changed files with 254 additions and 38 deletions

View File

@@ -109,8 +109,16 @@ async def generic_content_page(
context = get_storefront_context(request, db=db, page=page)
context["page_content"] = page_content
# Select template based on page.template field
template_map = {
"full": "cms/storefront/landing-full.html",
"modern": "cms/storefront/landing-modern.html",
"minimal": "cms/storefront/landing-minimal.html",
}
template_name = template_map.get(page.template, "cms/storefront/content-page.html")
return templates.TemplateResponse(
"cms/storefront/content-page.html",
template_name,
context,
)

View File

@@ -20,10 +20,11 @@
{% endif %}
</div>
{# Testimonial cards #}
{% if testimonials.items %}
{# Testimonial cards — use .get() to avoid dict.items() method collision with JSON dicts #}
{% set testimonial_items = testimonials.get('items', []) if testimonials is mapping else [] %}
{% if testimonial_items %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{% for item in testimonials.items %}
{% for item in testimonial_items %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex text-yellow-400">

View File

@@ -384,15 +384,17 @@ def get_storefront_context(
if access_method == "path" and store:
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
# Use subdomain (lowercase, hyphens) for URL routing — store_code is for internal use
store_slug = store.subdomain or store.store_code
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
base_url = f"/platforms/{platform.code}/storefront/{store_slug}/"
else:
full_prefix = (
store_context.get("full_prefix", "/storefront/")
if store_context
else "/storefront/"
)
base_url = f"{full_prefix}{store.store_code}/"
base_url = f"{full_prefix}{store_slug}/"
# Read subscription info set by StorefrontAccessMiddleware
subscription = getattr(request.state, "subscription", None)

View File

@@ -75,6 +75,7 @@ class BuildPocRequest(BaseModel):
prospect_id: int
template_id: str
merchant_id: int | None = None
site_id: int | None = None # If set, populate existing site instead of creating new one
class BuildPocResponse(BaseModel):
@@ -100,6 +101,7 @@ def build_poc(
prospect_id=data.prospect_id,
template_id=data.template_id,
merchant_id=data.merchant_id,
site_id=data.site_id,
)
db.commit()
return BuildPocResponse(**result)

View File

@@ -218,8 +218,19 @@ class HostedSiteService:
return site
def delete(self, db: Session, site_id: int) -> bool:
"""Delete a hosted site and soft-delete the associated store."""
from app.core.soft_delete import soft_delete
site = self.get_by_id(db, site_id)
store = site.store
db.delete(site)
# Soft-delete the store created for this site (frees the subdomain)
if store:
soft_delete(db, store)
logger.info("Soft-deleted store %d (subdomain=%s) for site %d", store.id, store.subdomain, site_id)
db.flush()
logger.info("Deleted hosted site: %d", site_id)
return True

View File

@@ -35,9 +35,13 @@ class PocBuilderService:
prospect_id: int,
template_id: str,
merchant_id: int | None = None,
site_id: int | None = None,
) -> dict:
"""Build a complete POC site from prospect data and a template.
If site_id is given, populates the existing site's store with CMS
content. Otherwise creates a new HostedSite + Store.
Returns dict with hosted_site, store, pages_created, theme_applied.
"""
from app.modules.prospecting.models import Prospect
@@ -57,17 +61,19 @@ class PocBuilderService:
# 3. Build placeholder context from prospect data
context = self._build_context(prospect)
# 4. Create HostedSite + Store
# 4. Use existing site or create new one
if site_id:
site = hosted_site_service.get_by_id(db, site_id)
else:
site_data = {
"business_name": context["business_name"],
"domain_name": prospect.domain_name, # used for clean subdomain slug
"domain_name": prospect.domain_name,
"prospect_id": prospect_id,
"contact_email": context.get("email"),
"contact_phone": context.get("phone"),
}
if merchant_id:
site_data["merchant_id"] = merchant_id
site = hosted_site_service.create(db, site_data)
# 5. Get the hosting platform_id from the store
@@ -141,8 +147,16 @@ class PocBuilderService:
context["meta_description"] = scraped["meta_description"]
if scraped.get("paragraphs"):
context["about_paragraph"] = scraped["paragraphs"][0]
if scraped.get("headings") and not prospect.business_name:
# Build rich content from scraped paragraphs for page bodies
context["scraped_paragraphs_html"] = "\n".join(
f"<p>{p}</p>" for p in scraped["paragraphs"][:5]
)
if scraped.get("headings"):
if not prospect.business_name:
context["business_name"] = scraped["headings"][0]
# Use second heading as tagline if available
if len(scraped["headings"]) > 1:
context["tagline"] = scraped["headings"][1]
except (json.JSONDecodeError, KeyError):
pass
@@ -197,6 +211,16 @@ class PocBuilderService:
if content_translations and not content:
content = next(iter(content_translations.values()), "")
# Enrich with scraped paragraphs (append to template content)
scraped_html = context.get("scraped_paragraphs_html", "")
if scraped_html and slug in ("about", "services", "projects"):
content = content + "\n" + scraped_html if content else scraped_html
if content_translations:
for lang_code in content_translations:
content_translations[lang_code] = (
content_translations[lang_code] + "\n" + scraped_html
)
page = ContentPage(
platform_id=platform_id,
store_id=store_id,

View File

@@ -45,7 +45,7 @@
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[200px]">
<select x-model="selectedTemplate"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-teal-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select a template...</option>
<template x-for="t in templates" :key="t.id">
<option :value="t.id" x-text="t.name + ' — ' + t.description"></option>
@@ -53,9 +53,7 @@
</select>
</div>
<button type="button" @click="buildPoc()" :disabled="!selectedTemplate || buildingPoc"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50">
<span x-show="!buildingPoc" x-html="$icon('sparkles', 'w-4 h-4 mr-2')"></span>
<span x-show="buildingPoc" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
<span x-text="buildingPoc ? 'Building...' : 'Build POC'"></span>
</button>
</div>
@@ -353,6 +351,7 @@ function hostingSiteDetail(siteId) {
var result = await apiClient.post('/admin/hosting/sites/poc/build', {
prospect_id: this.site.prospect_id,
template_id: this.selectedTemplate,
site_id: this.site.id,
});
this.pocResult = 'POC built! ' + result.pages_created + ' pages created.';
Utils.showToast('POC built successfully', 'success');

View File

@@ -10,8 +10,8 @@
"subtitle": {"translations": {"en": "Your trusted auto parts specialist in {{city}}", "fr": "Votre spécialiste pièces auto de confiance à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
{"text": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
]
},
"features": {
@@ -27,7 +27,7 @@
"enabled": true,
"title": {"translations": {"en": "Need a specific part?", "fr": "Besoin d'une pièce spécifique ?"}},
"buttons": [
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
}
}

View File

@@ -10,8 +10,8 @@
"subtitle": {"translations": {"en": "Quality construction and renovation in {{city}}", "fr": "Construction et rénovation de qualité à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
{"text": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
{"text": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
]
},
"features": {
@@ -33,7 +33,7 @@
"enabled": true,
"title": {"translations": {"en": "Ready to start your project?", "fr": "Prêt à démarrer votre projet ?"}},
"buttons": [
{"label": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
]
}
}

View File

@@ -11,7 +11,7 @@
"subtitle": {"translations": {"en": "{{meta_description}}", "fr": "{{meta_description}}"}},
"background_type": "gradient",
"buttons": [
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
},
"features": {
@@ -28,7 +28,7 @@
"title": {"translations": {"en": "Ready to get started?", "fr": "Prêt à commencer ?"}},
"subtitle": {"translations": {"en": "Contact us today for a free consultation", "fr": "Contactez-nous pour une consultation gratuite"}},
"buttons": [
{"label": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
]
}
}

View File

@@ -10,8 +10,8 @@
"subtitle": {"translations": {"en": "Professional expertise you can trust", "fr": "Une expertise professionnelle de confiance"}},
"background_type": "gradient",
"buttons": [
{"label": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
{"text": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
{"text": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
]
},
"features": {
@@ -27,7 +27,7 @@
"enabled": true,
"title": {"translations": {"en": "Need professional guidance?", "fr": "Besoin d'un accompagnement professionnel ?"}},
"buttons": [
{"label": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
]
}
}

View File

@@ -10,8 +10,8 @@
"subtitle": {"translations": {"en": "A culinary experience in {{city}}", "fr": "Une expérience culinaire à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Reserve a Table", "fr": "Réserver une table"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "See Our Menu", "fr": "Voir la carte"}}, "url": "/menu", "style": "secondary"}
{"text": {"translations": {"en": "Reserve a Table", "fr": "Réserver une table"}}, "url": "/contact", "style": "primary"},
{"text": {"translations": {"en": "See Our Menu", "fr": "Voir la carte"}}, "url": "/menu", "style": "secondary"}
]
},
"features": {
@@ -32,7 +32,7 @@
"enabled": true,
"title": {"translations": {"en": "Ready to dine with us?", "fr": "Prêt à nous rendre visite ?"}},
"buttons": [
{"label": {"translations": {"en": "Make a Reservation", "fr": "Réserver"}}, "url": "/contact", "style": "primary"}
{"text": {"translations": {"en": "Make a Reservation", "fr": "Réserver"}}, "url": "/contact", "style": "primary"}
]
}
}

View File

@@ -220,6 +220,32 @@
<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>
<script>
// Propagate preview token to all internal links (runs repeatedly to catch Alpine-rendered content)
(function() {
var params = new URLSearchParams(window.location.search);
var token = params.get('_preview');
if (!token) return;
var baseUrl = '{{ base_url | default("/") }}'.replace(/\/$/, '');
function fixLinks() {
document.querySelectorAll('a[href]').forEach(function(a) {
if (a.dataset.previewFixed) return;
var href = a.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
// Short paths like /contact from CMS sections → prepend base_url
if (href.match(/^\/[a-z]/) && !href.startsWith('/platforms/') && !href.startsWith('/storefront/') && !href.startsWith('/api/') && !href.startsWith('/admin/') && !href.startsWith('/store/')) {
href = baseUrl + href;
}
var url = new URL(href, window.location.origin);
url.searchParams.set('_preview', token);
a.setAttribute('href', url.pathname + url.search);
a.dataset.previewFixed = '1';
});
}
document.addEventListener('DOMContentLoaded', fixLinks);
setInterval(fixLinks, 1000);
})();
</script>
{% endif %}
{# Mobile menu panel #}

View File

@@ -0,0 +1,143 @@
# POC Content Mapping — Scraped Content → Template Sections
## Problem
The POC builder creates pages from industry templates but the scraped content from the prospect's site doesn't appear meaningfully. The homepage shows generic template text ("Quality construction and renovation") instead of the prospect's actual content ("Depuis trois générations, nous mettons notre savoir-faire au service de la qualité...").
For batirenovation-strasbourg.fr:
- **Scraped:** 30 headings, 21 paragraphs, 13 images, 3 contacts
- **Shows on POC:** only business name, phone, email, address via placeholders
- **Missing:** all the prose content, service descriptions, company history, project descriptions
## Current Flow
```
Scraped content → context dict → {{placeholder}} replacement → CMS pages
```
Placeholders are limited to simple fields: `{{business_name}}`, `{{phone}}`, `{{email}}`, `{{address}}`, `{{meta_description}}`, `{{about_paragraph}}`.
The rich content (paragraphs, headings, images) is stored in `prospect.scraped_content_json` but never mapped into the template sections.
## Desired Flow
```
Scraped content → intelligent mapping → template sections populated with real content
```
### Without AI (Phase 1 — programmatic mapping)
Map scraped content to template sections by position and keyword matching:
| Template Section | Scraped Source | Logic |
|---|---|---|
| Hero title | `headings[0]` | First heading = main title |
| Hero subtitle | `headings[1]` or `meta_description` | Second heading or meta desc |
| Hero background | `images[0]` | First large image |
| Features items | `headings` containing service keywords | Match headings to service names, use following paragraph as description |
| About content | `paragraphs[0:3]` | First 3 paragraphs = company story |
| Services content | `paragraphs` matching service keywords | Group paragraphs by service |
| Projects/Gallery | `images[1:8]` | Scraped images as gallery |
| Contact details | contacts (email, phone, address) | Already working |
| Social links | `social_links` from scrape | Footer social icons |
### With AI (Phase 2 — Workstream 4)
Send scraped content + template structure to LLM with prompt:
```
Given this scraped content from a construction company website and this
template structure, generate professional marketing copy for each section.
Rewrite and enhance the original text, keeping the facts but improving
tone and clarity. Output JSON matching the template section format.
```
AI would:
1. Extract the company's key selling points from paragraphs
2. Write a compelling hero tagline
3. Generate professional service descriptions from raw text
4. Create an about section from company history paragraphs
5. Translate to multiple languages
6. Generate missing content (testimonial placeholders, CTA copy)
## Plan — Phase 1 (Programmatic, no AI)
### Changes to `poc_builder_service.py`
#### 1. Enhanced `_build_context()` — extract more from scraped content
```python
context["hero_subtitle"] = scraped["headings"][1] if len(headings) > 1 else ""
context["hero_image"] = scraped["images"][0] if scraped.get("images") else None
context["about_paragraphs"] = scraped["paragraphs"][:3]
context["all_paragraphs_html"] = "\n".join(f"<p>{p}</p>" for p in scraped["paragraphs"][:8])
context["gallery_images"] = scraped["images"][:8]
context["social_links"] = scraped.get("social_links", {})
```
#### 2. New `_enrich_homepage_sections()` — inject scraped data into sections JSON
After placeholder replacement, before saving to DB:
```python
def _enrich_homepage_sections(self, sections: dict, context: dict) -> dict:
# Hero: use scraped subtitle and image
if sections.get("hero") and context.get("hero_subtitle"):
hero = sections["hero"]
for lang in (hero.get("subtitle", {}).get("translations", {}) or {}):
hero["subtitle"]["translations"][lang] = context["hero_subtitle"]
if context.get("hero_image"):
hero["background_image"] = context["hero_image"]
# Add gallery section from scraped images
if context.get("gallery_images") and len(context["gallery_images"]) > 2:
sections["gallery"] = {
"enabled": True,
"title": {"translations": {"en": "Our Work", "fr": "Nos Réalisations"}},
"images": [{"src": img, "alt": ""} for img in context["gallery_images"]],
}
return sections
```
#### 3. Enrich subpages with scraped paragraphs
Already partially done (appending scraped_paragraphs_html to about/services/projects). Improve by:
- Using scraped headings as section titles when they match service keywords
- Distributing paragraphs across pages based on keyword proximity
- Adding scraped images inline in content pages
### Changes to templates
#### Hero section: support `background_image` field
```html
{% if hero.background_image %}
<section style="background-image: url('{{ hero.background_image }}'); background-size: cover;">
{% else %}
<section class="gradient-primary">
{% endif %}
```
#### Add gallery rendering to landing-full.html
Already supported via `_gallery.html` macro — just need the section data populated.
### Changes to storefront base template
#### Social links in footer
When `request.state.is_preview` is True, render social links from the store's CMS data or from the prospect's scraped social links.
## Files to modify
| File | Change |
|---|---|
| `hosting/services/poc_builder_service.py` | Enhanced `_build_context()`, new `_enrich_homepage_sections()`, better content distribution |
| `cms/platform/sections/_hero.html` | Support `background_image` field for scraped hero images |
| `cms/storefront/landing-full.html` | Ensure gallery section renders |
| Template JSON files | Add `{{hero_subtitle}}`, `{{hero_image}}` placeholders |
## Estimated effort
- Phase 1 (programmatic): ~2-3 hours
- Phase 2 (AI): depends on provider integration (deferred)