// Falls back to localhost for local development if nothing is set. const API_BASE_URL = window.BLOCKSTEAD_API_URL || "http://localhost:4000"; function useLiveHomeData(address, token) { const [state, setState] = useState({ status: "loading", value: null }); useEffect(() => { if (!address) return; // /api/home-value requires auth on the backend — without this header // the call 401s and silently falls back to sample data. if (!token) { setState({ status: "unavailable", value: null }); return; } let cancelled = false; fetch(`${API_BASE_URL}/api/home-value?address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` } }) .then(r => r.json().then(data => ({ ok: r.ok, data }))) .then(({ ok, data }) => { if (cancelled) return; if (ok && data.estimatedValue != null) setState({ status: "live", value: data.estimatedValue }); else setState({ status: "unavailable", value: null }); }) .catch(() => { if (!cancelled) setState({ status: "unavailable", value: null }); }); return () => { cancelled = true; }; }, [address, token]); return state; } /* ---------------- LIVE PLACES — real Google-sourced data via myhome-api, falls back to the simulated directories when unavailable ---------------- */ function usePlacesDirectory(category, address, token) { const [state, setState] = useState({ status: "loading", places: [] }); useEffect(() => { let cancelled = false; if (!token || !address || !category) { setState({ status: "unavailable", places: [] }); return; } setState(s => ({ ...s, status: "loading" })); fetch(`${API_BASE_URL}/api/places?category=${encodeURIComponent(category)}&address=${encodeURIComponent(address)}&radiusMiles=50`, { headers: { Authorization: `Bearer ${token}` }, }) .then(r => r.json().then(data => ({ ok: r.ok, data }))) .then(({ ok, data }) => { if (cancelled) return; if (ok && Array.isArray(data.places) && data.places.length > 0) setState({ status: "live", places: data.places }); else setState({ status: "unavailable", places: [] }); }) .catch(() => { if (!cancelled) setState({ status: "unavailable", places: [] }); }); return () => { cancelled = true; }; }, [category, address, token]); return state; } /* LIVE COMPS — real sales comparables from RentCast via myhome-api's /api/rentcast/comps, replacing the simulated comps generator when a provider key is configured. Falls back silently otherwise. */ function useLiveComps(address, token) { const [state, setState] = useState({ status: "loading", comps: [] }); useEffect(() => { let cancelled = false; if (!token || !address) { setState({ status: "unavailable", comps: [] }); return; } setState(s => ({ ...s, status: "loading" })); fetch(`${API_BASE_URL}/api/rentcast/comps?address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` }, }) .then(r => r.json().then(data => ({ ok: r.ok, data }))) .then(({ ok, data }) => { if (cancelled) return; if (ok && Array.isArray(data.comps) && data.comps.length > 0) setState({ status: "live", comps: data.comps }); else setState({ status: "unavailable", comps: [] }); }) .catch(() => { if (!cancelled) setState({ status: "unavailable", comps: [] }); }); return () => { cancelled = true; }; }, [address, token]); return state; } function adaptRentcastComp(c) { return { addr: c.distanceMiles != null ? `${c.distanceMiles} mi away` : c.address, style: c.propertyType ? `Comparable sale — ${c.propertyType}` : "Comparable sale", beds: c.bedrooms ?? "—", baths: c.bathrooms ?? "—", sqft: c.squareFeet ?? 0, sold: c.soldPrice ?? 0, }; } const DEFAULT_PROFILE = { address: "4904 W San Nicholas St, Tampa, FL 33629", purchasePrice: 247400, purchaseDate: "1999-08-09", sqft: 2281 }; const SAMPLE_ANNUAL_APPRECIATION = 0.045; function buildHomeModel(profile) { const today = new Date(); const purchaseDate = new Date(profile.purchaseDate); const purchasePrice = Number(profile.purchasePrice) || 0; const totalDaysOwned = Math.max(dayDiff(today, purchaseDate), 1); const yearsOwned = totalDaysOwned / 365.25; const sampleCurrentValue = Math.round(purchasePrice * Math.pow(1 + SAMPLE_ANNUAL_APPRECIATION, yearsOwned)); const CAGR = purchasePrice > 0 ? Math.pow(sampleCurrentValue / purchasePrice, 1 / yearsOwned) - 1 : 0; function valueAtDaysAgo(daysAgoTarget) { const clamped = Math.min(Math.max(daysAgoTarget, 0), totalDaysOwned); const daysSincePurchase = totalDaysOwned - clamped; return Math.round(purchasePrice * Math.pow(1 + CAGR, daysSincePurchase / 365.25)); } const valueLog = []; const step = Math.max(Math.round(totalDaysOwned / 12), 30); for (let d = totalDaysOwned; d >= 0; d -= step) valueLog.push({ daysAgo: d, date: daysAgo(today, d), value: valueAtDaysAgo(d) }); valueLog.push({ daysAgo: 0, date: today, value: sampleCurrentValue }); return { address: profile.address, sqft: Number(profile.sqft) || 0, purchasePrice, purchaseDate, sqftDefault: Number(profile.sqft) || 0, totalDaysOwned, sampleCurrentValue, valueAtDaysAgo, valueLog }; } const WINDOWS = [ { label: "Today", days: 0 }, { label: "Past week", days: 7 }, { label: "Past month", days: 30 }, { label: "3 months", days: 90 }, { label: "6 months", days: 180 }, { label: "12 months", days: 365 }, { label: "2 years", days: 730 }, ]; const IMPROVEMENT_CATEGORIES = [ { key: "kitchen", label: "Kitchen", roiPct: 8, contractors: [ { name: "Heritage Cabinet & Stone", price: 42000, rating: 4.9, reviews: 128, blurb: "Full gut remodels, custom cabinetry, quartz counters." }, { name: "Modern Kitchen Collective", price: 31000, rating: 4.7, reviews: 94, blurb: "Mid-range remodels, 6-week turnaround." }, { name: "Gulf Coast Kitchen Works", price: 22500, rating: 4.6, reviews: 61, blurb: "Refacing, counters, and layout tweaks." }, { name: "BudgetBuild Renovations", price: 14000, rating: 4.2, reviews: 37, blurb: "Cosmetic refresh — paint, hardware, backsplash." }, ]}, { key: "bath", label: "Bathroom", roiPct: 5, contractors: [ { name: "Spa Bath Studio", price: 24000, rating: 4.9, reviews: 82, blurb: "Full remodel, walk-in showers, heated floors." }, { name: "Suncoast Bath Renovations", price: 16500, rating: 4.7, reviews: 65, blurb: "Tub-to-shower conversions, vanity swaps." }, { name: "Reliable Reno Co.", price: 9800, rating: 4.4, reviews: 40, blurb: "Fixtures, tile, and paint refresh." }, ]}, { key: "flooring", label: "Flooring", roiPct: 3, contractors: [ { name: "Florida Hardwood Co.", price: 15200, rating: 4.8, reviews: 71, blurb: "Solid & engineered hardwood, whole-home." }, { name: "TileWorks Pasco", price: 9600, rating: 4.6, reviews: 54, blurb: "Porcelain tile, wet areas a specialty." }, { name: "LVP Direct Install", price: 5400, rating: 4.5, reviews: 88, blurb: "Luxury vinyl plank, fast install." }, ]}, { key: "backyard", label: "Backyard", roiPct: 4, contractors: [ { name: "Citrus Outdoor Living", price: 38000, rating: 4.8, reviews: 46, blurb: "Pools, pavers, outdoor kitchens." }, { name: "Hernando Landscape Design", price: 18500, rating: 4.7, reviews: 59, blurb: "Decking, fire pits, planting design." }, { name: "Simple Yard Solutions", price: 7200, rating: 4.3, reviews: 33, blurb: "Sod, fencing, basic hardscape." }, ]}, { key: "frontyard", label: "Front Yard", roiPct: 2, contractors: [ { name: "Curb Appeal Co.", price: 9800, rating: 4.7, reviews: 51, blurb: "Full landscape redesign, lighting." }, { name: "GreenLine Lawn & Design", price: 5200, rating: 4.6, reviews: 44, blurb: "Beds, mulch, seasonal color." }, { name: "Weekend Warrior Landscaping", price: 2400, rating: 4.1, reviews: 22, blurb: "Trim, edge, mulch refresh." }, ]}, { key: "bedrooms", label: "Bedrooms", roiPct: 3, contractors: [ { name: "Whole Home Interiors", price: 12500, rating: 4.8, reviews: 39, blurb: "Closets, trim carpentry, paint, flooring." }, { name: "Fresh Coat Painting & Trim", price: 4800, rating: 4.6, reviews: 67, blurb: "Paint, baseboards, ceiling fans." }, { name: "ClosetCraft", price: 2600, rating: 4.5, reviews: 29, blurb: "Custom closet systems only." }, ]}, ]; const SERVICING_CATEGORIES = [ { key: "plumbing", label: "Plumbing", contractors: [ { name: "West Central Plumbing Pros", price: 1200, rating: 4.9, reviews: 210, blurb: "Repipe, water heaters, emergency calls." }, { name: "Citrus County Plumbing", price: 650, rating: 4.7, reviews: 140, blurb: "Repairs, fixture install, drain cleaning." }, { name: "QuickFix Plumbing", price: 280, rating: 4.4, reviews: 95, blurb: "Small jobs, same-day service." }, ]}, { key: "hvac", label: "HVAC", contractors: [ { name: "Suncoast Air Systems", price: 8200, rating: 4.9, reviews: 176, blurb: "Full system replacement, 10-yr warranty." }, { name: "Pasco Heating & Cooling", price: 3400, rating: 4.7, reviews: 122, blurb: "Repairs, tune-ups, duct work." }, { name: "Cool Breeze Maintenance", price: 190, rating: 4.6, reviews: 88, blurb: "Seasonal maintenance plans." }, ]}, { key: "cleaning", label: "Home Cleaning", contractors: [ { name: "Spotless Estate Cleaners", price: 320, rating: 4.9, reviews: 154, blurb: "Deep clean, move-in/move-out." }, { name: "Hernando Home Cleaning Co.", price: 160, rating: 4.7, reviews: 118, blurb: "Bi-weekly standard cleaning." }, { name: "Budget Maid Service", price: 90, rating: 4.3, reviews: 61, blurb: "Basic recurring cleaning." }, ]}, { key: "yardwork", label: "Yard Work", contractors: [ { name: "Gulf to Bay Grounds Crew", price: 220, rating: 4.8, reviews: 99, blurb: "Full-service lawn, trimming, edging." }, { name: "Weekly Mow Co.", price: 90, rating: 4.6, reviews: 143, blurb: "Standard mow & blow, weekly." }, { name: "DIY Assist Yard Help", price: 45, rating: 4.2, reviews: 27, blurb: "Single visit, small yards." }, ]}, { key: "paver", label: "Paver Cleaning & Sealing", contractors: [ { name: "Paver Restoration Specialists", price: 1450, rating: 4.9, reviews: 58, blurb: "Clean, re-sand, and seal, driveway + patio." }, { name: "Clean Sweep Pressure Washing", price: 620, rating: 4.6, reviews: 74, blurb: "Pressure wash only, no sealing." }, { name: "Handy Paver Care", price: 310, rating: 4.3, reviews: 21, blurb: "Small patio areas." }, ]}, ]; /* ---------------- COMPANY DIRECTORY + COMPS (radius search, seeded by signup address) ---------------- Curated contractors expand into a full simulated directory; comps are also generated relative to the entered address. A real version needs a reviews API (Google Places/Yelp) plus geocoding for both. */ function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = (h << 5) - h + s.charCodeAt(i); h |= 0; } return h >>> 0; } function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const NAME_PREFIXES = ["Gulf Coast", "Suncoast", "West Central", "Hernando", "Pasco", "Citrus County", "Tampa Bay", "Bay Area", "Gold Coast", "Coastal", "Heritage", "Reliable", "Premier", "Family", "Elite", "Trusted", "Sunshine State", "Bayshore", "Palm Harbor", "Clearwater"]; const NAME_SUFFIXES = ["Services", "Solutions", "Pros", "Co.", "Specialists", "Experts", "Group", "Contractors", "LLC", "Crew"]; function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24) || "company"; } function makeDirectory(cat, address) { const rng = mulberry32(hashStr(cat.key + "|" + address)); const basePrice = cat.contractors.reduce((s, c) => s + c.price, 0) / cat.contractors.length; const list = cat.contractors.map((c, i) => ({ ...c, distance: +(0.4 + i * 0.7).toFixed(1) })); for (let i = list.length; i < 15; i++) { const prefix = NAME_PREFIXES[Math.floor(rng() * NAME_PREFIXES.length)]; const suffix = NAME_SUFFIXES[Math.floor(rng() * NAME_SUFFIXES.length)]; const name = rng() < 0.45 ? `${prefix} ${cat.label} ${suffix}` : `${prefix} ${suffix}`; const price = Math.max(50, Math.round((basePrice * (0.45 + rng() * 1.4)) / 10) * 10); const rating = Math.min(5, +(3.5 + rng() * 1.5).toFixed(1)); const reviews = Math.round(6 + rng() * 260); const distance = +(1 + rng() * 49).toFixed(1); list.push({ name, price, rating, reviews, distance, blurb: "Licensed & insured, serving your area." }); } return list.map(c => ({ ...c, website: `https://www.${slugify(c.name)}.com` })); } const ALL_CATEGORIES = [...IMPROVEMENT_CATEGORIES, ...SERVICING_CATEGORIES]; function buildDirectory(address) { const dir = {}; ALL_CATEGORIES.forEach((cat) => { dir[cat.key] = makeDirectory(cat, address); }); return dir; } /* ---------------- SELLING? — agents & brokerages directory ---------------- */ const REALTOR_FIRST_NAMES = ["Sarah", "Michael", "Jessica", "David", "Amanda", "Chris", "Lauren", "Brian", "Nicole", "Kevin", "Rachel", "Tom", "Priya", "Marcus", "Elena"]; const REALTOR_LAST_NAMES = ["Whitfield", "Delgado", "Carrington", "Okafor", "Bianchi", "Sutton", "Reyes", "Hartley", "Kowalski", "Marsh", "Devane", "Alvarez"]; const FIRM_SUFFIXES = ["Realty Group", "Real Estate Partners", "Properties", "Realty Co.", "Home Group", "Realty Advisors", "Real Estate Team"]; const SELLING_FEATURED = [ { name: "Whitfield & Delgado Realty Group", rating: 4.9, reviews: 142, blurb: "Full-service brokerage — listings, staging, and closing support." }, { name: "Sarah Bianchi, Realtor", rating: 4.9, reviews: 88, blurb: "Top producer the last 5 years, free home valuation." }, { name: "Coastal Home Partners", rating: 4.7, reviews: 205, blurb: "Large team brokerage, pro photography & marketing included." }, { name: "Michael Okafor, Realtor", rating: 4.8, reviews: 64, blurb: "Boutique approach, hands-on through the whole sale." }, ]; function makeSellingDirectory(address) { const rng = mulberry32(hashStr("selling|" + address)); const list = SELLING_FEATURED.map((c, i) => ({ ...c, distance: +(0.3 + i * 0.6).toFixed(1) })); for (let i = list.length; i < 15; i++) { const isAgent = rng() < 0.5; const name = isAgent ? `${REALTOR_FIRST_NAMES[Math.floor(rng() * REALTOR_FIRST_NAMES.length)]} ${REALTOR_LAST_NAMES[Math.floor(rng() * REALTOR_LAST_NAMES.length)]}, Realtor` : `${NAME_PREFIXES[Math.floor(rng() * NAME_PREFIXES.length)]} ${FIRM_SUFFIXES[Math.floor(rng() * FIRM_SUFFIXES.length)]}`; const rating = Math.min(5, +(3.6 + rng() * 1.4).toFixed(1)); const reviews = Math.round(10 + rng() * 220); const distance = +(1 + rng() * 49).toFixed(1); const blurb = isAgent ? "Independent listing agent, free home valuation." : "Full-service brokerage — listings, staging, and closing support."; list.push({ name, rating, reviews, distance, blurb }); } return list.map(c => ({ ...c, website: `https://www.${slugify(c.name)}.com` })); } function buildComps(profile) { const rng = mulberry32(hashStr(profile.address + "|comps")); const labels = ["Next door", "Across the street", "0.2 mi away", "0.3 mi away", "0.4 mi away"]; const styles = ["Similar style, updated kitchen", "Similar style, original condition", "Similar style, remodeled bath", "Similar style, full remodel", "Similar style, updated flooring"]; const comps = []; for (let i = 0; i < 5; i++) { const priceFactor = 0.82 + rng() * 0.5; const sqftFactor = 0.85 + rng() * 0.3; comps.push({ addr: labels[i], beds: 3 + Math.round(rng()), baths: 2 + (rng() < 0.4 ? 1 : 0), sqft: Math.max(900, Math.round((profile.sqft || 1800) * sqftFactor / 10) * 10), style: styles[i], sold: Math.max(80000, Math.round(((profile.purchasePrice || 300000) * 1.4 * priceFactor) / 100) * 100), }); } return comps; } function getCategory(key) { return ALL_CATEGORIES.find((c) => c.key === key); } /* ---------------- INSURANCE — "shop for better rates" directory ---------------- */ const INSURANCE_FEATURED = [ { name: "Suncoast Homeowners Insurance", rating: 4.8, reviews: 312, blurb: "Local carrier, strong hurricane/wind coverage, fast claims." }, { name: "Bayshore Mutual", rating: 4.6, reviews: 189, blurb: "Bundles home + auto, competitive multi-policy discount." }, { name: "Heritage Insurance Group", rating: 4.5, reviews: 401, blurb: "Large regional carrier, online quote in minutes." }, { name: "Palm Harbor Underwriters", rating: 4.7, reviews: 96, blurb: "Independent agency, shops multiple carriers for you." }, ]; function makeInsuranceDirectory(address) { const rng = mulberry32(hashStr("insurance|" + address)); const list = INSURANCE_FEATURED.map((c, i) => ({ ...c, distance: +(0.5 + i * 0.8).toFixed(1) })); const suffixes = ["Insurance", "Insurance Group", "Underwriters", "Mutual", "Insurance Partners"]; for (let i = list.length; i < 12; i++) { const name = `${NAME_PREFIXES[Math.floor(rng() * NAME_PREFIXES.length)]} ${suffixes[Math.floor(rng() * suffixes.length)]}`; const rating = Math.min(5, +(3.6 + rng() * 1.4).toFixed(1)); const reviews = Math.round(15 + rng() * 350); const distance = +(1 + rng() * 49).toFixed(1); list.push({ name, rating, reviews, distance, blurb: "Licensed home insurance carrier serving your area." }); } return list.map(c => ({ ...c, website: `https://www.${slugify(c.name)}.com` })); } /* ---------------- NEIGHBORHOOD FORUM ---------------- */ const POST_CATEGORIES = [ { key: "sale", label: "For Sale", emoji: "🏷️", color: "var(--brass)", bg: "#DCC48A55" }, { key: "moved", label: "Just Moved In", emoji: "🏠", color: "var(--sage)", bg: "var(--sageSoft)" }, { key: "ask", label: "Ask Neighbors", emoji: "❓", color: "var(--slate)", bg: "var(--paperDeep)" }, { key: "event", label: "Neighborhood Event", emoji: "🎉", color: "var(--rust)", bg: "#A6512D22" }, ]; const SEED_POSTS = [ { id: "p1", category: "event", author: "Dana @ San Miguel", body: "Trick-or-treating on San Nicholas + San Miguel this year is Oct 31, 6-8pm — porch light on if you're handing out candy!", minutesAgo: 40 }, { id: "p2", category: "sale", author: "Marcus", body: "Selling a barely-used patio dining set, seats 6. $180 OBO, pickup on San Nicholas.", minutesAgo: 130 }, { id: "p3", category: "ask", author: "Priya", body: "Anyone know a good rec soccer league for a 7-year-old around here? Just moved from out of state.", minutesAgo: 300 }, { id: "p4", category: "moved", author: "The Osei Family", body: "Just moved onto San Miguel last week — excited to meet everyone! Say hi if you see us walking the dog.", minutesAgo: 620 }, ]; function timeAgo(m) { if (m < 60) return `${m}m ago`; if (m < 1440) return `${Math.round(m/60)}h ago`; return `${Math.round(m/1440)}d ago`; } /* ---------------- SMALL COMPONENTS ---------------- */ function TabButton({ active, onClick, icon, children }) { return ( ); } function ContractorRow({ c, rank }) { return (
{rank}
{c.name}
{c.blurb}{c.distance != null ? ` · ${c.distance} mi away` : ""}
★ {c.rating} ({c.reviews})
); } function CategoryCard({ cat, directory, onClick }) { const companies = directory[cat.key]; const avgRating = (companies.reduce((s, c) => s + c.rating, 0) / companies.length).toFixed(1); return ( ); } function CategoryDetail({ catKey, directory, address, token, onBack }) { const cat = getCategory(catKey); const [radius, setRadius] = useState(25); const [sortBy, setSortBy] = useState("rating"); const live = usePlacesDirectory(catKey, address, token); const companies = live.status === "live" ? live.places : (directory[catKey] || []); const filtered = companies.filter(c => c.distance <= radius); const sorted = [...filtered].sort((a, b) => { if (sortBy === "rating") return b.rating - a.rating || b.reviews - a.reviews; return a.distance - b.distance; }); return (
{cat.label}
{live.status === "loading" ? "checking…" : live.status === "live" ? "● live (Google)" : "sample data"}
{"roiPct" in cat && ~{cat.roiPct}% typical value-add}

{sorted.length} companies within {radius} mi, sorted by {sortBy === "rating" ? "reviews" : sortBy}.

{radius} mi
setRadius(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--brass)" }} />
{[["rating", "Reviews"], ["distance", "Distance"]].map(([key, label]) => ( ))}
{sorted.length === 0 &&
No companies within {radius} mi — try widening the radius.
} {sorted.map((c, i) => )}

{live.status === "live" ? "Live results from Google Places, biased to a radius around your home." : "Company list, ratings, distances, and website links are simulated for this demo. Connect GOOGLE_PLACES_API_KEY in myhome-api for real data."}

); } /* ---------------- TAB: THINKING OF SELLING? ---------------- */ function SellingTab({ home, token }) { const [radius, setRadius] = useState(25); const [sortBy, setSortBy] = useState("rating"); const simulated = useMemo(() => makeSellingDirectory(home.address), [home.address]); const live = usePlacesDirectory("selling", home.address, token); const directory = live.status === "live" ? live.places : simulated; const filtered = directory.filter(c => c.distance <= radius); const sorted = [...filtered].sort((a, b) => { if (sortBy === "rating") return b.rating - a.rating || b.reviews - a.reviews; return a.distance - b.distance; }); return (
🔑
Thinking of Selling?
{live.status === "loading" ? "checking…" : live.status === "live" ? "● live (Google)" : "sample data"}

Top agents & brokerages near {home.address}, ranked by reviews.

{radius} mi
setRadius(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--brass)" }} />
{[["rating", "Reviews"], ["distance", "Distance"]].map(([key, label]) => ( ))}
{sorted.length === 0 &&
No agents within {radius} mi — try widening the radius.
} {sorted.map((c, i) => )}

{live.status === "live" ? "Live results from Google Places, biased to a radius around your home." : "Agent/brokerage list, ratings, and website links are simulated for this demo. In production, this list would be hand-picked established firms with a real Google review history rather than open self-listing — longevity and review volume are hard to fake. Worth knowing: strong reviews vouch for the firm, not that a specific individual is currently licensed — that only matters if agents can ever create their own profiles here rather than being curated in."}

); } /* ---------------- VAULT UNLOCK GATE — Face ID/Touch ID or password step-up required every time Home Vault opens, on top of the normal session. Lives as component state (not lifted to root), so leaving and returning to this tab re-locks it automatically. ---------------- */ function VaultUnlockGate({ propertyId, authToken, children }) { const [vaultToken, setVaultToken] = useState(null); const [mode, setMode] = useState("choose"); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); async function unlockWithFaceId() { setError(""); setLoading(true); try { const optionsRes = await fetch(`${API_BASE_URL}/api/auth/webauthn/vault-unlock-options`, { method: "POST", headers: { Authorization: `Bearer ${authToken}` } }); const options = await optionsRes.json(); if (!optionsRes.ok) { setError(options.error || "Face ID isn't set up for this account yet."); setLoading(false); return; } const assertion = await SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: options }); const verifyRes = await fetch(`${API_BASE_URL}/api/auth/webauthn/vault-unlock-verify`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${authToken}` }, body: JSON.stringify(assertion), }); const data = await verifyRes.json(); if (!verifyRes.ok) { setError(data.error || "Couldn't verify Face ID / Touch ID."); setLoading(false); return; } setVaultToken(data.vaultToken); } catch (err) { setError("Face ID / Touch ID didn't complete."); } setLoading(false); } async function unlockWithPassword() { setError(""); setLoading(true); try { const r = await fetch(`${API_BASE_URL}/api/auth/vault-unlock-password`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${authToken}` }, body: JSON.stringify({ password }), }); const data = await r.json(); if (!r.ok) { setError(data.error || "Incorrect password."); setLoading(false); return; } setVaultToken(data.vaultToken); } catch (err) { setError("Couldn't reach the backend — is myhome-api running?"); } setLoading(false); } if (vaultToken) return children(vaultToken); if (!authToken) { return (
🔒
Home Vault needs the backend

Vault's Face ID gate and encryption only work with myhome-api running — you're currently in local demo mode.

); } return (
🫆
Unlock Home Vault

Every time Home Vault opens, we ask you to prove it's really you — no exceptions, even within the same session.

{mode === "choose" ? (
) : (
setPassword(e.target.value)} onKeyDown={e => { if (e.key === "Enter") unlockWithPassword(); }} placeholder="Your account password" autoComplete="current-password" style={{ fontSize: 13, width: "100%", padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--card)" }} />
)} {error &&
{error}
}
); } /* ---------------- TAB: HOME VAULT ---------------- */ const VAULT_CATEGORIES = [ { key: "deed", label: "Deed & Title" }, { key: "warranty", label: "Warranties" }, { key: "manual", label: "Manuals" }, { key: "inspection", label: "Inspections" }, { key: "receipt", label: "Receipts" }, { key: "other", label: "Other" }, ]; function VaultTab({ propertyId, token }) { const [documents, setDocuments] = useState([]); const [status, setStatus] = useState("checking"); const [adding, setAdding] = useState(false); const [title, setTitle] = useState(""); const [category, setCategory] = useState("deed"); const [notes, setNotes] = useState(""); const [uploadingId, setUploadingId] = useState(null); const [downloadingId, setDownloadingId] = useState(null); useEffect(() => { let cancelled = false; if (!token) { setStatus("local-only"); return; } fetch(`${API_BASE_URL}/api/vault?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` } }) .then(r => r.json()) .then(data => { if (!cancelled && Array.isArray(data.documents)) { setDocuments(data.documents); setStatus("shared"); } else if (!cancelled) setStatus("local-only"); }) .catch(() => { if (!cancelled) setStatus("local-only"); }); return () => { cancelled = true; }; }, [propertyId, token]); // Two-step, matching the backend: create the metadata record first (so we // get its real server-assigned id back), then a file can be attached to // that id afterwards. async function submitAdd() { if (!title.trim()) return; const date = new Date().toISOString().slice(0, 10); const localDoc = { id: `local-${Date.now()}`, title: title.trim(), category, notes, date, hasFile: false }; if (status === "shared") { try { const r = await fetch(`${API_BASE_URL}/api/vault`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ propertyId, title: localDoc.title, category, notes, date }), }); const data = await r.json(); setDocuments(d => [(r.ok && data.document) ? data.document : localDoc, ...d]); } catch (err) { setDocuments(d => [localDoc, ...d]); } } else { setDocuments(d => [localDoc, ...d]); } setTitle(""); setNotes(""); setAdding(false); } async function uploadFile(docId, file) { if (!file || status !== "shared") return; setUploadingId(docId); try { const formData = new FormData(); formData.append("file", file); const r = await fetch(`${API_BASE_URL}/api/vault/${docId}/upload?propertyId=${encodeURIComponent(propertyId)}`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: formData, }); const data = await r.json(); if (r.ok) setDocuments(docs => docs.map(d => (d.id === docId ? { ...d, hasFile: true, fileName: data.fileName } : d))); } catch (err) { // best-effort — leave the record as-is if the upload fails } finally { setUploadingId(null); } } async function downloadFile(docId) { setDownloadingId(docId); try { const r = await fetch(`${API_BASE_URL}/api/vault/${docId}/download?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await r.json(); if (r.ok && data.url) window.open(data.url, "_blank", "noopener,noreferrer"); } catch (err) { // best-effort } finally { setDownloadingId(null); } } function deleteDoc(docId) { setDocuments(docs => docs.filter(d => d.id !== docId)); if (status === "shared" && !String(docId).startsWith("local-")) { fetch(`${API_BASE_URL}/api/vault/${docId}?propertyId=${encodeURIComponent(propertyId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }).catch(() => {}); } } return (
🗄️
Home Vault

Deed, warranties, manuals, inspection reports, receipts — everything you'd need once a year but really need then.

{adding ? (
{VAULT_CATEGORIES.map(c => ( ))}
setTitle(e.target.value)} placeholder="e.g. Original deed, HVAC warranty, home inspection 2024" style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />