This commit is contained in:
Andras Schmelczer 2026-06-22 22:04:20 +01:00
parent 6c6780fc60
commit f7e0814a38
8 changed files with 206 additions and 92 deletions

View file

@ -148,7 +148,12 @@ async function suppressDevServerNoise(context: BrowserContext) {
iframe[id*="webpack"],
[id*="webpack-dev-server-client"],
[class*="error-overlay"],
[class*="webpack-error"] {
[class*="webpack-error"],
/* Recording-only: hide dashboard chrome that reads as noise/loading
on camera (the "Finding the Perfect Postcode" AI-status CTA). The
data attribute is inert in production only this injected rule
targets it, and only during a recording. */
[data-video-hide] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;

View file

@ -146,12 +146,16 @@ export async function installCursor(
At the 540-wide CSS viewport this renders ~30px type 60px in the
published 1080-wide mp4: a proper social-video hook size. */
body.__demo-aspect-vertical #__demo-caption {
top: 8%;
top: 14%;
max-width: 88vw;
font-size: 30px;
font-weight: 850;
padding: 12px 18px;
border-radius: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
#__demo-caption.visible {
opacity: 1;
@ -209,6 +213,13 @@ export async function installCursor(
margin-left: auto;
margin-right: auto;
}
#__demo-outro-subtitle {
font: 500 22px/1.34 "Inter", system-ui, sans-serif;
color: #cbd5e1;
max-width: 30ch;
margin: 2px auto 4px;
text-align: center;
}
#__demo-outro-url {
display: inline-flex;
align-items: center;
@ -228,6 +239,7 @@ export async function installCursor(
comfortably inside the platform-safe centre column. */
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
body.__demo-aspect-vertical #__demo-outro-subtitle { font-size: 20px; max-width: 24ch; }
body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; }
.__ad-scene {
@ -816,10 +828,11 @@ export async function showOutro(
page: Page,
brand: string,
tagline: string,
url: string
url: string,
subtitle?: string
): Promise<void> {
await page.evaluate(
({ brand, tagline, url }) => {
({ brand, tagline, url, subtitle }) => {
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
@ -835,14 +848,22 @@ export async function showOutro(
urlEl.id = '__demo-outro-url';
// Drop the protocol so the CTA reads as a bare domain.
urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
// Optional spoken-outro line, burned in for muted social viewers.
if (subtitle && subtitle.length > 0) {
const subtitleEl = document.createElement('div');
subtitleEl.id = '__demo-outro-subtitle';
subtitleEl.textContent = subtitle;
card.append(brandEl, taglineEl, subtitleEl, urlEl);
} else {
card.append(brandEl, taglineEl, urlEl);
}
el.appendChild(card);
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('visible'));
});
},
{ brand, tagline, url }
{ brand, tagline, url, subtitle }
);
}

View file

@ -35,3 +35,38 @@ class NarrationLog {
}
export const narrationLog = new NarrationLog();
function formatVttTimestamp(ms: number): string {
const clamped = Math.max(0, Math.round(ms));
const totalSeconds = Math.floor(clamped / 1000);
const millis = clamped % 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n: number, width: number): string =>
n.toString().padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
}
/**
* Render narration cues as a WebVTT document.
*
* Pure helper: takes cues (already ordered by the caller) and produces a
* caption sidecar string. Cue text is trimmed and any internal blank lines
* are collapsed so a single cue stays a single VTT block.
*/
export function cuesToVtt(cues: NarrationCue[]): string {
let out = 'WEBVTT\n\n';
for (const cue of cues) {
const start = formatVttTimestamp(cue.videoTimeMs);
const end = formatVttTimestamp(cue.videoTimeMs + cue.durationMs);
const text = cue.text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n')
.trim();
out += `${start} --> ${end}\n${text}\n\n`;
}
return out;
}

View file

@ -1,8 +1,8 @@
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { narrationLog } from './narration.js';
import { cuesToVtt, narrationLog } from './narration.js';
import { installDemoRoutes } from './routes.js';
import type { Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
@ -105,6 +105,11 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
console.log(
`[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}`
);
const vttPath = join(dir, 'narration.vtt');
writeFileSync(vttPath, cuesToVtt(cues));
console.log(
`[${storyboard.name}] wrote ${cues.length} WebVTT captions → ${vttPath}`
);
}
async function main(): Promise<void> {

View file

@ -38,7 +38,7 @@ async function stubAiFilters(page: Page, storyboard: Storyboard) {
filters: storyboard.content.stubbedFilters,
travel_time_filters: storyboard.content.stubbedTravelTimeFilters,
notes: '',
match_count: 1247,
match_count: storyboard.content.matchCount ?? 1247,
}),
});
});

