Acceptance Spec - Realtor Site-Level Video Module (MVP)
- Status: Proposed (MVP) — 2026-07-02
- Product: WiseAI Realtor System → Pro Website public site (
/s/[slug]) - Owner surface:
/business/[token]dashboard → Website editor (RealEstateWebsiteEditor) - Related:
real-estate-content-editor-mvp,cwa-pro-website,pro-website-multipage
1. Why (problem)
The Becketts' current SellingToolz site (teambeckett.ca) has a homepage "video
scroll": a carousel of ~9 curated YouTube videos (thumbnail + "Play Video"
button) on a grey band below the listings. Our RE Pro Website template has no
site-level video section — we only store a per-listing video_url. This is the
one clear feature gap vs their current site. This MVP closes it with a
security-first, owner-editable site-level video module.
2. Scope
In: a per-tenant videos section (list of video URLs + optional title /
description), rendered on the RE homepage in one of two layouts (grid |
scroll), owner-editable from the Website editor, published via the existing
draft/publish flow. YouTube + Vimeo only, via a strict allow-list parser.
Out (explicit non-goals): arbitrary embed HTML, non-YouTube/Vimeo providers,
per-video styling knobs (SellingToolz's format options), autoplay, playlists,
video upload/hosting, a dedicated /videos sub-page, analytics on plays.
3. Data shape
Videos are stored exactly like the other owner-editable sections (testimonials,
communities, partners): as a per-tenant override on
local_businesses.metadata.website = { draft, published } (a JSONB twin),
read-modify-write, sanitized server-side. No migration, no new columns, no
draft_* column constraints — the RE content editor has never used draft_*
columns (that is the CHURCH Pro Website model); RE uses the JSONB metadata twin.
See src/lib/real-estate/server/site-content.ts.
// client-safe types (src/lib/real-estate/types.ts)
interface REVideoItem {
url: string; // raw YouTube/Vimeo URL the owner pasted (the ONLY stored form)
title?: string; // optional caption
description?: string; // optional one-line blurb
}
interface REVideoSectionOverride {
items: REVideoItem[];
layout: 'grid' | 'scroll';
showOnHomepage: boolean;
heading?: string; // optional section heading override
}
// RESiteContentOverrides gains: videos?: REVideoSectionOverride
// RESiteContentSection gains: 'videos'
// RESiteConfig gains: videos?: REVideoSection (code default; empty/absent for real tenants)
Derived, never stored: the video id, embedUrl, and thumbnailUrl are
computed at render time from url by parseVideoUrl(). We NEVER store raw
embed HTML, an iframe, or a thumbnail URL supplied by the client.
4. URL security model (the load-bearing piece)
A pure, unit-tested parser parseVideoUrl(url) in
src/lib/real-estate/video-url.ts (100% client-safe, no deps):
- Accepts only
http/httpsURLs that parse vianew URL(). - Provider allow-list by exact host or dotted subdomain match (so
evilyoutube.comandyoutube.com.evil.comboth fail):- YouTube:
youtube.com,*.youtube.com,youtube-nocookie.com,*.youtube-nocookie.com,youtu.be— accepts/watch?v=,/embed/,/shorts/,/v/, andyoutu.be/<id>. Video id must match^[A-Za-z0-9_-]{11}$. - Vimeo:
vimeo.com,*.vimeo.com(incl.player.vimeo.com/video/<id>). Video id must match^\d{6,12}$.
- YouTube:
- Returns
{ provider, id, embedUrl, thumbnailUrl, watchUrl } | null.embedUrlis derived:https://www.youtube-nocookie.com/embed/<id>(privacy domain) orhttps://player.vimeo.com/video/<id>.thumbnailUrlis derived:https://i.ytimg.com/vi/<id>/hqdefault.jpgfor YouTube;nullfor Vimeo (no keyless thumbnail — the render shows a themed placeholder rather than depend on a third-party thumbnail host).
- Anything else →
null→ not stored, not rendered.
Render contract: the public <VideoSection> uses ONLY the derived
embedUrl in an iframe, ONLY the derived thumbnailUrl for the poster, and
NEVER dangerouslySetInnerHTML of any user input. The iframe is lazy — the
section renders the derived thumbnail + a play button, and the iframe is only
injected into the DOM on click. sandbox + referrerpolicy are set on the
iframe.
Server trust boundary: sanitizeVideos() in site-content.ts re-runs
parseVideoUrl() on every save and drops any item whose URL doesn't parse, so a
malformed/hostile URL can never be persisted even if the client is bypassed.
Title/description are HTML-stripped + length-capped like every other field.
5. Public render (states + layouts)
- Location: homepage, below the Featured Listings section (mirrors the Becketts' "video scroll below listings").
- Empty / none: if the tenant has no videos,
showOnHomepageis false, or every URL fails to parse → render nothing (no empty section, no heading). gridlayout: responsive thumbnail grid (1 / 2 / 3 columns). Each card = derived thumbnail + play button overlay + optional title/description.scrolllayout: horizontal, snap-scroll carousel of the same cards (the bxSlider analog), keyboard-accessible, on a grey-ish band consistent with the RE design system (themelinen/cream, not a hardcoded#e8e8e8).- Lazy play: click a card → the derived nocookie/Vimeo iframe replaces the
thumbnail in that card only.
prefers-reduced-motionrespected for any transition.
6. Editor (owner surface)
A "Videos" section added to the RealEstateWebsiteEditor sidebar (mirrors the Testimonials/Communities editors):
- Add / reorder (up/down) / remove a video by pasting a URL + optional title + optional description.
- A layout toggle (Grid / Scroll) and a "Show on homepage" checkbox.
- Inline validation: on blur/add, a pasted URL that is not a supported
YouTube/Vimeo link shows an inline error (reusing
parseVideoUrl) and a live thumbnail preview when valid. - Saves the whole
videossection to the DRAFT viaPUT /api/real-estate/site-content { section: 'videos', value }; Publish flips draft → published (existing flow). Auth is the existing per-business Bearer token (this editor is the/business/[token]surface — NOT a/realtor/appsurface, so theaccount_id-in-query rule does not apply here; that rule is preserved for the realtor-app fetches, which this feature does not touch).
7. Acceptance criteria
- Parser purity + safety (unit-tested):
parseVideoUrlis pure and covered byvideo-url.test.ts. Tests prove: valid youtube.com/watch, youtu.be, /embed/, /shorts/, and vimeo.com/all parse to the correct derived nocookie/Vimeo embedUrl; andjavascript:,data:, protocol-relative//youtube.com/..., arbitrary hosts, look-alike hosts (evilyoutube.com,youtube.com.evil.com), bad-length ids, non-string, and empty input ALL returnnull. - No raw HTML: no
dangerouslySetInnerHTMLof user input anywhere in the video path; the iframesrcis only ever a derivedembedUrl. - Lazy iframe: no
<iframe>is in the DOM until the visitor clicks a card (verify at ≥2 timepoints: absent before click, present after). - Empty state: a tenant with no videos (or
showOnHomepage:false) renders no video section at all on/s/[slug]. - Both layouts render:
gridshows a responsive thumbnail grid;scrollshows a horizontal snap carousel. - Round-trip: add a video in the editor → Save draft → it appears in the
?draft=<token>preview → Publish → it appears on the public site. A non-YouTube/Vimeo URL is rejected inline and never persisted (server drops it even if forced). - Server drops bad URLs: a
PUTwith a hostile/garbage URL initems[].urlpersists an empty/filtered list (server-sideparseVideoUrlgate). - No regressions: other sections and verticals unaffected (
videosis additive; absent config → no section).
8. Verification (deployed URL)
- Playwright on the deployed
/s/terry-and-sheri(or the TeamMoelker test instance which is seeded with a sample video): assert the section renders, the iframe is absent pre-click and present post-click, and thesrchost iswww.youtube-nocookie.com/player.vimeo.com. node --import tsx --test src/lib/real-estate/__tests__/video-url.test.tspasses (the safety core).- Host-aliased note: verify on the real Pro Website host, not a bare preview.