← back to Malden House
rental.js
801 lines
// rental.js — reads a saved rental from localStorage and patches rental.html
(function () {
// Schema check — wipe stale v1/v2 records on first load
try {
const HTG_SCHEMA = 3;
const v = Number(localStorage.getItem("htg.schema") || 1);
if (v !== HTG_SCHEMA) {
localStorage.removeItem("htg.rentals");
localStorage.removeItem("htg.active");
localStorage.removeItem("victorystays.rental");
localStorage.setItem("htg.schema", HTG_SCHEMA);
}
} catch (e) {}
function load() {
const params = new URLSearchParams(location.search);
const id = params.get("id") || localStorage.getItem("htg.active");
const all = JSON.parse(localStorage.getItem("htg.rentals") || "{}");
if (id && all[id]) return all[id];
// Back-compat with old single-record key
const legacy = localStorage.getItem("victorystays.rental");
if (legacy) return JSON.parse(legacy);
return null;
}
function splitStreet(street) {
// "471 S Peck Dr" -> { number: "471", rest: "S PECK DR" }
const m = String(street || "").match(/^\s*(\d+[\w-]*)\s+(.+)$/);
if (!m) return { number: "—", rest: (street || "").toUpperCase() };
return { number: m[1], rest: m[2].toUpperCase() };
}
function text(sel, val) { const el = document.querySelector(sel); if (el) el.textContent = val; }
function html(sel, val) { const el = document.querySelector(sel); if (el) el.innerHTML = val; }
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
const escapeAttr = escapeHtml;
function formatMoney(v) {
return v === null || v === undefined || v === "" ? "—" : escapeHtml(v.toLocaleString());
}
// Expand a date-range string like "Jul 15 – Jul 23, 2028" (or single "Jul 14, 2028")
// into an array of { mm, dd, year, dow } entries.
function expandDates(str) {
const monthMap = { Jan:0, Feb:1, Mar:2, Apr:3, May:4, Jun:5, Jul:6, Aug:7, Sep:8, Oct:9, Nov:10, Dec:11 };
const mNames = Object.keys(monthMap);
const clean = String(str || "").replace(/[–—]/g, "-").trim();
// Range with same or different months: "Jul 15 - Jul 23, 2028"
let m = clean.match(/^(\w{3})\s+(\d{1,2})\s*-\s*(\w{3})\s+(\d{1,2}),\s*(\d{4})$/);
if (m) {
const [, m1, d1, m2, d2, y] = m;
const start = new Date(+y, monthMap[m1], +d1);
const end = new Date(+y, monthMap[m2], +d2);
const out = [];
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
out.push({ mm: mNames[d.getMonth()], dd: d.getDate(), year: d.getFullYear(), dow: d.getDay() });
}
return out;
}
// Single-day: "Jul 14, 2028"
m = clean.match(/^(\w{3})\s+(\d{1,2}),\s*(\d{4})$/);
if (m) {
const d = new Date(+m[3], monthMap[m[1]], +m[2]);
return [{ mm: m[1], dd: +m[2], year: +m[3], dow: d.getDay() }];
}
return [];
}
// -----------------------------------------------------------------
// SCRIPTED TRAILER — commercial-style 30-second rental promo
// -----------------------------------------------------------------
function buildTrailer(ctx) {
const { address, host, nearest, price, fullAddr } = ctx;
const player = document.querySelector(".player");
if (!player) return;
const svQ = encodeURIComponent(fullAddr);
const sat = `https://maps.google.com/maps?q=${svQ}&t=k&z=19&output=embed`;
const sv = `https://maps.google.com/maps?q=${svQ}&layer=c&output=svembed`;
const c0 = nearest[0] || {};
const days = expandDates(c0.dates || "Jul 14 – Jul 30, 2028");
const dow = ["SUN","MON","TUE","WED","THU","FRI","SAT"];
const daysHTML = days.slice(0, 12).map(d => `
<span class="tr-day"><em>${dow[d.dow]}</em><b>${d.dd}</b><i>${d.mm.toUpperCase()}</i></span>
`).join("");
const scenes = [
{ dur: 4000, id: "intro", html: `
<div class="tr-intro">
<div class="tr-kicker">✦ A PRIVATE HOME FOR THE GAMES</div>
<div class="tr-title-big">${escapeHtml(address.street || "Your House")}</div>
<div class="tr-title-sub">${escapeHtml((address.city || "Los Angeles").toUpperCase())} · CA</div>
<div class="tr-intro-tag">HOSTED BY ${escapeHtml((host || "").toUpperCase())}</div>
</div>` },
{ dur: 5000, id: "satellite", html: `
<iframe class="tr-frame" src="${sat}" loading="lazy" title="Aerial"></iframe>
<div class="tr-vignette"></div>
<div class="tr-lower-third">
<div class="tr-eyebrow">AERIAL · ${escapeHtml(address.zip || "")}</div>
<div class="tr-headline">${escapeHtml(address.street || "")}</div>
<div class="tr-sub">${escapeHtml(address.city || "")}, ${escapeHtml(address.state || "CA")}</div>
</div>` },
{ dur: 5000, id: "streetview", html: `
<iframe class="tr-frame" src="${sv}" loading="lazy" title="Street view"></iframe>
<div class="tr-vignette"></div>
<div class="tr-lower-third">
<div class="tr-eyebrow">THE FRONT · 360°</div>
<div class="tr-headline">Walk up to the door.</div>
<div class="tr-sub">Live from Google Street View</div>
</div>` },
{ dur: 4000, id: "photo1", html: `
<img class="tr-photo" src="https://picsum.photos/seed/${encodeURIComponent((address.street||'living')+'-living')}/1600/900" alt="Living">
<div class="tr-vignette"></div>
<div class="tr-lower-third">
<div class="tr-eyebrow">THE LIVING ROOM</div>
<div class="tr-headline">Room for six.</div>
<div class="tr-sub">Three bedrooms · two baths</div>
</div>` },
{ dur: 4000, id: "photo2", html: `
<img class="tr-photo" src="https://picsum.photos/seed/${encodeURIComponent((address.street||'kitchen')+'-kitchen')}/1600/900" alt="Kitchen">
<div class="tr-vignette"></div>
<div class="tr-lower-third">
<div class="tr-eyebrow">THE KITCHEN</div>
<div class="tr-headline">Dinner at home.</div>
<div class="tr-sub">Cook in, or Postmates a medal-winner meal</div>
</div>` },
{ dur: 5000, id: "olympic", html: `
<div class="tr-olympic">
<div class="tr-kicker">✦ CLOSEST GAMES EVENT</div>
<div class="tr-title-big">${escapeHtml(c0.headline || c0.sports || "LA 2028")}</div>
<div class="tr-olympic-venue">${escapeHtml(c0.name || "")} · ${(c0.mi || 0).toFixed(1)} mi from your door</div>
<div class="tr-daybar">${daysHTML}</div>
</div>` },
{ dur: 4000, id: "outro", html: `
<div class="tr-outro">
<div class="tr-kicker">✦ RESERVE</div>
<div class="tr-title-big">$${formatMoney(price && price.mid)}<span class="tr-unit">/ night</span></div>
<div class="tr-sub">${escapeHtml(c0.dates || "Jul 14 – Jul 30, 2028")}</div>
<div class="tr-cta-row">
<span class="tr-pill">victorystays.com/${(address.street || "").toLowerCase().replace(/[^a-z0-9]+/g, "-")}</span>
</div>
<div class="tr-outro-brand">VICTORY STAYS · LA 2028</div>
</div>` },
];
const total = scenes.reduce((a, s) => a + s.dur, 0);
player.innerHTML = `
<div class="tr-stage" id="tr-stage"></div>
<button class="tr-playbtn" id="tr-playbtn" aria-label="Play">
<span class="tr-playicon">▶</span>
<span class="tr-playlabel">PLAY 30-SEC TRAILER</span>
</button>
<div class="tr-cover" id="tr-cover">
<img src="${escapeAttr(sat.replace('&output=embed',''))}" alt="">
<div class="tr-cover-overlay"></div>
<div class="tr-cover-text">
<div class="tr-kicker">✦ PROPERTY TRAILER</div>
<div class="tr-title-big">${escapeHtml(address.street || "Your House")}</div>
<div class="tr-sub">${escapeHtml((address.city || "Los Angeles").toUpperCase())} · 30 SECONDS</div>
</div>
</div>
<div class="tr-hud"><span class="tr-live"><span class="dot"></span>LIVE TRAILER</span><span id="tr-music-toggle" class="tr-music">♪ MUSIC: ON</span></div>
<div class="tr-controls" id="tr-controls" hidden>
<div class="tr-scrub"><div class="tr-prog" id="tr-prog"></div></div>
<div class="tr-bottom">
<div class="tr-time"><button id="tr-pause">⏸</button><span id="tr-elapsed">0:00</span><span class="tr-dim"> / 0:${(total/1000).toFixed(0).padStart(2,"0")}</span></div>
<button class="tr-book" onclick="document.getElementById('contact').scrollIntoView({behavior:'smooth'})">BOOK THIS HOUSE</button>
</div>
</div>
`;
const stage = document.getElementById("tr-stage");
const cover = document.getElementById("tr-cover");
const playBtn = document.getElementById("tr-playbtn");
const controls = document.getElementById("tr-controls");
const progEl = document.getElementById("tr-prog");
const timeEl = document.getElementById("tr-elapsed");
const pauseBtn = document.getElementById("tr-pause");
const musicToggle = document.getElementById("tr-music-toggle");
let running = false, paused = false, audioCtl = null, rafId = null, startTs = 0, accumMs = 0, musicOn = true;
function startAudio() {
try {
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = new AC();
const master = ctx.createGain(); master.gain.value = 0; master.connect(ctx.destination);
master.gain.linearRampToValueAtTime(0.12, ctx.currentTime + 0.8);
// Cinematic ambient pad — two slow detuned sines + filtered noise "air"
function pad(freq, detune, gainVal) {
const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = freq; o.detune.value = detune;
const g = ctx.createGain(); g.gain.value = gainVal;
o.connect(g); g.connect(master); o.start();
return o;
}
const chord = [
pad(130.81, 0, 0.22), // C3
pad(196.00, -4, 0.18), // G3 slightly flat
pad(261.63, 3, 0.14), // C4
pad(329.63, -6, 0.10), // E4
];
// Slow LFO on filter for "breathing"
const filt = ctx.createBiquadFilter(); filt.type = "lowpass"; filt.frequency.value = 800;
// Noise for air
const bufSize = 2 * ctx.sampleRate;
const noise = ctx.createBufferSource();
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) d[i] = (Math.random() * 2 - 1) * 0.04;
noise.buffer = buf; noise.loop = true;
const ng = ctx.createGain(); ng.gain.value = 0.06;
noise.connect(filt); filt.connect(ng); ng.connect(master);
noise.start();
// Gentle LFO breathing on master gain
const lfo = ctx.createOscillator(); lfo.frequency.value = 0.15;
const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.03;
lfo.connect(lfoGain); lfoGain.connect(master.gain);
lfo.start();
audioCtl = { ctx, master, chord, noise, lfo };
} catch (e) { console.warn("audio:", e); }
}
function stopAudio() {
if (!audioCtl) return;
try {
const { ctx, master } = audioCtl;
master.gain.cancelScheduledValues(ctx.currentTime);
master.gain.linearRampToValueAtTime(0.0001, ctx.currentTime + 0.6);
setTimeout(() => { try { ctx.close(); } catch (e) {} }, 700);
} catch (e) {}
audioCtl = null;
}
function renderScene(i) {
stage.innerHTML = `<div class="tr-scene active" data-id="${scenes[i].id}">${scenes[i].html}</div>`;
}
function tick() {
if (!running || paused) return;
const elapsed = accumMs + (performance.now() - startTs);
if (elapsed >= total) { finish(); return; }
// Pick scene
let t = 0, idx = 0;
for (let i = 0; i < scenes.length; i++) {
if (elapsed < t + scenes[i].dur) { idx = i; break; }
t += scenes[i].dur;
}
if (stage.firstChild && stage.firstChild.getAttribute("data-id") !== scenes[idx].id) renderScene(idx);
progEl.style.width = (elapsed / total * 100) + "%";
const secs = Math.floor(elapsed / 1000);
timeEl.textContent = `0:${String(secs).padStart(2,"0")}`;
rafId = requestAnimationFrame(tick);
}
function play() {
if (running) return;
running = true; paused = false; accumMs = 0; startTs = performance.now();
cover.classList.add("hidden");
playBtn.classList.add("hidden");
controls.hidden = false;
renderScene(0);
if (musicOn) startAudio();
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!running || paused) { // resume if paused
if (paused) { paused = false; startTs = performance.now(); pauseBtn.textContent = "⏸"; if (musicOn && !audioCtl) startAudio(); rafId = requestAnimationFrame(tick); }
return;
}
paused = true; accumMs += performance.now() - startTs; pauseBtn.textContent = "▶"; stopAudio();
}
function finish() {
running = false; stopAudio();
renderScene(scenes.length - 1);
progEl.style.width = "100%";
timeEl.textContent = `0:${(total/1000).toFixed(0).padStart(2,"0")}`;
pauseBtn.textContent = "↺";
pauseBtn.onclick = () => { pauseBtn.onclick = null; play(); pauseBtn.onclick = () => pause(); pauseBtn.textContent = "⏸"; };
}
playBtn.addEventListener("click", play);
pauseBtn.addEventListener("click", pause);
musicToggle.addEventListener("click", () => {
musicOn = !musicOn;
musicToggle.textContent = musicOn ? "♪ MUSIC: ON" : "♪ MUSIC: OFF";
if (!musicOn) stopAudio(); else if (running && !paused) startAudio();
});
}
// Fetch the Wikipedia page summary thumbnail for a given page title. Returns null on miss.
async function wikiThumb(title) {
if (!title) return null;
try {
const r = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`, { headers: { "Accept": "application/json" } });
if (!r.ok) return null;
const j = await r.json();
return (j && j.thumbnail && j.thumbnail.source) || (j && j.originalimage && j.originalimage.source) || null;
} catch (e) { return null; }
}
function patch(r) {
const { address, host, email, phone, nearest, price, baseline } = r;
const split = splitStreet(address.street || "");
// Title & nav brand
document.title = `${address.street || "Your home"} — LA 2028 Rental`;
text(".brand-mark", (host.split(" ")[0] || "HOST").toUpperCase() + " HOUSE");
const sub = document.querySelector(".brand-sub");
if (sub) sub.textContent = `${address.city || "Los Angeles"} · LA 2028`;
// Hero lede — swap Northridge-specific line for the real nearest venue
const lede = document.querySelector(".hero-copy .lede");
if (lede) {
const n0 = nearest[0];
lede.textContent =
`A private home in ${address.city || "Los Angeles"} — ${n0.mi.toFixed(1)} miles from ${n0.name}, ` +
`where ${n0.sports} land July 14–30, 2028.`;
}
// Property card
const pcAddrB = document.querySelector(".pc-address b");
const pcAddrS = document.querySelector(".pc-address span");
if (pcAddrB) pcAddrB.textContent = split.number;
if (pcAddrS) pcAddrS.textContent = split.rest;
html(".pc-sub",
`${escapeHtml(address.city || "")}${address.state ? ", " + escapeHtml(address.state) : ""} ${escapeHtml(address.zip || "")}<br>${escapeHtml(address.county || "Los Angeles County")}`
);
// Photo plate — swap placeholder img for real Google satellite view of the house
const plateImg = document.querySelector(".plate-img img");
if (plateImg) {
const sat = document.createElement("iframe");
sat.src = `https://maps.google.com/maps?q=${address.lat},${address.lng}&t=k&z=19&output=embed`;
sat.loading = "lazy";
sat.style.cssText = "width:100%;height:600px;border:0;display:block;";
sat.title = `Satellite view — ${address.street || "home"}`;
plateImg.replaceWith(sat);
}
const capI = document.querySelector(".plate-img figcaption i");
if (capI) capI.textContent = `${address.street || "The house"} — satellite, live from Google Maps.`;
const fig = document.querySelector(".plate-img .fig");
if (fig) fig.textContent = "LIVE · GOOGLE SAT";
text(".plate-meta span:first-child", `Satellite · ${address.street || ""}`.trim());
text(".plate-meta span:last-child",
`${address.city || ""} · ${address.state || ""} ${address.zip || ""}`.toUpperCase().trim());
// Spec rows — update GAMES ACCESS + HOSTS + LOT/year (keep known-unknowns honest)
const rows = document.querySelectorAll(".spec dl > div");
if (rows.length >= 7) {
// Year built — unknown for most; keep placeholder but mark as "built year on request"
rows[0].querySelector("dd").textContent = "Single-family home — year on request";
rows[0].querySelector("span").textContent = "PRIVATE";
// Floor area — unknown
rows[1].querySelector("dd").textContent = "Interior square footage on request";
rows[1].querySelector("span").textContent = "INQUIRE";
// Lot
rows[2].querySelector("dd").textContent = `${address.city || "Los Angeles"} lot — size on request`;
rows[2].querySelector("span").textContent = "PRIVATE";
// Sleeps — keep
// Parking — keep
// Games access — dynamic
const n0 = nearest[0];
rows[5].querySelector("dt").textContent = "NEAREST GAMES VENUE";
rows[5].querySelector("dd").textContent =
`${n0.name} — ${n0.mi.toFixed(1)} mi · ~${n0.min} min drive`;
rows[5].querySelector("span").textContent = `${n0.mi.toFixed(1)} MI`;
// Hosts
rows[6].querySelector("dd").textContent = `${host} · ${address.city || "Los Angeles"}`;
rows[6].querySelector("span").textContent = "LOCAL HOST";
}
// Pick neighborhood library entry (BH / Northridge / SM / default LA)
const NB = (window.HTG_NEIGHBORHOODS && window.HTG_NEIGHBORHOODS.find(address)) || null;
// Replace Games event grid with address-specific nearest venues (top 4)
const eg = document.querySelector(".event-grid");
if (eg && nearest.length) {
const classes = ["headline", "inverted", "ghost", "ghost"];
eg.innerHTML = nearest.slice(0, 4).map((v, i) => {
const headline = (v.headline || v.sports.split("·")[0].trim()).toUpperCase();
const name = v.name.replace(/·.*$/, "").trim();
const sports = v.sports.charAt(0).toUpperCase() + v.sports.slice(1);
return `
<article class="event ${classes[i] || 'ghost'}">
<div class="row"><span>${(i + 1).toString().padStart(2, "0")} · ${escapeHtml(headline)}</span><span>${v.mi.toFixed(1)} MI</span></div>
<h3>${escapeHtml(name)}</h3>
<p>${escapeHtml(sports)} · ~${escapeHtml(v.min)} min from your door.</p>
<div class="chips">
<span class="pill ${i === 1 ? 'filled dark' : 'filled'}">${escapeHtml(v.dates || 'JUL 14–30')}</span>
<span class="pill ${i === 1 ? 'outline dark' : (i === 0 ? 'outline' : 'outline light')}">${v.mi < 5 ? 'QUICK DRIVE' : v.mi < 15 ? 'SHORT DRIVE' : 'DAY TRIP'}</span>
</div>
</article>
`; }).join("");
}
// Games section — swap h2/eyebrow/lede using the matched neighborhood
const olympH2 = document.querySelector(".olympics header h2");
const olympEyebrow = document.querySelector(".olympics header .eyebrow");
const olympLede = document.querySelector(".olympics header .lede");
if (NB) {
if (olympH2 && NB.olympicsH2) olympH2.innerHTML = NB.olympicsH2;
if (olympEyebrow && NB.olympicsEyebrow) olympEyebrow.textContent = NB.olympicsEyebrow;
if (olympLede) {
olympLede.textContent = (typeof NB.olympicsLede === "function")
? NB.olympicsLede(address, nearest)
: (NB.olympicsLede || olympLede.textContent);
}
}
// Guide — REWRITE things to do, h2, eyebrow, lede from neighborhood
const guideH2 = document.querySelector(".guide h2");
const guideEyebrow = document.querySelector(".guide .eyebrow");
const guideLede = document.querySelector(".guide .lede");
const guideList = document.querySelector(".guide-list");
if (NB) {
if (guideH2) {
const h2 = (typeof NB.guideH2 === "function") ? NB.guideH2(escapeHtml(address.city || "")) : NB.guideH2;
if (h2) guideH2.innerHTML = h2;
}
if (guideEyebrow && NB.guideEyebrow) guideEyebrow.textContent = NB.guideEyebrow;
if (guideLede && NB.guideLede) guideLede.textContent = NB.guideLede;
if (guideList && Array.isArray(NB.thingsToDo)) {
guideList.innerHTML = NB.thingsToDo.map((it, i) => {
const seed = (it.t || "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
const fallback = `https://picsum.photos/seed/${encodeURIComponent(seed)}/280/200`;
return `
<li data-wiki="${it.wiki || ''}" data-idx="${i}">
<span class="n">${it.n}</span>
<img class="thumb" src="${fallback}" alt="${it.t}" loading="lazy">
<span class="t">${it.t}</span>
<span class="d">${it.d}</span>
<span class="k">${it.k}</span>
</li>`;
}).join("");
// Async: replace thumbnails with Wikipedia page images where available
guideList.querySelectorAll("li[data-wiki]").forEach(async (li) => {
const title = li.getAttribute("data-wiki");
if (!title) return;
const src = await wikiThumb(title);
if (src) {
const img = li.querySelector(".thumb");
if (img) img.src = src;
}
});
}
}
// History — rewrite paragraphs + timeline from neighborhood
const historyCopy = document.querySelector(".history-copy");
const timelineEl = document.querySelector(".timeline");
if (NB && historyCopy) {
const hH2 = (typeof NB.historyH2 === "function") ? NB.historyH2(escapeHtml(address.city || "")) : NB.historyH2;
historyCopy.innerHTML = `
<div class="eyebrow accent">${NB.historyEyebrow || "§ 07 · THE NEIGHBORHOOD"}</div>
<h2 class="display sm">${hH2 || "Your neighborhood<br>has stories."}</h2>
${(NB.historyParagraphs || []).map(p => `<p>${p}</p>`).join("")}
`;
}
if (NB && timelineEl && Array.isArray(NB.timeline)) {
timelineEl.style.display = "";
timelineEl.innerHTML = NB.timeline.map(t => `<li><span class="y">${t.y}</span><span>${t.t}</span></li>`).join("");
}
// Map section — swap the three mockup images for real Google Maps iframes
// Use address string (not lat/lng) so Google snaps to the street-frontage pano,
// not the closest pano which can face the alley.
const fullAddr = [address.street, address.city, address.state, address.zip].filter(Boolean).join(", ");
const mapMainImg = document.querySelector(".map-main img");
if (mapMainImg) {
const sv = document.createElement("iframe");
sv.src = `https://maps.google.com/maps?q=${encodeURIComponent(fullAddr)}&layer=c&output=svembed`;
sv.loading = "lazy";
sv.style.cssText = "width:100%;height:100%;border:0;display:block;";
sv.title = "Street view";
mapMainImg.replaceWith(sv);
}
const sideTiles = document.querySelectorAll(".map-side .map-tile img");
if (sideTiles.length >= 1) {
const sat = document.createElement("iframe");
sat.src = `https://maps.google.com/maps?q=${address.lat},${address.lng}&t=k&z=18&output=embed`;
sat.loading = "lazy";
sat.style.cssText = "width:100%;height:100%;border:0;display:block;";
sat.title = "Satellite";
sideTiles[0].replaceWith(sat);
}
if (sideTiles.length >= 2 && nearest[0]) {
const dir = document.createElement("iframe");
const v = nearest[0];
// Google Maps directions-style embed without API key
dir.src = `https://maps.google.com/maps?saddr=${address.lat},${address.lng}&daddr=${v.lat},${v.lng}&output=embed`;
dir.loading = "lazy";
dir.style.cssText = "width:100%;height:100%;border:0;display:block;";
dir.title = "Drive to " + v.name;
sideTiles[1].replaceWith(dir);
// Update the drive chip with the real nearest venue
const driveChip = document.querySelector(".map-side .map-tile:nth-child(2) .chip-abs");
if (driveChip) driveChip.textContent = `DRIVE TO ${v.name.toUpperCase()}`;
const driveMeta = document.querySelector(".map-side .map-tile:nth-child(2) .drive-meta");
if (driveMeta) driveMeta.innerHTML = `<b>${escapeHtml(v.min)} min</b><span>${v.mi.toFixed(1)} MILES</span>`;
}
// Rewrite map chips for clarity
const liveChip = document.querySelector(".map-main .chip-abs.tl");
if (liveChip) liveChip.textContent = `LIVE STREET VIEW · ${address.street || ""}`.trim();
const headChip = document.querySelector(".map-main .chip-abs.tl2");
if (headChip) headChip.textContent = `LAT ${address.lat.toFixed(4)} · LNG ${address.lng.toFixed(4)} · GOOGLE`;
// Relabel the interior contact sheet as reference imagery
const sheetHead = document.querySelector(".plate ~ .olympics");
const cs = document.querySelectorAll("#host ~ .olympics"); // no-op guard
// Gentle relabel on the "Contact Sheet" caption if present
const csEyebrow = document.querySelectorAll(".eyebrow");
csEyebrow.forEach(e => {
if (/§ 05 · CONTACT SHEET/i.test(e.textContent)) e.textContent = "§ 05 · REFERENCE IMAGERY · SWAP WITH YOUR PHOTOS";
});
// Contact section
text(".meta li:nth-child(1) b", email);
text(".meta li:nth-child(2) b", phone);
text(".meta li:nth-child(3) b", "8am–9pm Pacific · seven days");
const contactH = document.querySelector(".contact h2");
if (contactH) contactH.innerHTML = `Talk to<br>${escapeHtml(host.split(" ")[0])}.`;
const contactCopy = document.querySelector(".contact-copy p");
if (contactCopy) {
contactCopy.textContent = `Write me directly — I read every inquiry. Tell me which LA 2028 events you're attending and how long you'd like to stay, and I'll reply within a day.`;
}
// Footer
text(".footer .brand", "");
const fBrand = document.querySelector(".footer .brand");
if (fBrand) fBrand.innerHTML = `<span class="brand-dot"></span>${escapeHtml((host.split(" ")[0] || "").toUpperCase())} HOUSE · LA 2028 RENTAL`;
text(".footer .addr", `${address.street || ""} · ${address.city || ""} · ${address.county || "Los Angeles County"} · California · ${address.zip || ""}`);
// Scripted 30-second trailer — intro, house, photos, closest Games event, outro
buildTrailer({ address, host, nearest, price, fullAddr });
// Countdown / today-at-Games banner — date-aware (opens / live / wrapped)
(function countdownBanner() {
const OPEN = new Date(2028, 6, 14);
const CLOSE = new Date(2028, 6, 30);
const today = new Date(); today.setHours(0,0,0,0);
const MS_DAY = 86400000;
const daysTo = Math.round((OPEN - today) / MS_DAY);
const daysSince = Math.round((today - CLOSE) / MS_DAY);
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
let kicker, title, meta;
if (today < OPEN) {
kicker = "⏳ COUNTDOWN TO THE GAMES";
title = `${daysTo.toLocaleString()} days until opening`;
meta = `Opening ceremony · Friday, July 14, 2028 · SoFi Stadium`;
} else if (today > CLOSE) {
kicker = "THE GAMES HAVE WRAPPED";
title = `${daysSince} days since closing`;
meta = `See you at the 2030 winter edition.`;
} else {
kicker = "✦ TODAY AT THE GAMES";
const tk = today.toISOString().slice(0,10);
const live = (nearest || []).filter(v =>
expandDates(v.dates || "").some(d =>
new Date(d.year, MONTHS.indexOf(d.mm), d.dd).toISOString().slice(0,10) === tk
)
);
title = live.length ? live.slice(0,3).map(v => v.headline || v.sports).join(" · ") : "A quiet day between heats";
meta = live.length ? live.slice(0,3).map(v => `${v.name.replace(/·.*$/, "").trim()} (${v.mi.toFixed(1)} mi)`).join(" · ") : "Rest day — back tomorrow";
}
const banner = document.createElement("section");
banner.className = "countdown-banner";
banner.innerHTML = `<div class="cb-inner"><div class="cb-kicker">${escapeHtml(kicker)}</div><div class="cb-title">${escapeHtml(title)}</div><div class="cb-meta">${escapeHtml(meta)}</div></div>`;
const hero = document.querySelector(".hero");
if (hero) hero.parentNode.insertBefore(banner, hero.nextSibling);
})();
// Closest-event banner — prominent card with headline sport + exact day chips
const c0 = nearest[0];
if (c0) {
const days = expandDates(c0.dates || "Jul 14 – Jul 30, 2028");
const weekdayShort = ["SUN","MON","TUE","WED","THU","FRI","SAT"];
const dayChipsHtml = days.map(d => `
<span class="ce-day">
<em>${weekdayShort[d.dow]}</em>
<b>${d.dd}</b>
<i>${d.mm.toUpperCase()}</i>
</span>
`).join("");
const closestBanner = document.createElement("section");
closestBanner.className = "closest-event";
closestBanner.innerHTML = `
<div class="ce-inner">
<div class="ce-top">
<div class="ce-left">
<div class="ce-eyebrow">✦ CLOSEST GAMES EVENT TO YOUR DOOR</div>
<div class="ce-title">${escapeHtml(c0.headline || c0.sports)}</div>
<div class="ce-meta">${escapeHtml(c0.name)} · ${c0.mi.toFixed(1)} mi / ~${escapeHtml(c0.min)} min drive</div>
</div>
<div class="ce-right">
<div class="ce-dates-label">WINDOW · ${days.length} DAY${days.length > 1 ? 'S' : ''}</div>
<div class="ce-dates">${escapeHtml(c0.dates || "Jul 14 – Jul 30, 2028")}</div>
</div>
</div>
${days.length ? `<div class="ce-days" aria-label="Exact event days">${dayChipsHtml}</div>` : ""}
</div>
`;
const hero = document.querySelector(".hero");
if (hero) hero.parentNode.insertBefore(closestBanner, hero.nextSibling);
}
// Inject a Suggested-nightly card into the hero below the property card
const heroBody = document.querySelector(".hero-body");
const existing = document.querySelector(".price-card");
if (heroBody && !existing && price) {
const card = document.createElement("aside");
card.className = "property-card price-card";
card.style.marginLeft = "16px";
card.innerHTML = `
<div class="pc-label">SUGGESTED NIGHTLY</div>
<div class="pc-address"><b>$${formatMoney(price.mid)}</b><span>/ NIGHT</span></div>
<div class="pc-sub">Range $${formatMoney(price.low)} – $${formatMoney(price.high)}<br>Jul 14 – Jul 30, 2028</div>
<hr>
<div class="pc-stats">
<div><b>$${formatMoney(baseline && baseline.base)}</b><span>ZIP BASE</span></div>
<div><b>×${price.proximity.toFixed(2)}</b><span>PROXIMITY</span></div>
<div><b>×${price.eventPremium.toFixed(1)}</b><span>LA 2028</span></div>
</div>
`;
heroBody.appendChild(card);
}
// Budget calculator — slider for nights × price + event-ticket estimate
(function budgetCalculator() {
if (!price) return;
const heroEl = document.querySelector(".hero");
if (!heroEl) return;
const panel = document.createElement("section");
panel.className = "budget-calc";
panel.innerHTML = `
<div class="bc-inner">
<div class="bc-left">
<div class="bc-eyebrow">✦ TRIP BUDGET — YOUR ALL-IN COST</div>
<h3>Plan it before you book it.</h3>
<p>Adjust nights and event tickets below. The nightly rate is your suggested rate; ticket averages come from public secondary-market data.</p>
</div>
<div class="bc-right">
<label>
<span>NIGHTS <b id="bc-nv">7</b></span>
<input type="range" id="bc-nights" min="1" max="17" value="7">
</label>
<label>
<span>EVENT TICKETS (per person) <b id="bc-tv">2</b></span>
<input type="range" id="bc-tix" min="0" max="10" value="2">
</label>
<label>
<span>GUESTS <b id="bc-gv">4</b></span>
<input type="range" id="bc-guests" min="1" max="6" value="4">
</label>
<div class="bc-breakdown">
<div><span>Lodging</span><b id="bc-lodge">—</b></div>
<div><span>Tickets</span><b id="bc-tickets">—</b></div>
<div class="bc-total"><span>ALL-IN</span><b id="bc-total">—</b></div>
</div>
</div>
</div>`;
// Place after the closest-event banner / before spec
const after = document.querySelector(".closest-event") || heroEl;
after.parentNode.insertBefore(panel, after.nextSibling);
const $ = s => panel.querySelector(s);
const AVG_TICKET = 185; // rough secondary-market avg across events
function recalc() {
const nights = +$("#bc-nights").value;
const tix = +$("#bc-tix").value;
const guests = +$("#bc-guests").value;
const lodging = nights * price.mid;
const tickets = tix * guests * AVG_TICKET;
const total = lodging + tickets;
$("#bc-nv").textContent = nights;
$("#bc-tv").textContent = tix;
$("#bc-gv").textContent = guests;
$("#bc-lodge").textContent = "$" + lodging.toLocaleString();
$("#bc-tickets").textContent = "$" + tickets.toLocaleString();
$("#bc-total").textContent = "$" + total.toLocaleString();
}
panel.querySelectorAll("input[type=range]").forEach(r => r.addEventListener("input", recalc));
recalc();
})();
// Persist guest inquiries submitted through the contact form
wireContactForm(r);
// Cascade uploaded photos into the hero plate + video trailer
applyUploadedPhotos(r);
}
// If the host has uploaded photos, use them as the hero plate cover image
// and as the two "interior" photo scenes in the video trailer.
function applyUploadedPhotos(rental) {
const photos = rental.photos || [];
if (!photos.length) return;
// Hero plate — swap the satellite iframe for the first uploaded photo
const plateSec = document.querySelector(".plate-img");
if (plateSec) {
const existing = plateSec.querySelector("iframe, img");
const img = document.createElement("img");
img.src = photos[0];
img.alt = "Cover photo";
img.style.cssText = "width:100%;height:600px;object-fit:cover;display:block;";
if (existing) existing.replaceWith(img);
const fig = plateSec.querySelector(".fig");
if (fig) fig.textContent = "COVER · YOUR PHOTO";
const capI = plateSec.querySelector("figcaption i");
if (capI) capI.textContent = "Uploaded by the host.";
}
// Trailer scenes — replace photo1 / photo2 picsum srcs with host photos once playback hits them
// Easiest: override the scene HTML by intercepting in buildTrailer. Alternative: monkey-patch
// after the player renders. We'll wait a tick and replace picsum imgs inside the stage.
setTimeout(() => {
document.querySelectorAll(".player .tr-photo").forEach((img, i) => {
const src = photos[i + 1] || photos[i];
if (src) img.src = src;
});
}, 100);
// Contact-sheet style: if more than 2 photos, fill the existing plate area with a grid
// (already have plate + trailer — leave rest for v2)
}
// Wire the contact form on rental.html to save inquiries to the rental record
function wireContactForm(rental) {
const form = document.getElementById("contact-form");
if (!form) return;
form.addEventListener("submit", e => {
e.preventDefault();
const f = e.currentTarget;
const events = Array.from(f.querySelectorAll(".events input[type=checkbox]"))
.filter(c => c.checked).map(c => c.parentElement.textContent.trim());
const inquiry = {
id: "inq-" + Math.random().toString(36).slice(2, 10),
firstName: f.first ? f.first.value : "",
lastName: f.last ? f.last.value : "",
email: f.email ? f.email.value : "",
checkIn: f.in ? f.in.value : "",
checkOut: f.out ? f.out.value : "",
guests: f.guests? f.guests.value: "",
events,
message: f.msg ? f.msg.value : "",
createdAt: new Date().toISOString(),
};
try {
const all = JSON.parse(localStorage.getItem("htg.rentals") || "{}");
if (all[rental.slug]) {
all[rental.slug].inquiries = all[rental.slug].inquiries || [];
all[rental.slug].inquiries.push(inquiry);
localStorage.setItem("htg.rentals", JSON.stringify(all));
}
} catch (err) {}
// UX: replace the form with a thank-you state
form.innerHTML = `
<div style="padding: 24px; border: 1px solid rgba(255,255,255,.25);">
<div style="font-family: var(--font-body); font-weight: 800; font-size: 11px; letter-spacing: 0.24em; color: var(--accent); margin-bottom: 8px;">✓ INQUIRY SAVED</div>
<div style="font-family: var(--font-serif); font-size: 19px; line-height: 1.5;">Thanks, ${escapeHtml(inquiry.firstName || "friend")} — your message is in the host's inbox.</div>
<div style="font-family: var(--font-serif); font-style: italic; font-size: 15px; opacity: .7; margin-top: 8px;">They'll reply to ${escapeHtml(inquiry.email || "you")} within 24 hours.</div>
</div>`;
});
}
function render() {
const r = load();
if (!r) {
document.body.innerHTML = `
<div style="font-family: Inter, sans-serif; max-width: 520px; margin: 12vh auto; padding: 32px; text-align: center;">
<h1 style="font-family: Archivo, sans-serif; font-weight: 900; font-size: 40px; margin: 0 0 12px;">No rental found.</h1>
<p style="color: #555; font-size: 17px; line-height: 1.5;">Go to the signup page, enter your address, and we'll render a personalized rental microsite.</p>
<p style="margin-top: 24px;"><a href="index.html" style="color: #E2302A; font-weight: 700; letter-spacing: 0.1em; font-size: 13px;">→ CLAIM YOUR ADDRESS</a></p>
</div>
`;
return;
}
patch(r);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", render);
else render();
})();
// Live rail — clock ticker
(function railClock() {
const el = document.querySelector("[data-clock] b");
if (!el) return;
function tick() {
const d = new Date();
// Show local time formatted as HH : MM : SS (Pacific is user's local assumption)
const pad = n => String(n).padStart(2, "0");
el.textContent = `${pad(d.getHours())} : ${pad(d.getMinutes())} : ${pad(d.getSeconds())}`;
}
tick();
setInterval(tick, 1000);
})();