View file

@ -379,7 +379,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
return;
}
case 'showOutro':
await showOutro(ctx.page, step.brand, step.tagline, step.url);
await showOutro(ctx.page, step.brand, step.tagline, step.url, step.subtitle);
return;
case 'showAdScene':
await showAdScene(ctx.page, step.scene);

View file

@ -172,7 +172,7 @@ export type Activity =
timeoutMs?: number;
}
/** Reveal the closing brand card. */
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
| { kind: 'showOutro'; brand: string; tagline: string; url: string; subtitle?: string; durationMs: number }
/** Reveal a full-screen ad-style overlay over the live map. */
| { kind: 'showAdScene'; scene: AdScene; durationMs: number }
/** Fade the ad overlay away. */
@ -318,6 +318,13 @@ export interface ContentConfig {
travelTimeDragFromMin: number;
travelTimeDragToMin: number;
brand: BrandConfig;
/**
* Stubbed "{{count}} properties match" total shown in the AI summary.
* Set PER storyboard so different searches/cities don't all display the
* same number (an identical count across cuts reads as fake). Falls back
* to a default in routes.ts when omitted.
*/
matchCount?: number;
}
export interface TravelTimeFilter {

View file

@ -148,21 +148,21 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
colourMapTitle: 'Colour map',
brand: {
name: 'Perfect Postcode',
tagline: 'Find the area before the house.',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
},
cues: {
hook: "You can't view your way to the right area.",
hook: "A postcode's reputation is priced in. Its value isn't.",
brief:
'So start with the brief. Price, commute, schools, even how quiet the street is.',
search: 'One search, and England shrinks to the postcodes that fit.',
commute: 'Push the commute down, and the map answers in seconds.',
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
search: 'One brief, and England narrows to the postcodes worth your money.',
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
streets: 'Down at street level, the strongest streets start to stand out.',
open:
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
shortlist:
'Shortlist the winners, export them, and only spend Saturdays where it counts.',
outro: 'Stop guessing postcodes. Start choosing them.',
'Keep the best-value few, export them, and scout where it actually counts.',
outro: 'Stop paying for the name. Find the value.',
},
},
de: {
@ -183,21 +183,22 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
colourMapTitle: 'Karte einfärben',
brand: {
name: 'Perfect Postcode',
tagline: 'Erst die Gegend, dann das Haus.',
tagline: 'Finde die unterschätzte Postleitzahl.',
url: BRAND_URL,
},
cues: {
hook: 'Das richtige Viertel findest du nicht durch Besichtigungen.',
hook: 'Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.',
brief:
'Fang mit dem Briefing an. Preis, Pendelzeit, Schulen, sogar wie ruhig die Straße ist.',
search: 'Eine Suche, und England schrumpft auf die Postleitzahlen, die passen.',
commute: 'Verkürze den Arbeitsweg, und die Karte antwortet in Sekunden.',
'Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.',
search:
'Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.',
commute: 'Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.',
streets: 'Auf Straßenebene stechen die besten Ecken von selbst hervor.',
open:
'Öffne eine, und sie zeigt ihre Daten. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.',
'Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.',
shortlist:
'Speichere die Favoriten, exportiere sie, und verbringe deine Samstage nur dort, wo es zählt.',
outro: 'Hör auf, Postleitzahlen zu raten. Fang an, sie zu wählen.',
'Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.',
outro: 'Zahl nicht für den Namen. Finde den Wert.',
},
},
zh: {
@ -216,18 +217,18 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
colourMapTitle: '地图着色',
brand: {
name: 'Perfect Postcode',
tagline: '先选对街区,再选房子。',
tagline: '发现那个被低估的邮区。',
url: BRAND_URL,
},
cues: {
hook: '光靠看房,看不出哪个街区适合你。',
brief: '先列条件。预算、通勤、学校,甚至街道安不安静。',
search: '搜索一次,全英格兰只剩下符合条件的邮编。',
commute: '把通勤时间调短,地图几秒内就给出答案。',
hook: '一个邮区的名气,早就算进了房价里。它真正的价值,却没有。',
brief: '所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。',
search: '一份需求,整个英格兰就缩小到那些真正值这个价的邮区。',
commute: '把通勤再收紧一点,几秒钟,留下的好选择又少了一批。',
streets: '放大到街道层面,好街区自己浮现出来。',
open: '点开一个,它会摆出证据。成交价、学校、犯罪率、噪音、宽带。',
shortlist: '把心仪的区域导出,周末只去值得去的地方。',
outro: '别再猜邮编,开始挑邮编。',
open: '点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。',
shortlist: '留下最划算的那几个,导出来,去真正值得的地方实地看房。',
outro: '别再为名气买单。找到真正的价值。',
},
},
hi: {
@ -247,21 +248,21 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
colourMapTitle: 'मानचित्र रंगें',
brand: {
name: 'Perfect Postcode',
tagline: 'Find the area before the house.',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
},
cues: {
hook: "You can't view your way to the right area.",
hook: "A postcode's reputation is priced in. Its value isn't.",
brief:
'So start with the brief. Price, commute, schools, even how quiet the street is.',
search: 'One search, and England shrinks to the postcodes that fit.',
commute: 'Push the commute down, and the map answers in seconds.',
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
search: 'One brief, and England narrows to the postcodes worth your money.',
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
streets: 'Down at street level, the strongest streets start to stand out.',
open:
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
shortlist:
'Shortlist the winners, export them, and only spend Saturdays where it counts.',
outro: 'Stop guessing postcodes. Start choosing them.',
'Keep the best-value few, export them, and scout where it actually counts.',
outro: 'Stop paying for the name. Find the value.',
},
},
};
@ -432,8 +433,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
{
kind: 'mapZoom',
target: mapFocus,
steps: 6,
durationMs: 1800,
steps: 5,
durationMs: 1300,
direction: 'out',
},
]
@ -468,17 +469,19 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
},
{
text: copy.cues.outro,
gapBeforeMs: 450,
gapBeforeMs: 350,
during: [
{
kind: 'showOutro',
brand: copy.brand.name,
tagline: copy.brand.tagline,
url: copy.brand.url,
// Burn the spoken closer for sound-off / accessibility.
subtitle: copy.cues.outro,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1300 }],
tail: [{ kind: 'wait', durationMs: 1700 }],
},
];
}
@ -523,8 +526,10 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
minDurationS: 10,
maxDurationS: 75,
// Right-pane inspection: London map, filters applied, data pane
// open — the clearest paused-state preview.
posterTimeS: 31,
// FULLY populated (Street View + price history). 31s caught the pane
// mid-load on prod (empty white + spinner); 35s lands a few seconds into
// the "open" cue once the evidence has rendered — the strongest preview.
posterTimeS: 35,
};
}
@ -537,11 +542,13 @@ function createRecordingStoryboard(
formFactor: FormFactor
): Storyboard {
const copy = RECORDING_LOCALIZATIONS[locale];
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
// viewport still shows an inner-London slice densely populated with
// hexagons (otherwise the visible map gets dominated by the low-density
// outer edges with no matches).
const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
// Both form factors open at zoom 12 so the cold-open frame lands on a
// densely-populated inner-London slice rather than the low-density outer
// edges. Desktop was 11.5, but the opening lean-in panned the visible map
// toward the sparse NW suburbs (Wembley/Ealing) — a soft first frame for
// the hero. 12 keeps central London (rich default-density colouring) in
// shot from t=0.
const initialZoom = 12;
// On mobile the MobileBottomSheet covers the bottom ~44% of the
// viewport, so the map's geographic centre sits roughly at the seam
@ -609,6 +616,9 @@ function createRecordingStoryboard(
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: copy.brand,
// A tight 7-filter inner-London search; a plausible total (not the
// shared stub default) so the on-screen count reads as real.
matchCount: 1843,
},
pre: buildPre(),
cues: createCues(locale, formFactor),
@ -665,6 +675,8 @@ interface DemoAdStoryboardConfig {
travelTimeFilters?: TravelTimeFilter[];
posterTimeS?: number;
initialZoom?: number;
/** Plausible per-city "{{count}} properties match" total for the AI summary. */
matchCount?: number;
/** Activities to run before the cue loop starts (silent — keep short). */
prePrime?: Activity[];
/** Spoken line during the outro card. Must NOT repeat the card's text. */
@ -688,7 +700,7 @@ const AD_VIDEO: VideoConfig = {
const AD_BRAND = {
name: 'Perfect Postcode',
tagline: 'Search the area before you search the listings.',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
};
@ -825,6 +837,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: AD_BRAND,
matchCount: ad.matchCount,
},
pre: ad.prePrime ?? AD_PRE,
cues: [
@ -844,10 +857,14 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
brand: AD_BRAND.name,
tagline: AD_BRAND.tagline,
url: AD_BRAND.url,
// Burn the spoken closer onto the card so muted social viewers
// (the ads' real channel) still read the CTA.
subtitle: ad.outroLine,
durationMs: 0,
},
],
tail: [wait(900)],
// Hold the brand card long enough to read the URL + closer with sound off.
tail: [wait(2200)],
},
],
};
@ -865,6 +882,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-01-say-it',
matchCount: 2410,
city: 'london',
promptText: 'Flat under £600k, 35 min to central London, low crime, quiet street',
filters: {
@ -878,23 +896,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
],
initialZoom: 10.2,
posterTimeS: 8,
outroLine: 'Your area is on this map. Go find it.',
outroLine: 'The underpriced twin is on this map. Find it.',
cues: [
{
text: 'This is my whole house brief. One sentence.',
caption: 'One sentence. Every postcode.',
text: 'My whole house brief is one plain sentence.',
caption: "Reputation is priced in. Value isn't.",
during: [
typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600),
],
tail: [wait(150)],
},
{
text: "And that's every postcode in England that fits it.",
text: 'Every postcode in England that fits, sorted by value.',
caption: 'All of England, by value',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Now I scroll a map, not nine hundred listings.',
text: 'Same schools, same commute, the price quietly drops nearby.',
caption: 'The cheaper twin nearby',
during: [mapZoomIn(1400, 4)],
tail: [wait(400)],
},
@ -907,6 +927,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-02-twenty-minute-map',
matchCount: 38600,
city: 'london',
promptText: 'Within an hour of central London',
filters: {},
@ -915,7 +936,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
],
initialZoom: 10.2,
posterTimeS: 9,
outroLine: 'Check the commute before you fall in love.',
outroLine: 'The commute is priced in. The bargain is not.',
prePrime: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
@ -932,17 +953,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
cues: [
{
text: 'Every colour on this map is a commute to central London.',
caption: 'The 20-minute map',
caption: 'Same commute, cheaper postcode',
during: [wait(400)],
tail: [wait(150)],
},
{
text: "Here's what twenty minutes actually leaves you.",
caption: 'What 20 minutes buys',
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)],
tail: [touchHide(), sheetDown(800), wait(300)],
},
{
text: "If it's not lit, you'd live on the train. Now you know before you view.",
text: "Same twenty minutes wherever it's lit, but not the same price.",
caption: 'Same ride, lower price',
during: [mapZoomIn(1300, 3)],
tail: [wait(400)],
},
@ -955,6 +978,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-03-postcode-files',
matchCount: 1290,
city: 'london',
initialFilters: {
'Estimated current price': [0, 700000],
@ -962,7 +986,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
initialZoom: 10.6,
posterTimeS: 11,
outroLine: "Read the area's file before you book the viewing.",
outroLine: 'Every postcode, proven on the numbers, not its reputation.',
prePrime: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
@ -971,8 +995,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
],
cues: [
{
text: 'Estate agents say, up and coming. The data is more specific.',
caption: 'Every postcode has a file',
text: 'One name costs more. The block next door scores the same.',
caption: 'See the proof, not the postcode',
during: [
{
// Zoom around the strongest visible match so the destination
@ -991,11 +1015,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
{
text: 'Sold prices, schools, crime, even Street View, for this exact postcode.',
caption: 'The whole postcode file',
during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)],
tail: [wait(200)],
},
{
text: 'Thirty seconds here saves you a Saturday on the wrong street.',
text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.',
caption: 'Same proof, less money',
during: [scrollDrawer(820, 900), touchHide()],
tail: [wait(350)],
},
@ -1007,6 +1033,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-04-quiet-streets',
matchCount: 6420,
city: 'london',
promptText: 'Quiet London street under 55 decibels, low crime',
filters: {
@ -1015,21 +1042,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
initialZoom: 10.6,
posterTimeS: 7,
outroLine: 'Quiet is searchable now.',
outroLine: 'The quiet street nobody bid up.',
cues: [
{
text: 'Listing photos are silent. Main roads are not.',
caption: "You can't hear a photo",
caption: "The quiet that's overlooked",
during: [typeAct('Quiet London street under 55 decibels, low crime', 2400)],
tail: [wait(150)],
},
{
text: 'This is London under fifty-five decibels.',
caption: 'Under 55 decibels',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: "The quiet streets were always there. Now they're searchable.",
text: 'Nearby, just as quiet, and overlooked.',
caption: 'Quiet, and overlooked',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
@ -1041,6 +1070,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-05-school-run',
matchCount: 3380,
city: 'leeds',
promptText: 'Family home in Leeds under £350k, good primary schools, low crime',
filters: {
@ -1049,24 +1079,26 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
'Serious crime (avg/yr)': [0, 30],
},
initialZoom: 11.0,
posterTimeS: 7,
outroLine: 'Pick the area first. Then the house.',
posterTimeS: 13,
outroLine: 'The schools are real. The premium is optional.',
cues: [
{
text: 'Good primary school, low crime, under three hundred and fifty. The actual family brief.',
caption: 'The school-run map',
text: 'Good primary, low crime, under three hundred and fifty. The actual family brief.',
caption: 'Catchment, minus the premium',
during: [
typeAct('Family home in Leeds under £350k, good primary schools, low crime', 2800),
],
tail: [wait(150)],
},
{
text: 'Everything still on the map passes all three.',
text: 'Everything still on the map passes all three filters.',
caption: 'Passes all three',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'No more falling for a house, then googling the catchment at midnight.',
text: 'Same primary, same low crime, without the name everyone else is bidding up.',
caption: 'Same catchment, less money',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
@ -1079,6 +1111,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-06-waitrose-test',
matchCount: 880,
city: 'london',
promptText: 'Walking distance to a Waitrose, a tube station and a park',
filters: {
@ -1088,21 +1121,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
initialZoom: 10.4,
posterTimeS: 7,
outroLine: 'Filter for the life, not just the floor plan.',
outroLine: 'Same Waitrose. Same tube. Cheaper postcode.',
cues: [
{
text: "Near a Waitrose, a tube stop and a park. Yes, that's a real search.",
text: 'A Waitrose, a tube stop, a park nearby. Search by what you want.',
caption: 'The Waitrose test',
during: [typeAct('Walking distance to a Waitrose, a tube station and a park', 2800)],
tail: [wait(150)],
},
{
text: 'London, cut down to the postcodes that live the way you do.',
text: 'London, narrowed to the postcodes that fit the way you live.',
caption: 'Fits how you live',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Snobby? Maybe. Efficient? Extremely.',
text: 'Same amenities, lower price nearby.',
caption: 'Same life, lower price',
during: [mapZoomIn(1400, 4)],
tail: [wait(400)],
},
@ -1114,6 +1149,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
{
name: 'ad-07-renters-map',
matchCount: 2160,
city: 'london',
promptText: 'Rent under £1,600, 30 min to central London, quiet street',
filters: {
@ -1124,22 +1160,24 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 30 },
],
initialZoom: 10.3,
posterTimeS: 7,
outroLine: 'Know the postcode before the viewing queue.',
posterTimeS: 13,
outroLine: 'The name costs more. The street does not.',
cues: [
{
text: 'Rent under sixteen hundred, half an hour to work, on a quiet street.',
caption: 'Renters get a map too',
text: 'Rent under sixteen hundred, half an hour to central, on a quiet street.',
caption: 'Stop renting the reputation',
during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)],
tail: [wait(150)],
},
{
text: 'Every letting site shows you flats. This shows you areas.',
text: 'Letting sites rank flats. This ranks the streets around them.',
caption: 'Areas, not just flats',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Turn up to the right viewings, and skip the rest.',
text: 'Same commute, same quiet, lower rent nearby.',
caption: 'Same street, lower rent',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
@ -1147,10 +1185,11 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 08 — Value. £9.99 against the real cost of a wasted viewing.
// 08 — Value. The cost of overpaying for a name vs the one-off fee.
// -------------------------------------------------------------------
{
name: 'ad-08-cheap-insurance',
name: 'ad-08-value',
matchCount: 1540,
city: 'london',
promptText: 'Under £500k, 35 min to central London, low crime, good schools',
filters: {
@ -1163,21 +1202,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
],
initialZoom: 10.4,
posterTimeS: 7,
outroLine: 'Skip the wrong viewings.',
outroLine: 'Buy value, not reputation.',
cues: [
{
text: 'A bad viewing costs a train ticket and half a weekend.',
caption: '£9.99 vs a wasted Saturday',
text: 'Two streets, same schools, same commute, priced tens of thousands apart.',
caption: 'Two streets. One is overpriced.',
during: [typeAct('Under £500k, 35 min to central London, low crime, good schools', 2600)],
tail: [wait(150)],
},
{
text: 'This costs nine ninety-nine, and tells you where not to go.',
text: "You pay for the postcode's name; the value sits a postcode away.",
caption: 'You pay for the name',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Cheap insurance, for the biggest purchase of your life.',
text: 'Pay once, find the bargain, stop overpaying for the name.',
caption: 'Find the value instead',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},