diff --git a/app/components/NewInspectionWizard.test.tsx b/app/components/NewInspectionWizard.test.tsx index 8bee3c25..ecd03bf8 100644 --- a/app/components/NewInspectionWizard.test.tsx +++ b/app/components/NewInspectionWizard.test.tsx @@ -167,8 +167,12 @@ describe('NewInspectionWizard — guard test for client + buyer-agent payload', const addressInput = getByPlaceholderText(/123 Main|St.*City/i) as HTMLInputElement; fireEvent.change(addressInput, { target: { value: '123 Main Street' } }); - const selects = getAllByRole('combobox') as HTMLSelectElement[]; - fireEvent.change(selects[0], { target: { value: 'tpl-1' } }); + // Two comboboxes now exist on the property step: the address + // autocomplete (#198) and the template specifically. + const combos = getAllByRole('combobox') as HTMLElement[]; + const templateSelect = combos.find((el) => el.tagName === 'SELECT') as HTMLSelectElement; + fireEvent.change(templateSelect, { target: { value: 'tpl-1' } }); let buttons = getAllByRole('button') as HTMLButtonElement[]; let nextBtn = buttons.find((btn) => btn.textContent?.includes('Next')); diff --git a/app/components/NewInspectionWizard.tsx b/app/components/NewInspectionWizard.tsx index 9734e6b2..eaeae9bc 100644 --- a/app/components/NewInspectionWizard.tsx +++ b/app/components/NewInspectionWizard.tsx @@ -7,6 +7,7 @@ import { ServicesStep } from "./new-inspection/ServicesStep"; import { ScheduleStep } from "./new-inspection/ScheduleStep"; import { TeamStep } from "./new-inspection/TeamStep"; import { QuotaExceededPanel } from "./new-inspection/QuotaExceededPanel"; +import type { AddressSelection } from "~/routes/resources/places"; import { m } from "~/paraglide/messages"; function stepLabel(id: WizardStepId): string { @@ -94,6 +95,10 @@ export function NewInspectionWizard({ const [stepIdx, setStepIdx] = useState(0); const [propertyType, setPropertyType] = useState("single_family"); const [address, setAddress] = useState(""); + // #198 — structured, geocoded address captured when the inspector picks a + // Places suggestion. Cleared when they edit the text back to free-form, so we + // never persist stale coordinates against a hand-typed address. + const [addressSel, setAddressSel] = useState(null); const [templateId, setTemplateId] = useState(""); // Stores selected service IDs (matched against the tenant's services table). const [services, setServices] = useState>(new Set()); @@ -172,6 +177,7 @@ export function NewInspectionWizard({ setStepIdx(0); setPropertyType("single_family"); setAddress(""); + setAddressSel(null); setTemplateId(""); setTemplateQuery(""); setServices(new Set()); @@ -292,6 +298,17 @@ export function NewInspectionWizard({ setAgentDropdownOpen(false); } + // #198 — editing the address text by hand invalidates any previously picked + // Places suggestion (its coordinates no longer describe what's typed). + function handleAddressChange(v: string) { + setAddress(v); + if (addressSel) setAddressSel(null); + } + function handleAddressSelect(sel: AddressSelection) { + setAddressSel(sel); + setAddress(sel.formatted); + } + if (!open) return null; const toggleService = (id: string) => { @@ -368,6 +385,17 @@ export function NewInspectionWizard({ intent: "create", propertyType, address, + // #198 — structured geocoded address (empty strings when the inspector + // typed a free-form address the API couldn't match; the server stamps + // addressGeocodedAt itself). + addressPlaceId: addressSel?.placeId ?? "", + addressStreet: addressSel?.street ?? "", + addressCity: addressSel?.city ?? "", + addressState: addressSel?.state ?? "", + addressZip: addressSel?.zip ?? "", + addressCounty: addressSel?.county ?? "", + addressLat: addressSel?.lat != null ? String(addressSel.lat) : "", + addressLng: addressSel?.lng != null ? String(addressSel.lng) : "", templateId, serviceIds: [...services].join(","), serviceSelectionsJson, @@ -420,7 +448,10 @@ export function NewInspectionWizard({ propertyType={propertyType} setPropertyType={setPropertyType} address={address} - setAddress={setAddress} + setAddress={handleAddressChange} + onAddressSelect={handleAddressSelect} + addressLat={addressSel?.lat} + addressLng={addressSel?.lng} templates={templates} templateId={templateId} setTemplateId={setTemplateId} diff --git a/app/components/address/AddressAutocomplete.test.tsx b/app/components/address/AddressAutocomplete.test.tsx new file mode 100644 index 00000000..162a924e --- /dev/null +++ b/app/components/address/AddressAutocomplete.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { useState } from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { AddressAutocomplete } from "./AddressAutocomplete"; +import type { AddressSelection } from "~/routes/resources/places"; + +const SAMPLE: AddressSelection = { + placeId: "p1", + formatted: "123 Main St, Austin, TX 78701", + street: "123 Main St", + city: "Austin", + state: "TX", + zip: "78701", + county: "Travis", + lat: 30.26, + lng: -97.74, +}; + +function mountWith(onSelect: (s: AddressSelection) => void) { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => { + const [val, setVal] = useState(""); + return ; + }, + }, + { + path: "/resources/places", + loader: ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("placeId")) { + return { suggestions: [], address: SAMPLE }; + } + return { + suggestions: [ + { placeId: "p1", description: "123 Main St, Austin, TX", mainText: "123 Main St", secondaryText: "Austin, TX" }, + ], + address: null, + }; + }, + }, + ]); + return render(); +} + +describe("AddressAutocomplete", () => { + it("shows suggestions while typing and emits a structured selection on click", async () => { + const onSelect = vi.fn(); + mountWith(onSelect); + + fireEvent.change(screen.getByRole("combobox"), { target: { value: "123 Main" } }); + + const option = await waitFor(() => screen.getByText("123 Main St")); + fireEvent.mouseDown(option); + + await waitFor(() => + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ placeId: "p1", lat: expect.any(Number), lng: expect.any(Number) }), + ), + ); + }); + + it("does not query for inputs shorter than 2 characters", async () => { + const onSelect = vi.fn(); + mountWith(onSelect); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "1" } }); + // Give any (incorrectly scheduled) debounce time to fire. + await new Promise((r) => setTimeout(r, 300)); + expect(screen.queryByRole("listbox")).toBeNull(); + }); +}); diff --git a/app/components/address/AddressAutocomplete.tsx b/app/components/address/AddressAutocomplete.tsx new file mode 100644 index 00000000..71828a85 --- /dev/null +++ b/app/components/address/AddressAutocomplete.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; +import { useFetcher } from "react-router"; +import type { AddressSelection, PlaceSuggestion } from "~/routes/resources/places"; +import { m } from "~/paraglide/messages"; + +/** + * Address autocomplete input (Spec 5D B4, #198). Debounced suggestions from the + * `/resources/places` BFF, keyboard-navigable listbox, and a per-typing-session + * token so Google bills the whole autocomplete→details sequence once. + * + * Controlled: `value`/`onValueChange` own the free-text address (so a user can + * still type a free-form address the API can't match and submit it). `onSelect` + * fires only when a suggestion is resolved to a structured `AddressSelection`. + * + * Fail-soft: when GOOGLE_PLACES_API_KEY is unset the BFF returns no suggestions, + * so the dropdown simply never opens and this behaves as a plain text input. + */ +export function AddressAutocomplete({ + value, + onValueChange, + onSelect, + id = "property-address", + placeholder, +}: { + value: string; + onValueChange: (v: string) => void; + onSelect: (sel: AddressSelection) => void; + id?: string; + placeholder?: string; +}) { + const suggestFetcher = useFetcher<{ suggestions: PlaceSuggestion[] }>(); + const detailsFetcher = useFetcher<{ address: AddressSelection | null }>(); + + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const sessionRef = useRef(""); + const debounceRef = useRef | null>(null); + // Set the moment a suggestion is clicked; the details effect below consumes it + // so onSelect fires exactly once per resolved place (not on every re-render). + const pendingSelectRef = useRef(false); + + const suggestions = suggestFetcher.data?.suggestions ?? []; + + function ensureSession(): string { + if (!sessionRef.current) sessionRef.current = crypto.randomUUID(); + return sessionRef.current; + } + + function handleChange(next: string) { + onValueChange(next); + setActive(-1); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (next.trim().length < 2) { + setOpen(false); + return; + } + const session = ensureSession(); + debounceRef.current = setTimeout(() => { + suggestFetcher.load( + `/resources/places?q=${encodeURIComponent(next.trim())}&session=${encodeURIComponent(session)}`, + ); + setOpen(true); + }, 250); + } + + function choose(s: PlaceSuggestion) { + onValueChange(s.description); + setOpen(false); + setActive(-1); + pendingSelectRef.current = true; + const session = ensureSession(); + detailsFetcher.load( + `/resources/places?placeId=${encodeURIComponent(s.placeId)}&session=${encodeURIComponent(session)}`, + ); + } + + // When the details load settles, emit the structured selection once and start + // a fresh billing session for the next lookup. + useEffect(() => { + if (detailsFetcher.state !== "idle") return; + if (!pendingSelectRef.current) return; + const address = detailsFetcher.data?.address; + if (address) { + pendingSelectRef.current = false; + sessionRef.current = ""; // terminate the Google session token + onSelect(address); + } + // onSelect is a stable-enough callback from the caller; excluding it keeps + // this from re-firing on unrelated parent renders (RR fetcher convention). + }, [detailsFetcher.state, detailsFetcher.data]); + + function onKeyDown(e: React.KeyboardEvent) { + if (!open || suggestions.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % suggestions.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); + } else if (e.key === "Enter" && active >= 0) { + e.preventDefault(); + choose(suggestions[active]); + } else if (e.key === "Escape") { + setOpen(false); + setActive(-1); + } + } + + const listboxId = `${id}-listbox`; + + return ( +
+ 0} + aria-controls={listboxId} + aria-autocomplete="list" + autoComplete="off" + value={value} + placeholder={placeholder} + onChange={(e) => handleChange(e.target.value)} + onKeyDown={onKeyDown} + onFocus={() => value.trim().length >= 2 && suggestions.length > 0 && setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 120)} + className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[13px] focus:shadow-ih-focus outline-none" + /> + {open && suggestions.length > 0 && ( +
    + {suggestions.map((s, i) => ( +
  • { + e.preventDefault(); + choose(s); + }} + onMouseEnter={() => setActive(i)} + className={`px-3 py-2 cursor-pointer text-[13px] ${i === active ? "bg-ih-primary-tint text-ih-primary" : "text-ih-fg-2"}`} + > + {s.mainText} + {s.secondaryText && {s.secondaryText}} +
  • + ))} +
+ )} + {detailsFetcher.state === "loading" && ( +

{m.common_loading()}

+ )} +
+ ); +} diff --git a/app/components/address/GoogleMap.test.tsx b/app/components/address/GoogleMap.test.tsx new file mode 100644 index 00000000..ffd1cb1d --- /dev/null +++ b/app/components/address/GoogleMap.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; + +// Prevent the real SDK loader from injecting a