Skip to main content

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/https URLs that parse via new URL().
  • Provider allow-list by exact host or dotted subdomain match (so evilyoutube.com and youtube.com.evil.com both fail):
    • YouTube: youtube.com, *.youtube.com, youtube-nocookie.com, *.youtube-nocookie.com, youtu.be — accepts /watch?v=, /embed/, /shorts/, /v/, and youtu.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}$.
  • Returns { provider, id, embedUrl, thumbnailUrl, watchUrl } | null.
    • embedUrl is derived: https://www.youtube-nocookie.com/embed/<id> (privacy domain) or https://player.vimeo.com/video/<id>.
    • thumbnailUrl is derived: https://i.ytimg.com/vi/<id>/hqdefault.jpg for YouTube; null for 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, showOnHomepage is false, or every URL fails to parse → render nothing (no empty section, no heading).
  • grid layout: responsive thumbnail grid (1 / 2 / 3 columns). Each card = derived thumbnail + play button overlay + optional title/description.
  • scroll layout: horizontal, snap-scroll carousel of the same cards (the bxSlider analog), keyboard-accessible, on a grey-ish band consistent with the RE design system (theme linen/cream, not a hardcoded #e8e8e8).
  • Lazy play: click a card → the derived nocookie/Vimeo iframe replaces the thumbnail in that card only. prefers-reduced-motion respected 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 videos section to the DRAFT via PUT /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/app surface, so the account_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

  1. Parser purity + safety (unit-tested): parseVideoUrl is pure and covered by video-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; and javascript:, 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 return null.
  2. No raw HTML: no dangerouslySetInnerHTML of user input anywhere in the video path; the iframe src is only ever a derived embedUrl.
  3. Lazy iframe: no <iframe> is in the DOM until the visitor clicks a card (verify at ≥2 timepoints: absent before click, present after).
  4. Empty state: a tenant with no videos (or showOnHomepage:false) renders no video section at all on /s/[slug].
  5. Both layouts render: grid shows a responsive thumbnail grid; scroll shows a horizontal snap carousel.
  6. 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).
  7. Server drops bad URLs: a PUT with a hostile/garbage URL in items[].url persists an empty/filtered list (server-side parseVideoUrl gate).
  8. No regressions: other sections and verticals unaffected (videos is 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 the src host is www.youtube-nocookie.com / player.vimeo.com.
  • node --import tsx --test src/lib/real-estate/__tests__/video-url.test.ts passes (the safety core).
  • Host-aliased note: verify on the real Pro Website host, not a bare preview.