← 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}
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>
          &nbsp;·&nbsp;
          <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 };
}