← back to Malden House
signup.js
437 lines
// Victory Stays — signup → geocode → price → save → open rental
// Limitations: no real email verification (honor-system gate).
// Pricing uses transparent heuristics over public data (OpenStreetMap geocoding
// + LA 2028 venue coordinates + ZIP baseline). No live scraping of Zillow/Airbnb.
const HTG_SCHEMA = 3;
// Auto-wipe stored rentals if they were seeded under an older schema —
// old records lack headline/dates/wiki fields added in v3.
if (typeof localStorage !== "undefined") {
try {
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) {}
}
const LA2028_VENUES = [
{ name: "Sepulveda Basin Valley Complex", sports: "3x3 basketball · BMX · skate · pentathlon", headline: "3×3 Basketball", dates: "Jul 18 – Jul 28, 2028", lat: 34.1706, lng: -118.4787 },
{ name: "UCLA · Pauley Pavilion", sports: "gymnastics · basketball", headline: "Gymnastics (Artistic)", dates: "Jul 15 – Jul 23, 2028", lat: 34.0704, lng: -118.4468 },
{ name: "SoFi Stadium · Inglewood", sports: "opening & closing ceremony", headline: "Opening Ceremony", dates: "Jul 14, 2028", lat: 33.9535, lng: -118.3391 },
{ name: "Crypto.com Arena · DTLA", sports: "gymnastics final", headline: "Gymnastics Finals", dates: "Jul 21 – Jul 23, 2028", lat: 34.0430, lng: -118.2673 },
{ name: "Peacock Theater · LA Live", sports: "weightlifting", headline: "Weightlifting", dates: "Jul 23 – Jul 29, 2028", lat: 34.0425, lng: -118.2666 },
{ name: "LA Memorial Coliseum", sports: "track & field", headline: "Track & Field", dates: "Jul 21 – Jul 30, 2028", lat: 34.0141, lng: -118.2879 },
{ name: "Long Beach Convention Center", sports: "handball", headline: "Handball", dates: "Jul 15 – Jul 30, 2028", lat: 33.7648, lng: -118.1905 },
{ name: "Long Beach · Alamitos Beach", sports: "beach volleyball", headline: "Beach Volleyball", dates: "Jul 15 – Jul 26, 2028", lat: 33.7617, lng: -118.1839 },
{ name: "Santa Monica Pier", sports: "triathlon · marathon swim", headline: "Triathlon", dates: "Jul 15 – Jul 19, 2028", lat: 34.0100, lng: -118.4973 },
{ name: "Riviera Country Club", sports: "golf", headline: "Golf", dates: "Jul 25 – Jul 30, 2028", lat: 34.0505, lng: -118.5096 },
{ name: "Intuit Dome · Inglewood", sports: "basketball", headline: "Basketball", dates: "Jul 24 – Jul 30, 2028", lat: 33.9450, lng: -118.3420 },
{ name: "Dignity Health Sports Park", sports: "track cycling · tennis", headline: "Track Cycling", dates: "Jul 17 – Jul 22, 2028", lat: 33.8631, lng: -118.2612 },
{ name: "Dodger Stadium", sports: "baseball / softball", headline: "Baseball", dates: "Jul 17 – Jul 28, 2028", lat: 34.0739, lng: -118.2400 },
{ name: "Exposition Park Aquatic Center", sports: "swimming · diving", headline: "Swimming", dates: "Jul 15 – Jul 21, 2028", lat: 34.0164, lng: -118.2878 },
{ name: "Rose Bowl · Pasadena", sports: "soccer", headline: "Soccer (Football)", dates: "Jul 11 – Jul 29, 2028", lat: 34.1613, lng: -118.1676 },
];
const SERVICE_MAX_MI = 30; // We only host rentals within 30 miles of a venue
// Rough ZIP baseline table (per-night for 3br in non-Games times).
// Coarse on purpose — real tool would hit Zillow/Redfin comps on a server.
const ZIP_BASELINE = [
{ prefix: "902", base: 650, label: "Beverly Hills / Brentwood / Bel Air" },
{ prefix: "900", base: 500, label: "West Hollywood / mid-city LA" },
{ prefix: "901", base: 425, label: "Downtown / East LA" },
{ prefix: "903", base: 325, label: "South LA / Inglewood" },
{ prefix: "904", base: 475, label: "Santa Monica / Venice" },
{ prefix: "905", base: 260, label: "South Bay / Long Beach" },
{ prefix: "906", base: 325, label: "Long Beach / Signal Hill" },
{ prefix: "907", base: 260, label: "South Bay" },
{ prefix: "910", base: 240, label: "Pasadena / Arcadia" },
{ prefix: "911", base: 240, label: "Glendale / Burbank" },
{ prefix: "912", base: 240, label: "Glendale" },
{ prefix: "913", base: 210, label: "San Fernando Valley N" },
{ prefix: "914", base: 250, label: "SFV W · Woodland Hills" },
{ prefix: "91324", base: 185, label: "Northridge" },
{ prefix: "9132", base: 195, label: "Northridge / Reseda" },
{ prefix: "915", base: 215, label: "Santa Clarita" },
{ prefix: "916", base: 230, label: "Simi Valley" },
{ prefix: "9", base: 225, label: "Greater Los Angeles" },
];
function baselineFor(zip) {
for (const r of ZIP_BASELINE) if (zip && zip.startsWith(r.prefix)) return r;
return { prefix: "default", base: 225, label: "Greater Los Angeles" };
}
// Great-circle distance in miles
function haversineMi(a, b) {
const toRad = x => x * Math.PI / 180;
const R = 3958.8;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const s = Math.sin(dLat/2)**2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng/2)**2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function drivingMin(mi) { return Math.round(mi * 2.4); } // coarse LA traffic estimate
async function geocodeOnce(q) {
const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=1&q=${encodeURIComponent(q)}`;
const headers = { "Accept": "application/json" };
if (typeof window === "undefined") headers["User-Agent"] = "Victory Stays/0.1 (contact: hello@victorystays.com)";
const r = await fetch(url, { headers });
if (!r.ok) throw new Error("Geocoding failed (" + r.status + ")");
const rows = await r.json();
return rows[0] || null;
}
async function geocode(address) {
// Try the raw query first; on miss, retry with directionals stripped and
// with the "probably-wrong ZIP" removed. USPS vs OSM disagree about 90210/90212.
const variants = [
address,
address.replace(/\b([NSEW])\.? /gi, ""),
address.replace(/\b([NSEW])\.? /gi, "").replace(/,?\s*9\d{4}\s*$/, ""),
];
let row = null;
for (const q of variants) {
row = await geocodeOnce(q);
if (row) break;
await new Promise(res => setTimeout(res, 900)); // be polite to Nominatim
}
if (!row) throw new Error("We couldn't find that address. Double-check the street and ZIP.");
const a = row.address || {};
return {
lat: parseFloat(row.lat),
lng: parseFloat(row.lon),
display: row.display_name,
street: [a.house_number, a.road].filter(Boolean).join(" "),
city: a.city || a.town || a.village || a.neighbourhood || a.suburb,
county: a.county,
state: a.state,
zip: a.postcode,
};
}
function nearestVenues(coord, n = 6) {
return LA2028_VENUES
.map(v => ({ ...v, mi: haversineMi(coord, v) }))
.sort((a, b) => a.mi - b.mi)
.slice(0, n)
.map(v => ({ ...v, min: drivingMin(v.mi) }));
}
function priceModel(baseline, nearest) {
// Proximity multiplier — closer to a venue → higher premium
const closest = nearest[0].mi;
let proximity;
if (closest < 2) proximity = 1.6;
else if (closest < 5) proximity = 1.4;
else if (closest < 10) proximity = 1.25;
else if (closest < 20) proximity = 1.1;
else proximity = 1.0;
const eventPremium = 2.8; // LA 2028 two-week window demand spike
const low = Math.round(baseline.base * proximity * (eventPremium - 0.4));
const mid = Math.round(baseline.base * proximity * eventPremium);
const high = Math.round(baseline.base * proximity * (eventPremium + 0.5));
return { low, mid, high, proximity, eventPremium, closestMi: closest };
}
function slugify(s) {
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
const escapeAttr = escapeHtml;
function summarize(data) {
const p = data.price;
const n = data.nearest;
return {
headlineRange: `$${p.low.toLocaleString()} – $${p.high.toLocaleString()}`,
suggested: `$${p.mid.toLocaleString()} / night`,
nearest: `${n[0].name} — ${n[0].mi.toFixed(1)} mi / ~${n[0].min} min`,
};
}
// ----- DOM wiring -----
const $ = sel => document.querySelector(sel);
if (typeof document !== "undefined") {
const form = $("#signup-form");
const status = $("#f-status");
const btn = $("#f-submit");
function setStatus(html, ok = false) {
status.innerHTML = html;
status.hidden = false;
status.classList.toggle("ok", ok);
}
async function handleSubmit(e) {
e.preventDefault();
const address = $("#f-addr").value.trim();
const host = $("#f-host").value.trim();
const email = $("#f-email").value.trim();
const phone = $("#f-phone").value.trim();
if (!address || !host || !email || !phone) return;
btn.disabled = true;
btn.querySelector("span").textContent = "Finding your address…";
setStatus("<b>Geocoding</b>Asking OpenStreetMap for your coordinates…");
try {
const geo = await geocode(address);
btn.querySelector("span").textContent = "Mapping venues…";
const nearest = nearestVenues({ lat: geo.lat, lng: geo.lng }, 6);
const baseline = baselineFor(geo.zip || "");
const price = priceModel(baseline, nearest);
// Eligibility — service is LA 2028-local, within SERVICE_MAX_MI of a venue
if (nearest[0].mi > SERVICE_MAX_MI) {
setStatus(`<b>Outside our coverage area</b>
Your home is ${nearest[0].mi.toFixed(1)} miles from the nearest LA 2028 venue (${nearest[0].name}).
We currently only host microsites within ${SERVICE_MAX_MI} miles of a Games venue.
<a href="mailto:hello@victorystays.com?subject=Coverage%20request">Email us</a> if you'd like us to expand the service to your area.`);
btn.querySelector("span").textContent = "Try another address →";
btn.disabled = false;
return;
}
const slug = slugify((geo.street || address).split(",")[0]);
const record = {
slug,
host, email, phone,
address: {
raw: address,
display: geo.display,
street: geo.street || address.split(",")[0],
city: geo.city || "",
county: geo.county || "",
state: geo.state || "California",
zip: geo.zip || "",
lat: geo.lat, lng: geo.lng,
},
baseline, price, nearest,
createdAt: new Date().toISOString(),
verified: true, // honor-system: no real email verify yet
};
const all = JSON.parse(localStorage.getItem("htg.rentals") || "{}");
all[slug] = record;
localStorage.setItem("htg.rentals", JSON.stringify(all));
localStorage.setItem("htg.active", slug);
const s = summarize(record);
const closest = nearest[0];
setStatus(`
<b>Ready to render · saved to your dashboard</b>
Your personalized microsite is built. Open it in a new tab and edit or theme-swap anytime.
<dl>
<dt>Address</dt><dd>${escapeHtml(record.address.street || address)}${record.address.city ? ", " + escapeHtml(record.address.city) : ""}${record.address.zip ? " " + escapeHtml(record.address.zip) : ""}</dd>
<dt>Closest event</dt><dd><b>${escapeHtml(closest.headline || closest.sports)}</b> · ${escapeHtml(closest.dates || "Jul 14–30, 2028")}<br>${escapeHtml(closest.name)} — ${escapeHtml(closest.mi.toFixed(1))} mi / ~${escapeHtml(closest.min)} min</dd>
<dt>ZIP baseline</dt><dd>$${escapeHtml(baseline.base)}/night · ${escapeHtml(baseline.label)}</dd>
<dt>Proximity ×</dt><dd>${price.proximity.toFixed(2)} (closest ${price.closestMi.toFixed(1)} mi)</dd>
<dt>event premium</dt><dd>×${price.eventPremium.toFixed(1)} · Jul 14 – Jul 30, 2028</dd>
<dt>Suggested nightly</dt><dd><b>${escapeHtml(s.suggested)}</b> (range ${escapeHtml(s.headlineRange)})</dd>
</dl>
<p style="margin-top:14px">
<a href="rental.html?id=${encodeURIComponent(slug)}" target="_blank" rel="noopener noreferrer">→ Open my rental microsite</a>
·
<a href="dashboard.html">→ Open dashboard</a>
</p>
`, true);
btn.querySelector("span").textContent = "Open my site →";
btn.disabled = false;
btn.onclick = () => window.open("rental.html?id=" + encodeURIComponent(slug), "_blank");
// Verification flow — for now we "send" the email to console and save a flag.
console.info("[HTG] Would email %s with verify link for %s", email, record.address.display);
} catch (err) {
setStatus(`<b>Something went sideways</b>${err.message || err}. Try again in a second.`);
btn.querySelector("span").textContent = "Try again →";
btn.disabled = false;
}
}
form.addEventListener("submit", handleSubmit);
}
// ---------- Demo seeder — pre-populate two Beverly Hills rentals ----------
async function seedDemo() {
const demos = [
{ address: "471 S Peck Dr, Beverly Hills, CA 90210", host: "Steve Abrams", email: "steveabramsdesigns@gmail.com", phone: "(310) 713-0489" },
{ address: "462 S Beverwil Dr, Beverly Hills, CA 90212", host: "Steve Abrams", email: "steveabramsdesigns@gmail.com", phone: "(310) 713-0489" },
];
const store = JSON.parse(localStorage.getItem("htg.rentals") || "{}");
for (const d of demos) {
const geo = await geocode(d.address);
const nearest = nearestVenues({ lat: geo.lat, lng: geo.lng }, 6);
const baseline = baselineFor(geo.zip || "");
const price = priceModel(baseline, nearest);
const slug = slugify((geo.street || d.address).split(",")[0]);
store[slug] = {
slug, host: d.host, email: d.email, phone: d.phone,
address: {
raw: d.address, display: geo.display,
street: geo.street || d.address.split(",")[0],
city: geo.city || "", county: geo.county || "",
state: geo.state || "California", zip: geo.zip || "",
lat: geo.lat, lng: geo.lng,
},
baseline, price, nearest,
createdAt: new Date().toISOString(),
verified: true,
};
await new Promise(r => setTimeout(r, 1100)); // polite to Nominatim
}
localStorage.setItem("htg.rentals", JSON.stringify(store));
return Object.keys(store);
}
// ---------- Address autocomplete (debounced Nominatim) ----------
if (typeof document !== "undefined") {
const input = document.getElementById("f-addr");
const menu = document.getElementById("f-addr-menu");
if (input && menu) {
let abortCtl = null, lastQ = "", timer = 0;
async function suggest(q) {
if (abortCtl) abortCtl.abort();
abortCtl = new AbortController();
const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=5&countrycodes=us&q=${encodeURIComponent(q)}`;
try {
const r = await fetch(url, { signal: abortCtl.signal, headers: { "Accept": "application/json" } });
if (!r.ok) return [];
return await r.json();
} catch (e) { return []; }
}
function render(rows) {
if (!rows.length) { menu.hidden = true; menu.innerHTML = ""; return; }
menu.innerHTML = rows.slice(0, 5).map(r => {
const a = r.address || {};
const street = [a.house_number, a.road].filter(Boolean).join(" ");
const line1 = street || r.display_name.split(",")[0];
const line2 = [a.city || a.town || a.village || a.neighbourhood || a.suburb, a.state, a.postcode].filter(Boolean).join(", ");
const val = r.display_name;
return `<li data-val="${escapeAttr(val)}"><b>${escapeHtml(line1)}</b><span>${escapeHtml(line2)}</span></li>`;
}).join("");
menu.hidden = false;
}
input.addEventListener("input", () => {
const q = input.value.trim();
clearTimeout(timer);
if (q.length < 4 || q === lastQ) { if (q.length < 4) { menu.hidden = true; } return; }
lastQ = q;
timer = setTimeout(async () => {
const rows = await suggest(q);
render(rows);
}, 320);
});
menu.addEventListener("mousedown", (e) => {
const li = e.target.closest("li[data-val]");
if (!li) return;
e.preventDefault();
input.value = li.getAttribute("data-val");
menu.hidden = true;
});
input.addEventListener("blur", () => setTimeout(() => { menu.hidden = true; }, 120));
input.addEventListener("focus", () => { if (menu.innerHTML) menu.hidden = false; });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") menu.hidden = true; });
}
// Tier CTAs — jump to signup with the plan remembered
document.querySelectorAll(".l-tier-cta[data-plan]").forEach(btn => {
btn.addEventListener("click", () => {
const plan = btn.getAttribute("data-plan");
try { localStorage.setItem("htg.plan", plan); } catch (e) {}
document.getElementById("signup")?.scrollIntoView({ behavior: "smooth" });
document.getElementById("f-addr")?.focus();
});
});
// Domain availability checker — heuristic (no real registrar call).
// Gives the honest UX of "this name is long/short/available in these TLDs".
const domQ = document.getElementById("dom-q");
const domBtn = document.getElementById("dom-check");
if (domQ && domBtn) {
const note = document.querySelector(".l-dom-note");
domBtn.addEventListener("click", () => {
const raw = domQ.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
if (!raw) { domQ.focus(); return; }
const tlds = [
{ tld: ".com", price: 12 },
{ tld: ".house", price: 19 },
{ tld: ".la", price: 24 },
{ tld: ".rentals", price: 16 },
{ tld: ".stay", price: 29 },
{ tld: ".host", price: 22 },
];
const short = raw.length <= 14;
const results = tlds.map(t => {
// Deterministic pseudo-availability: common TLDs unlikely if short
const avail = !(t.tld === ".com" && short) && Math.abs(hashCode(raw + t.tld)) % 3 !== 0;
return { ...t, avail };
});
note.innerHTML = `
<b>Checked:</b> ${raw} —
${results.map(r => `<span style="margin-right:14px;${r.avail?'':'opacity:.35;text-decoration:line-through;'}">${r.tld} $${r.price}/yr ${r.avail?'✓':'taken'}</span>`).join("")}
<br><span style="opacity:.6;font-size:13px;">Heuristic only — final availability confirmed at checkout by the registrar.</span>`;
});
function hashCode(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; } return h; }
}
// Auto-seed when landing is opened with ?seed=1, then jump to the dashboard
if (new URLSearchParams(location.search).get("seed") === "1") {
(async () => {
try {
await seedDemo();
location.replace("dashboard.html");
} catch (e) { console.error("seed failed:", e); }
})();
}
const seedBtn = document.getElementById("seed-demo");
if (seedBtn) {
seedBtn.addEventListener("click", async () => {
seedBtn.disabled = true;
seedBtn.textContent = "Seeding two rentals…";
try {
const slugs = await seedDemo();
seedBtn.textContent = `✓ Seeded · ${slugs.length} rentals`;
const list = document.getElementById("demo-links");
if (list) {
list.hidden = false;
list.innerHTML = "<b>Open your demo rentals:</b>" + slugs
.map(s => ` <a href="rental.html?id=${encodeURIComponent(s)}" target="_blank" rel="noopener noreferrer">/${s}</a>`)
.join(" · ");
}
} catch (e) {
seedBtn.textContent = "Seed failed — see console";
console.error(e);
}
seedBtn.disabled = false;
});
}
}
// Expose for quick Node-style test runs (no-op in browser)
if (typeof module !== "undefined") {
module.exports = { geocode, nearestVenues, priceModel, baselineFor, haversineMi, LA2028_VENUES, slugify };
}