// 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 (
{icon} {children}
);
}
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 (
{cat.label}
{companies.length} companies within 50 mi · avg {avgRating}★
{"roiPct" in cat && +{cat.roiPct}% }
›
);
}
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 (
‹ Back
{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}.
Search radius
{radius} mi
setRadius(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--brass)" }} />
{[["rating", "Reviews"], ["distance", "Distance"]].map(([key, label]) => (
setSortBy(key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${sortBy === key ? "var(--ink)" : "var(--line)"}`, background: sortBy === key ? "var(--ink)" : "transparent", color: sortBy === key ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>{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.
Search radius
{radius} mi
setRadius(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--brass)" }} />
{[["rating", "Reviews"], ["distance", "Distance"]].map(([key, label]) => (
setSortBy(key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${sortBy === key ? "var(--ink)" : "var(--line)"}`, background: sortBy === key ? "var(--ink)" : "transparent", color: sortBy === key ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>{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" ? (
Use Face ID / Touch ID
{ setMode("password"); setError(""); }} style={{ fontSize: 14, fontWeight: 600, padding: "10px 16px", background: "transparent", border: "1px solid var(--line)", color: "var(--slateMute)", borderRadius: 8 }}>
Use password instead
) : (
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)" }} />
Unlock
{ setMode("choose"); setError(""); }} style={{ fontSize: 12, fontWeight: 600, color: "var(--slateMute)", background: "none", border: "none" }}>Back
)}
{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 (
Deed, warranties, manuals, inspection reports, receipts — everything you'd need once a year but really need then.
{adding ? (
{VAULT_CATEGORIES.map(c => (
setCategory(c.key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${category === c.key ? "var(--brass)" : "var(--line)"}`, background: category === c.key ? "#DCC48A33" : "transparent", color: category === c.key ? "var(--brass)" : "var(--slateMute)", fontWeight: category === c.key ? 600 : 500 }}>{c.label}
))}
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)" }} />
) : (
setAdding(true)} style={{ width: "100%", fontSize: 13, fontWeight: 600, padding: "12px 16px", marginBottom: 18, border: "1px dashed var(--line)", borderRadius: 12, color: "var(--slateMute)", background: "transparent" }}>
+ Add a record
)}
{VAULT_CATEGORIES.map(cat => {
const items = documents.filter(d => d.category === cat.key);
if (items.length === 0) return null;
return (
{cat.label}
{items.map(d => (
{d.title}
{d.date}{d.notes ? ` · ${d.notes}` : ""}
deleteDoc(d.id)} title="Delete record" style={{ background: "transparent", border: "none", padding: 4, color: "var(--slateMute)", cursor: "pointer", flexShrink: 0 }}>🗑️
{d.hasFile ? (
downloadFile(d.id)} disabled={downloadingId === d.id}
style={{ fontSize: 11, fontWeight: 600, padding: "4px 10px", borderRadius: 999, border: "1px solid var(--line)", color: "var(--brass)", background: "transparent" }}>
⬇ {downloadingId === d.id ? "Opening…" : (d.fileName || "Download file")}
) : status === "shared" && !String(d.id).startsWith("local-") ? (
{ if (e.target.files[0]) uploadFile(d.id, e.target.files[0]); e.target.value = ""; }} />
📎 {uploadingId === d.id ? "Uploading…" : "Attach file"}
) : null}
))}
);
})}
{documents.length === 0 && !adding &&
No records yet — add your first one above.
}
{status === "shared"
? "Records are saved to your account, and you can attach a real file to any record — stored privately via Supabase Storage, only ever accessed through short-lived signed links."
: "Local preview only — records and file attachments won't persist until connected to myhome-api."}
);
}
/* ---------------- TAB: HOME TIMELINE ---------------- */
const TIMELINE_CATEGORIES = [
{ key: "roof", label: "Roof" }, { key: "hvac", label: "HVAC" }, { key: "water_heater", label: "Water Heater" },
{ key: "pool", label: "Pool" }, { key: "landscaping", label: "Landscaping" }, { key: "insurance", label: "Insurance" },
{ key: "repair", label: "Repair" }, { key: "maintenance", label: "Maintenance" }, { key: "warranty", label: "Warranty" },
{ key: "permit", label: "Permit" }, { key: "memory", label: "Home Memory" }, { key: "other", label: "Other" },
];
const TIMELINE_STATUSES = ["completed", "scheduled", "in progress"];
function blankTimelineForm() {
return { category: "maintenance", title: "", date: new Date().toISOString().slice(0, 10), cost: "", company: "", notes: "", status: "completed", warrantyExpires: "" };
}
function TimelineTab({ propertyId, token }) {
const [events, setEvents] = useState([]);
const [status, setStatus] = useState("checking");
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(blankTimelineForm());
useEffect(() => {
let cancelled = false;
if (!token) { setStatus("local-only"); return; }
fetch(`${API_BASE_URL}/api/timeline?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(data => { if (!cancelled && Array.isArray(data.events)) { setEvents(data.events); setStatus("shared"); } else if (!cancelled) setStatus("local-only"); })
.catch(() => { if (!cancelled) setStatus("local-only"); });
return () => { cancelled = true; };
}, [propertyId, token]);
function openAdd() { setForm(blankTimelineForm()); setEditingId(null); setFormOpen(true); }
function openEdit(event) {
setForm({ category: event.category, title: event.title, date: event.date, cost: event.cost || "", company: event.company || "", notes: event.notes || "", status: event.status || "completed", warrantyExpires: event.warrantyExpires || "" });
setEditingId(event.id);
setFormOpen(true);
}
function submitForm() {
if (!form.title.trim() || !form.date) return;
const payload = { ...form, cost: form.cost ? Number(form.cost) : null };
if (editingId) {
setEvents(evts => evts.map(e => e.id === editingId ? { ...e, ...payload } : e));
if (status === "shared") {
fetch(`${API_BASE_URL}/api/timeline/${editingId}`, {
method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ propertyId, ...payload }),
}).catch(() => {});
}
} else {
const localEvent = { id: `local-${Date.now()}`, ...payload };
setEvents(evts => [localEvent, ...evts]);
if (status === "shared") {
fetch(`${API_BASE_URL}/api/timeline`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ propertyId, ...payload }),
}).catch(() => {});
}
}
setFormOpen(false); setEditingId(null);
}
function deleteEvent(id) {
setEvents(evts => evts.filter(e => e.id !== id));
if (status === "shared") {
fetch(`${API_BASE_URL}/api/timeline/${id}?propertyId=${encodeURIComponent(propertyId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }).catch(() => {});
}
}
const filtered = events
.filter(e => categoryFilter === "all" || e.category === categoryFilter)
.filter(e => {
const q = search.trim().toLowerCase();
if (!q) return true;
return e.title.toLowerCase().includes(q) || (e.company || "").toLowerCase().includes(q) || (e.notes || "").toLowerCase().includes(q);
})
.sort((a, b) => new Date(b.date) - new Date(a.date));
return (
Every roof, repair, and receipt — the permanent record of this home.
{formOpen ? (
{editingId ? "Edit event" : "Add an event"}
{TIMELINE_CATEGORIES.map(c => (
setForm(f => ({ ...f, category: c.key }))} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${form.category === c.key ? "var(--brass)" : "var(--line)"}`, background: form.category === c.key ? "#DCC48A33" : "transparent", color: form.category === c.key ? "var(--brass)" : "var(--slateMute)", fontWeight: form.category === c.key ? 600 : 500 }}>{c.label}
))}
setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. New roof installed, HVAC annual service"
style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setForm(f => ({ ...f, date: e.target.value }))}
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setForm(f => ({ ...f, cost: e.target.value }))} placeholder="Cost"
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setForm(f => ({ ...f, company: e.target.value }))} placeholder="Company (optional)"
style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setForm(f => ({ ...f, status: e.target.value }))}
style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }}>
{TIMELINE_STATUSES.map(s => {s} )}
setForm(f => ({ ...f, warrantyExpires: e.target.value }))}
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
) : (
+ Add an event
)}
setSearch(e.target.value)} placeholder="Search title, company, or notes..."
style={{ fontSize: 13, width: "100%", marginBottom: 12, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--card)" }} />
setCategoryFilter("all")} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${categoryFilter === "all" ? "var(--ink)" : "var(--line)"}`, background: categoryFilter === "all" ? "var(--ink)" : "transparent", color: categoryFilter === "all" ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>All
{TIMELINE_CATEGORIES.map(c => (
setCategoryFilter(c.key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${categoryFilter === c.key ? "var(--ink)" : "var(--line)"}`, background: categoryFilter === c.key ? "var(--ink)" : "transparent", color: categoryFilter === c.key ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>{c.label}
))}
{filtered.length === 0 &&
{events.length === 0 ? "No events yet — add your first one above." : "Nothing matches that search/filter."}
}
{filtered.map(e => {
const cat = TIMELINE_CATEGORIES.find(c => c.key === e.category) || TIMELINE_CATEGORIES[TIMELINE_CATEGORIES.length - 1];
return (
{cat.label}
{e.date}
openEdit(e)} style={{ background: "none", border: "none", color: "var(--slateMute)" }}>✏️
deleteEvent(e.id)} style={{ background: "none", border: "none", color: "var(--rust)" }}>🗑️
{e.title}
{e.company && `${e.company} · `}{e.cost != null && e.cost !== "" && `${money(Number(e.cost))} · `}{e.status}
{e.warrantyExpires && ` · warranty until ${e.warrantyExpires}`}
{e.notes &&
{e.notes}
}
);
})}
{status === "shared" ? "Timeline is saved to your account." : "Local preview only — events won't persist until connected to myhome-api."} Photos/document attachments are metadata-only for now.
);
}
/* ---------------- TAB: INSURANCE ---------------- */
function InsuranceTab({ home, propertyId, token }) {
const [policy, setPolicy] = useState(null);
const [editing, setEditing] = useState(false);
const [carrier, setCarrier] = useState("");
const [annualPremium, setAnnualPremium] = useState("");
const [coverageAmount, setCoverageAmount] = useState("");
const [deductible, setDeductible] = useState("");
const [renewalDate, setRenewalDate] = useState("");
const [radius, setRadius] = useState(25);
const [sortBy, setSortBy] = useState("rating");
const simulatedDirectory = useMemo(() => makeInsuranceDirectory(home.address), [home.address]);
const live = usePlacesDirectory("insurance", home.address, token);
const directory = live.status === "live" ? live.places : simulatedDirectory;
const filtered = directory.filter(c => c.distance <= radius);
const sorted = [...filtered].sort((a, b) => sortBy === "rating" ? (b.rating - a.rating || b.reviews - a.reviews) : (a.distance - b.distance));
useEffect(() => {
if (!token) return;
fetch(`${API_BASE_URL}/api/insurance-policy?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data && data.policy) setPolicy(data.policy); })
.catch(() => {});
}, [propertyId, token]);
function savePolicy() {
const p = { carrier: carrier.trim(), annualPremium: Number(annualPremium) || null, coverageAmount: Number(coverageAmount) || null, deductible: Number(deductible) || null, renewalDate: renewalDate || null };
setPolicy(p);
setEditing(false);
if (token) {
fetch(`${API_BASE_URL}/api/insurance-policy`, {
method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ propertyId, ...p }),
}).catch(() => {});
}
}
return (
Track your current policy, and shop reviewed carriers near you.
{policy && !editing ? (
Current policy
{policy.carrier}
{policy.annualPremium ? `${money(policy.annualPremium)}/yr` : "premium not set"}
{policy.coverageAmount ? ` · ${money(policy.coverageAmount)} coverage` : ""}
{policy.deductible ? ` · ${money(policy.deductible)} deductible` : ""}
{policy.renewalDate ? ` · renews ${policy.renewalDate}` : ""}
{ setCarrier(policy.carrier); setAnnualPremium(policy.annualPremium || ""); setCoverageAmount(policy.coverageAmount || ""); setDeductible(policy.deductible || ""); setRenewalDate(policy.renewalDate || ""); setEditing(true); }} style={{ fontSize: 11, fontWeight: 600, color: "var(--brassSoft)", background: "none", border: "none", marginTop: 10, padding: 0 }}>Edit
) : (
{policy ? "Edit policy" : "Add your current policy"}
setCarrier(e.target.value)} placeholder="Carrier name"
style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "none", borderRadius: 8, background: "#26313F", color: "#fff" }} />
setAnnualPremium(e.target.value)} placeholder="Annual premium"
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "none", borderRadius: 8, background: "#26313F", color: "#fff" }} />
setCoverageAmount(e.target.value)} placeholder="Coverage amount"
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "none", borderRadius: 8, background: "#26313F", color: "#fff" }} />
setDeductible(e.target.value)} placeholder="Deductible"
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "none", borderRadius: 8, background: "#26313F", color: "#fff" }} />
setRenewalDate(e.target.value)}
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "none", borderRadius: 8, background: "#26313F", color: "#fff" }} />
Save
{policy && setEditing(false)} style={{ fontSize: 13, fontWeight: 600, padding: "8px 16px", background: "transparent", color: "#9AA5B1", border: "1px solid #3A4552", borderRadius: 8 }}>Cancel }
)}
Shop for better rates
{live.status === "loading" ? "checking…" : live.status === "live" ? "● live (Google)" : "sample data"}
Search radius
{radius} mi
setRadius(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--brass)" }} />
{[["rating", "Reviews"], ["distance", "Distance"]].map(([key, label]) => (
setSortBy(key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${sortBy === key ? "var(--ink)" : "var(--line)"}`, background: sortBy === key ? "var(--ink)" : "transparent", color: sortBy === key ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>{label}
))}
{sorted.map((c, i) => )}
{live.status === "live" ? "Live results from Google Places, biased to a radius around your home." : "Carrier list is simulated for this demo. A real version needs a licensed insurance-comparison API or partnership, since insurance quotes can't legally be estimated without real underwriting data."}
);
}
function MiniLineChart({ data }) {
const w = 320, h = 170, pad = 28;
const vals = data.map(d => d.value);
const min = Math.min(...vals), max = Math.max(...vals);
const pts = data.map((d, i) => {
const x = pad + (i / (data.length - 1)) * (w - pad * 1.2);
const y = h - pad - ((d.value - min) / (max - min || 1)) * (h - pad * 1.6);
return [x, y];
});
const linePath = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0] + "," + p[1]).join(" ");
const areaPath = `${linePath} L${pts[pts.length - 1][0]},${h - pad} L${pts[0][0]},${h - pad} Z`;
const gradId = "miniChartGrad-" + Math.random().toString(36).slice(2, 8);
const strokeId = "miniChartStroke-" + Math.random().toString(36).slice(2, 8);
return (
{[0.25, 0.5, 0.75].map((f, i) => (
))}
{pts.map((p, i) => )}
{data.map((d, i) => (
{d.date.getFullYear()}
))}
);
}
/* ---------------- TAB: HOME DASHBOARD ---------------- */
function DashboardTab({ home, live, propertyId, token, season, onNavigate, onOpenChat }) {
const [recentEvents, setRecentEvents] = useState([]);
const [recentPosts, setRecentPosts] = useState([]);
const [policy, setPolicy] = useState(null);
const [attentionEvents, setAttentionEvents] = useState([]);
useEffect(() => {
if (!token) return;
fetch(`${API_BASE_URL}/api/timeline?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !Array.isArray(data.events)) return;
const sorted = [...data.events].sort((a, b) => new Date(b.date) - new Date(a.date));
setRecentEvents(sorted.slice(0, 3));
setAttentionEvents(data.events.filter(e => e.status && e.status !== "completed").slice(0, 3));
})
.catch(() => {});
fetch(`${API_BASE_URL}/api/insurance-policy?propertyId=${encodeURIComponent(propertyId)}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data && data.policy) setPolicy(data.policy); })
.catch(() => {});
fetch(`${API_BASE_URL}/api/posts`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data && Array.isArray(data.posts)) setRecentPosts(data.posts.slice(0, 2)); })
.catch(() => {});
}, [propertyId, token]);
const currentValue = live.status === "live" ? live.value : home.sampleCurrentValue;
const equity = currentValue - home.purchasePrice;
const monthChange = currentValue - home.valueAtDaysAgo(30);
const yearChange = currentValue - home.valueAtDaysAgo(365);
const daysUntilRenewal = policy && policy.renewalDate ? Math.round((new Date(policy.renewalDate) - new Date()) / 86400000) : null;
return (
Welcome back 👋
{home.address}
Estimated home value
{money(currentValue)}
{monthChange >= 0 ? "↑" : "↓"} {money(Math.abs(monthChange))} this month
{yearChange >= 0 ? "↑" : "↓"} {money(Math.abs(yearChange))} this year
Estimated equity
{money(equity)}
Value over time
onNavigate("equity")} style={{ fontSize: 11, fontWeight: 600, color: "var(--brass)", background: "none", border: "none", marginTop: 4 }}>See full equity breakdown
✨
Ask anything about your home…
Blockstead AI knows your equity, timeline, and insurance
{season && (
{season.emoji}
{season.label} checkup
{season.tip}
)}
{attentionEvents.length > 0 && (
Needs your attention
{attentionEvents.map(e => (
onNavigate("timeline")} style={{ width: "100%", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center", padding: 12, marginBottom: 8, background: "var(--card)", borderRadius: 10, border: "1px solid var(--line)" }}>
{e.title}
{e.status}
))}
{daysUntilRenewal != null && daysUntilRenewal >= 0 && daysUntilRenewal <= 45 && (
onNavigate("insurance")} style={{ width: "100%", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center", padding: 12, background: "var(--card)", borderRadius: 10, border: "1px solid var(--line)" }}>
Insurance renews soon
{daysUntilRenewal} days
)}
)}
Recent activity
onNavigate("timeline")} style={{ fontSize: 11, fontWeight: 600, color: "var(--brass)", background: "none", border: "none" }}>View all
{recentEvents.length === 0 &&
Nothing logged yet — add your first event in Home Timeline.
}
{recentEvents.map(e => (
))}
Neighborhood
onNavigate("neighborhood")} style={{ fontSize: 11, fontWeight: 600, color: "var(--brass)", background: "none", border: "none" }}>View all
{recentPosts.length === 0 &&
No recent posts.
}
{recentPosts.map(p => (
))}
);
}
/* ---------------- TAB: EQUITY ---------------- */
function EquityTab({ home, live }) {
const liveToday = live.status === "live" ? live.value : null;
const rows = [
...WINDOWS.map((w) => {
const value = w.label === "Today" && liveToday != null ? liveToday : home.valueAtDaysAgo(w.days);
return { ...w, value, gain: value - home.purchasePrice };
}),
{ label: "Since purchase", value: home.purchasePrice, gain: 0 },
];
const current = rows[0].value;
const equity = current - home.purchasePrice;
return (
Current estimated equity
{live.status === "loading" ? "checking…" : live.status === "live" ? "● live" : "sample data"}
{money(equity)}
Home value {money(current)} · purchased {money(home.purchasePrice)} on {home.purchaseDate.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
{rows.map((r) => (
{r.label}
= 0 ? "var(--sage)" : "var(--rust)" }}>
{money(r.value)}
))}
{live.status === "live"
? "Today's value is live from your configured data proxy. Older windows are still interpolated between the purchase price and today's value."
: `Showing sample/interpolated figures for ${home.address} — the live data proxy at ${API_BASE_URL} isn't reachable or configured yet. Run myhome-api and add a provider key to switch this to real data.`}
);
}
/* ---------------- TAB: CPA ---------------- */
function useMarketAnalysis(token, propertyId) {
const [state, setState] = useState({ status: "idle", text: "" });
async function generate(promptContext) {
setState({ status: "loading", text: "" });
if (!token) {
setState({ status: "error", text: "This needs the backend (myhome-api) running with ANTHROPIC_API_KEY configured — see README.md." });
return;
}
try {
const message = `Write a short (3-4 sentence) market analysis for this home. Never guarantee future prices — frame everything as an informational estimate, not a promise, and say so explicitly. Data: ${promptContext}`;
const response = await fetch(`${API_BASE_URL}/api/assistant`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ message, propertyId, currentTab: "cpa" }),
});
const data = await response.json();
if (!response.ok) {
setState({ status: "error", text: data.error ? `Assistant unavailable: ${data.error}` : "Couldn't generate an analysis right now." });
return;
}
setState({ status: "done", text: data.reply || "Couldn't generate an analysis right now." });
} catch (err) {
setState({ status: "error", text: "Couldn't reach the analysis service." });
}
}
return [state, generate];
}
function CPATab({ home, live, token, propertyId }) {
const [sqft, setSqft] = useState(home.sqftDefault || 1800);
const [selected, setSelected] = useState({});
const [mortgageBalance, setMortgageBalance] = useState("");
const simulatedComps = useMemo(() => buildComps(home), [home.address, home.purchasePrice, home.sqftDefault]);
const liveCompsState = useLiveComps(home.address, token);
const comps = liveCompsState.status === "live" ? liveCompsState.comps.map(adaptRentcastComp) : simulatedComps;
const validComps = comps.filter(c => c.sqft && c.sold);
const [analysis, generateAnalysis] = useMarketAnalysis(token, propertyId);
const currentValue = live.status === "live" ? live.value : home.sampleCurrentValue;
const avgPpsf = validComps.length ? validComps.reduce((s, c) => s + c.sold / c.sqft, 0) / validComps.length : 0;
const avgSoldPrice = validComps.length ? validComps.reduce((s, c) => s + c.sold, 0) / validComps.length : 0;
const avgSqft = validComps.length ? validComps.reduce((s, c) => s + c.sqft, 0) / validComps.length : 0;
const compBasedEstimate = Math.round(avgPpsf * sqft);
const homePpsf = sqft ? currentValue / sqft : 0;
const valueIncrease = currentValue - home.purchasePrice;
const roiTotal = IMPROVEMENT_CATEGORIES.reduce((s, c) => (selected[c.key] ? s + c.roiPct : s), 0);
const projectedValue = Math.round(currentValue * (1 + roiTotal / 100));
// Forward-looking forecast, using the same appreciation rate implied by the interpolated equity curve.
const yearsOwned = Math.max(home.totalDaysOwned / 365.25, 0.1);
const annualRate = Math.pow(currentValue / home.purchasePrice, 1 / yearsOwned) - 1;
const forecast = [1, 3, 5].map(yrs => ({ years: yrs, value: Math.round(currentValue * Math.pow(1 + annualRate, yrs)) }));
const mortgage = Number(mortgageBalance) || 0;
const closingCosts = Math.round(currentValue * 0.08);
const netProceeds = currentValue - mortgage - closingCosts;
function runAnalysis() {
const context = `Home at ${home.address}, ~${sqft} sqft, estimated value ${money(currentValue)}, purchased ${money(home.purchasePrice)}. Nearby comps average ${money(Math.round(avgSoldPrice))} at $${avgPpsf.toFixed(0)}/sqft (home is at $${homePpsf.toFixed(0)}/sqft). Home has appreciated at roughly ${(annualRate * 100).toFixed(1)}% per year since purchase.`;
generateAnalysis(context);
}
return (
{/* Hero card */}
🏢
Comparable Properties
{money(currentValue)}
Estimated home value
{[
{ label: "Value increase", value: money(valueIncrease) },
{ label: "Purchased", value: `${money(home.purchasePrice)} · ${home.purchaseDate.getFullYear()}` },
{ label: "Price / sqft", value: `$${homePpsf.toFixed(0)}` },
{ label: "Market trend", value: annualRate >= 0 ? "Up" : "Down", icon: annualRate >= 0 },
].map(item => (
{item.label}
{"icon" in item && {item.icon ? "↑" : "↓"} }
{item.value}
))}
{/* Nearby comps */}
Comparable properties nearby
{liveCompsState.status === "loading" ? "checking…" : liveCompsState.status === "live" ? "● live (RentCast)" : "sample data"}
{liveCompsState.status === "live" ? "Real sales comparables from RentCast." : "Similar style & size, sold within the past 6 months. Simulated for this demo — connect RENTCAST_API_KEY in myhome-api for real comps."}
{comps.map((c) => (
{c.addr}
{c.style} · {c.beds}bd/{c.baths}ba · {c.sqft.toLocaleString()} sqft
{money(c.sold)}
))}
Avg price/sqft nearby: ${avgPpsf.toFixed(0)}/sqft
{/* Your home vs. similar homes */}
Your home vs. similar homes
Square footage
setSqft(Number(e.target.value) || 0)}
className="fx-mono" style={{ fontSize: 14, marginTop: 6, marginBottom: 12, padding: "8px 10px", width: 110, border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
{[
{ label: "Estimated value", mine: currentValue, avg: avgSoldPrice, format: money },
{ label: "Price / sqft", mine: homePpsf, avg: avgPpsf, format: n => `$${n.toFixed(0)}` },
{ label: "Square footage", mine: sqft, avg: avgSqft, format: n => Math.round(n).toLocaleString() },
].map(row => (
{row.label}
{row.format(row.mine)}
= row.avg ? "var(--sage)" : "var(--rust)", fontSize: 12 }}>{row.mine >= row.avg ? "↑" : "↓"}
avg {row.format(row.avg)}
))}
{/* AI market analysis */}
AI-generated insight, not a guarantee of future value.
{analysis.status === "idle" && (
✨ Generate analysis
)}
{analysis.status === "loading" &&
Thinking…
}
{(analysis.status === "done" || analysis.status === "error") && (
{analysis.text}
🔁 Regenerate
)}
{/* Renovation impact */}
If you renovate
Select planned projects for a projected value.
{IMPROVEMENT_CATEGORIES.map((c) => (
setSelected(s => ({ ...s, [c.key]: e.target.checked }))} />
{c.label}
+{c.roiPct}%
))}
Projected value
{money(projectedValue)}
vs. comps avg
= compBasedEstimate ? "#8FD19E" : "#E3A98F" }}>
{projectedValue >= compBasedEstimate ? "+" : ""}{money(projectedValue - compBasedEstimate)}
{/* Equity calculator */}
Equity calculator
Mortgage balance
setMortgageBalance(e.target.value)} placeholder="0"
className="fx-mono" style={{ fontSize: 14, marginTop: 6, marginBottom: 12, padding: "8px 10px", width: 140, border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
Estimated home value {money(currentValue)}
Mortgage payoff -{money(mortgage)}
Estimated closing costs (~8%) -{money(closingCosts)}
Estimated cash if sold today
{money(Math.max(netProceeds, 0))}
{/* Home value forecast */}
Home value forecast
Based on this home's appreciation since purchase. Estimate only — not guaranteed.
Today
{money(currentValue)}
{forecast.map(f => (
{f.years} yr{f.years > 1 ? "s" : ""}
{money(f.value)}
))}
);
}
/* ---------------- TAB: NEIGHBORHOOD ---------------- */
function NeighborhoodTab() {
const [posts, setPosts] = useState(SEED_POSTS);
const [status, setStatus] = useState("checking");
const [filter, setFilter] = useState("all");
const [draft, setDraft] = useState("");
const [author, setAuthor] = useState("");
const [category, setCategory] = useState("ask");
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE_URL}/api/posts`)
.then(r => r.json())
.then(data => {
if (cancelled) return;
if (Array.isArray(data.posts)) { setPosts(data.posts); setStatus("shared"); }
else setStatus("local-only");
})
.catch(() => { if (!cancelled) setStatus("local-only"); });
return () => { cancelled = true; };
}, []);
function submitPost() {
if (!draft.trim()) return;
const post = { id: `local-${Date.now()}`, category, author: author.trim() || "Neighbor", body: draft.trim(), minutesAgo: 0 };
setPosts(p => [post, ...p]);
setDraft("");
if (status === "shared") {
fetch(`${API_BASE_URL}/api/posts`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(post),
}).catch(() => {});
}
}
const visible = filter === "all" ? posts : posts.filter(p => p.category === filter);
return (
{status === "checking" ? "checking…" : status === "shared" ? "● shared with the neighborhood" : "local preview only — posts won't sync until connected"}
Post something
{POST_CATEGORIES.map(c => (
setCategory(c.key)} style={{
fontSize: 12, padding: "6px 12px", borderRadius: 999,
border: `1px solid ${category === c.key ? c.color : "var(--line)"}`,
background: category === c.key ? c.bg : "transparent",
color: category === c.key ? c.color : "var(--slateMute)",
fontWeight: category === c.key ? 600 : 500,
}}>{c.emoji} {c.label}
))}
setAuthor(e.target.value)}
style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setFilter("all")} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${filter === "all" ? "var(--ink)" : "var(--line)"}`, background: filter === "all" ? "var(--ink)" : "transparent", color: filter === "all" ? "#fff" : "var(--slateMute)", fontWeight: 600 }}>All
{POST_CATEGORIES.map(c => (
setFilter(c.key)} style={{ fontSize: 12, padding: "6px 12px", borderRadius: 999, border: `1px solid ${filter === c.key ? c.color : "var(--line)"}`, background: filter === c.key ? c.bg : "transparent", color: filter === c.key ? c.color : "var(--slateMute)", fontWeight: filter === c.key ? 600 : 500 }}>{c.emoji} {c.label}
))}
{visible.length === 0 &&
No posts in this category yet.
}
{visible.map(p => {
const cat = POST_CATEGORIES.find(c => c.key === p.category) || POST_CATEGORIES[2];
return (
{cat.emoji} {cat.label}
{timeAgo(p.minutesAgo)}
{p.body}
— {p.author}
);
})}
{status === "shared"
? "Posts are shared through your configured backend."
: "Local preview — wiring this to myhome-api's shared storage lets the whole neighborhood see the same feed. A real launch also needs basic moderation and abuse reporting."}
);
}
/* ---------------- PAYWALL (simulated — no real billing wired up) ---------------- */
/* ---------------- AI ASSISTANT — calls myhome-api's /api/assistant, which
needs a real ANTHROPIC_API_KEY (with billing) in the backend's .env.
Unlike the Claude-artifact version, this is a normal paid API call made
by a normal server — nothing free about it. ---------------- */
function buildAssistantContext(home, directory) {
const topPicks = {};
["kitchen", "plumbing", "hvac"].forEach(key => {
const list = directory && directory[key];
if (list && list.length) {
const best = [...list].sort((a, b) => b.rating - a.rating)[0];
topPicks[key] = `${best.name} (${best.rating}★, ${best.reviews} reviews)`;
}
});
return { address: home.address, sqft: home.sqft, purchasePrice: home.purchasePrice, estimatedValue: home.sampleCurrentValue, topRatedProviders: topPicks };
}
function AssistantChat({ home, directory, token, propertyId, currentTab, open, setOpen }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const suggestions = ["How much equity do I have?", "What maintenance is coming up?", "Explain what I'm looking at on this screen."];
async function send(text) {
const userText = (text || input).trim();
if (!userText || loading) return;
const nextMessages = [...messages, { role: "user", content: userText }];
setMessages(nextMessages);
setInput("");
setLoading(true);
if (!token) {
setMessages(m => [...m, { role: "assistant", content: "The assistant needs the backend running with a real ANTHROPIC_API_KEY configured — see myhome-api/README.md." }]);
setLoading(false);
return;
}
try {
const r = await fetch(`${API_BASE_URL}/api/assistant`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ message: userText, history: messages, homeContext: buildAssistantContext(home, directory), propertyId, currentTab }),
});
const data = await r.json();
if (!r.ok) {
setMessages(m => [...m, { role: "assistant", content: data.error === undefined ? "Something went wrong." : `Assistant unavailable: ${data.error}` }]);
} else {
setMessages(m => [...m, { role: "assistant", content: data.reply || "Sorry, I couldn't get a response just now." }]);
}
} catch (err) {
setMessages(m => [...m, { role: "assistant", content: "Couldn't reach the assistant backend — is myhome-api running?" }]);
}
setLoading(false);
}
return (
<>
setOpen(o => !o)} style={{
position: "fixed", bottom: 20, right: 20, width: 56, height: 56, borderRadius: 999,
background: "var(--ink)", color: "#fff", boxShadow: "0 8px 24px rgba(28,36,48,0.35)", zIndex: 50, border: "none",
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22,
}}>
{open ? "✕" : "✨"}
{open && (
{messages.length === 0 && (
Ask about your home, find something in Blockstead, or get help understanding this screen.
{suggestions.map(s => (
send(s)} style={{ fontSize: 12, width: "100%", textAlign: "left", padding: "8px 12px", marginBottom: 8, background: "var(--card)", border: "1px solid var(--line)", borderRadius: 8, color: "var(--slate)" }}>{s}
))}
)}
{messages.map((m, i) => (
{m.content}
))}
{loading &&
Thinking…
}
setInput(e.target.value)} onKeyDown={e => { if (e.key === "Enter") send(); }} placeholder="Ask a question..."
style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--card)" }} />
send()} disabled={loading} style={{ width: 38, height: 38, borderRadius: 8, background: "var(--brass)", border: "none", opacity: loading ? 0.6 : 1, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }}>➤
)}
>
);
}
function PaywallGate({ tier, requiredTier, onUnlockStandard, onUnlockPro, children }) {
const unlocked = requiredTier === "pro" ? tier === "pro" : (tier === "standard" || tier === "pro");
if (unlocked) return children;
const isPro = requiredTier === "pro";
return (
{children}
🔒
{isPro ? "Unlock Blockstead Pro" : "Unlock full access"}
{isPro ? "Track every property you own — a portfolio view and a full dashboard for each home." : "Full equity history, complete company directory, comps, and the neighborhood feed."}
{isPro ? "Upgrade to Pro — $19.99/mo" : "Subscribe — $9.99/mo"}
);
}
/* ---------------- TAB: PORTFOLIO (Blockstead Pro) ---------------- */
function PortfolioTab({ properties, activeId, onSelect, onAdd, error }) {
const [adding, setAdding] = useState(false);
const [address, setAddress] = useState("");
const [purchasePrice, setPurchasePrice] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [sqft, setSqft] = useState("");
const [submitting, setSubmitting] = useState(false);
const canAdd = address.trim() && purchasePrice && purchaseDate && !submitting;
async function submitAdd() {
setSubmitting(true);
await onAdd({ address: address.trim(), purchasePrice: Number(purchasePrice), purchaseDate, sqft: Number(sqft) || 0 });
setSubmitting(false);
setAddress(""); setPurchasePrice(""); setPurchaseDate(""); setSqft(""); setAdding(false);
}
return (
My Portfolio
{properties.length} {properties.length === 1 ? "property" : "properties"} · tap one to open its full dashboard.
{properties.map((p) => {
const model = buildHomeModel(p);
const equity = model.sampleCurrentValue - model.purchasePrice;
return (
onSelect(p.id)} style={{ width: "100%", textAlign: "left", display: "flex", alignItems: "center", justifyContent: "space-between", background: "var(--card)", borderRadius: 12, padding: "16px 18px", border: `1px solid ${p.id === activeId ? "var(--brass)" : "var(--line)"}` }}>
{p.address}
Purchased {money(model.purchasePrice)} · est. equity {money(equity)}
{p.id === activeId && active }
›
);
})}
{adding ? (
Add a property
setAddress(e.target.value)} placeholder="123 Main St, City, State ZIP"
style={{ fontSize: 13, width: "100%", marginBottom: 8, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setPurchasePrice(e.target.value)} placeholder="Purchase price"
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setPurchaseDate(e.target.value)}
className="fx-mono" style={{ fontSize: 13, flex: 1, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
setSqft(e.target.value)} placeholder="Square footage"
className="fx-mono" style={{ fontSize: 13, width: "100%", marginBottom: 12, padding: "8px 10px", border: "1px solid var(--line)", borderRadius: 8, background: "var(--paper)" }} />
{submitting ? "Saving…" : "Add property"}
setAdding(false)} style={{ fontSize: 13, fontWeight: 600, padding: "8px 16px", background: "transparent", border: "1px solid var(--line)", color: "var(--slateMute)", borderRadius: 8 }}>Cancel
) : (
setAdding(true)} style={{ width: "100%", fontSize: 13, fontWeight: 600, padding: "12px 16px", border: "1px dashed var(--line)", borderRadius: 12, color: "var(--slateMute)", background: "transparent" }}>
+ Add a property
)}
{error &&
{error}
}
);
}
/* ---------------- AUTH — sign up / log in, wired to myhome-api's real
auth endpoints (bcrypt + JWT). Falls back to local-only demo mode if
the backend isn't reachable. This file uses a real