← back to Stayclaim
src/app/submit/SubmitFormClient.tsx
259 lines
'use client';
/**
* Single-search submit form.
*
* Replaces the previous 4-field address grid (street/city/state/zip) with
* one big search input that hits the live LA County records lookup
* (/api/la-records/lookup). The lookup auto-fills every canonical field
* from the assessor record AND shows a preview card so the user can see
* the value of the product before submitting.
*
* If LA records doesn't find a match (out of county, unusual address, typo),
* the user can still submit — we'll do a manual lookup on our side.
*/
import { useState, useEffect, useRef } from 'react';
import { SubmitMiniMap } from '@/components/SubmitMiniMap';
type LookupHit = {
ok: true;
found: true;
apn: string;
situs_address: string | null;
city: string | null;
zip: string | null;
year_built: number | null;
sqft_main: number | null;
bedrooms: number | null;
bathrooms: number | null;
use_description: string | null;
total_value: number | null;
roll_year: number | null;
lat: number | null;
lng: number | null;
};
type LookupState =
| { kind: 'idle' }
| { kind: 'looking' }
| { kind: 'hit'; data: LookupHit; query: string }
| { kind: 'miss' }
| { kind: 'err'; msg: string };
export function SubmitFormClient() {
const [query, setQuery] = useState('');
const [lookup, setLookup] = useState<LookupState>({ kind: 'idle' });
const [status, setStatus] = useState<'idle' | 'submitting' | 'ok' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reqIdRef = useRef(0);
// Debounced lookup as the user types
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.trim().length < 8) {
setLookup({ kind: 'idle' });
return;
}
const submittedQuery = query;
debounceRef.current = setTimeout(async () => {
const myReqId = ++reqIdRef.current;
setLookup({ kind: 'looking' });
try {
const r = await fetch(`/api/la-records/lookup?q=${encodeURIComponent(submittedQuery)}`);
if (myReqId !== reqIdRef.current) return; // stale response
if (!r.ok) { setLookup({ kind: 'err', msg: `HTTP ${r.status}` }); return; }
const j = await r.json();
if (j.found) setLookup({ kind: 'hit', data: j, query: submittedQuery });
else setLookup({ kind: 'miss' });
} catch (e) {
if (myReqId !== reqIdRef.current) return;
setLookup({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
}
}, 450);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query]);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('submitting');
setError(null);
const fd = new FormData(e.currentTarget);
const body: Record<string, unknown> = Object.fromEntries(fd.entries());
// If the lookup hit, attach the canonical assessor data so the server
// doesn't have to re-resolve. If miss, body just carries the free-form query.
// /api/submit requires city + state. Pull them from the LA County hit
// (situs_address format: "<street> <CITY> CA <ZIP>") or default to LA/CA.
// Use parsed parcel fields directly (city/zip are returned by /api/la-records/lookup
// from the SitusCity / SitusZIP attributes) — far more reliable than regexing
// the joined situs_address string.
// Only attach assessor data when the hit is for the CURRENT query — guards
// against stale-lookup race when user edits address within the debounce window.
if (lookup.kind === 'hit' && lookup.query === query) {
body.address_line1 = lookup.data.situs_address ?? query;
body.apn = lookup.data.apn;
body.year_built = lookup.data.year_built;
body.sqft_main = lookup.data.sqft_main;
body.lat = lookup.data.lat;
body.lng = lookup.data.lng;
body.city = lookup.data.city ?? 'Los Angeles';
body.state = 'CA';
body.postal_code = lookup.data.zip ?? null;
} else {
body.address_line1 = query;
body.city = 'Los Angeles';
body.state = 'CA';
}
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
setError(j.error ?? `HTTP ${res.status}`);
setStatus('error');
return;
}
setStatus('ok');
} catch (err) {
setError(err instanceof Error ? err.message : 'Network error — please try again.');
setStatus('error');
}
}
if (status === 'ok') {
return (
<div className="border-2 border-[#c9292e] bg-[#fdf9ee] p-6">
<p className="font-mono uppercase text-[10px] tracking-[0.2em] text-[#c9292e] mb-2">Filed</p>
<p className="font-display text-2xl text-ink">Submission queued.</p>
<p className="mt-2 text-sm text-ink/70 leading-relaxed">
We’ll cross-check against assessor data, fetch any available archival material, and notify
you when the page is published. Typically 1–3 business days for Beverly Hills addresses; longer
for outside the pilot area.
</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="grid gap-6">
{/* Single search bar — auto-resolves to LA County parcel record */}
<label className="flex flex-col gap-2">
<span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
Address
</span>
<input
required
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Type your address — e.g. 612 N Bedford Drive Beverly Hills"
autoComplete="street-address"
className="border-2 border-ink/30 px-4 py-3 text-base font-sans focus:outline-none focus:border-[#c9292e] bg-white"
/>
<span className="text-[11px] text-ink/50">
One field. We’ll resolve the city, ZIP, and assessor record from LA County in real time.
</span>
</label>
{/* Live preview card — only renders when lookup has something to say */}
{lookup.kind === 'looking' && (
<div className="border border-ink/15 bg-[#fdf9ee] px-4 py-3 text-xs font-mono uppercase tracking-[0.15em] text-ink/55">
Checking LA County records…
</div>
)}
{lookup.kind === 'hit' && (
<div className="border-l-[4px] border-[#c9292e] bg-white px-5 py-4 relative grid grid-cols-[1fr_auto] gap-5">
<div>
<span className="inline-block bg-[#c9292e] text-white px-2 py-0.5 text-[9px] tracking-[0.2em] font-mono uppercase font-bold mb-2">
Found · APN {lookup.data.apn}
</span>
<p className="font-sans font-bold text-base text-ink">
{lookup.data.situs_address}
</p>
<ul className="mt-2 space-y-0.5 text-[12px] font-mono text-ink/75">
{lookup.data.year_built && <li>· built {lookup.data.year_built}</li>}
{lookup.data.sqft_main && <li>· {lookup.data.sqft_main.toLocaleString()} sqft</li>}
{lookup.data.bedrooms != null && lookup.data.bedrooms > 0 && (
<li>· {lookup.data.bedrooms} bed{lookup.data.bathrooms != null ? ` / ${lookup.data.bathrooms} bath` : ''}</li>
)}
{lookup.data.use_description && <li>· {lookup.data.use_description.toLowerCase()}</li>}
{lookup.data.total_value && (
<li>· assessed at ${(lookup.data.total_value / 1_000_000).toFixed(2)}M ({lookup.data.roll_year} roll)</li>
)}
</ul>
<p className="mt-3 text-[11px] text-ink/55 italic">
Source: LA County Assessor (public record).
</p>
</div>
{lookup.data.lat != null && lookup.data.lng != null && (
<SubmitMiniMap
lat={lookup.data.lat}
lng={lookup.data.lng}
label={lookup.data.situs_address ?? ''}
/>
)}
</div>
)}
{lookup.kind === 'miss' && (
<div className="border border-dashed border-ink/30 bg-[#fdf9ee] px-4 py-3 text-[12px] text-ink/70">
We couldn’t auto-resolve that one against LA County. Submit anyway —
we’ll look it up by hand. Outside-LA addresses always go through the
manual queue.
</div>
)}
{lookup.kind === 'err' && (
<div className="border border-[#c9292e]/40 bg-[#c9292e]/5 px-4 py-3 text-[12px] text-[#c9292e]">
Lookup error: {lookup.msg}. You can still submit.
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
Your email
</span>
<input
required
type="email"
name="contact_email"
placeholder="you@example.com"
autoComplete="email"
className="border border-ink/20 px-4 py-3 text-sm font-sans focus:outline-none focus:border-[#c9292e] bg-white"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
Anything else? (optional)
</span>
<textarea
name="notes"
rows={3}
placeholder="History you know, architect, prior occupants — helps us prioritize and find the right archive sources."
className="border border-ink/20 px-4 py-3 text-sm font-sans focus:outline-none focus:border-[#c9292e] bg-white resize-none"
/>
</label>
<div className="flex items-center gap-4 flex-wrap">
<button
type="submit"
disabled={status === 'submitting' || query.trim().length < 8}
className="bg-[#c9292e] text-white px-6 py-3 text-xs uppercase tracking-[0.2em] font-mono font-bold hover:opacity-90 transition disabled:opacity-40"
>
{status === 'submitting' ? 'Submitting…' : 'Submit address'}
</button>
<span className="text-[11px] font-mono uppercase tracking-[0.15em] text-ink/45">
Free forever · no card · no signup
</span>
</div>
{error && (
<p className="text-xs text-[#c9292e]" role="alert">
{error}
</p>
)}
</form>
);
}