diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 49251a83..072bddb7 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import LaunchIcon from "@mui/icons-material/Launch"; import { Alert, Box, @@ -22,6 +21,8 @@ import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; import { useVendorRosterForm } from "./use-vendor-roster-form"; import { Text } from "@/components/ui/text"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; +import { mapsSearchUrl } from "@/lib/address/vendor-address"; import type { VendorCompanyRoster, VendorListItem, @@ -118,34 +119,43 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const address = [roster.address, roster.city, roster.state, roster.zip] - .filter(Boolean) - .join(", "); + // SH-271 replaced the separate "Open in Google Maps" row: the Street Address + // itself is the link now, and the location is derived from the structured + // parts rather than a stored URL. + const address = { street: roster.address, city: roster.city, state: roster.state }; + const mapsUrl = mapsSearchUrl(address); return ( - - Google Maps + Street Address - {roster.googleMapsUrl ? ( + {roster.address && mapsUrl ? ( - - Open in Google Maps + {roster.address} ) : ( - — + {roster.address || "—"} )} + + + + + + + + + ); } diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 1878b417..3d69562a 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -1,5 +1,6 @@ import { Controller, + useController, useFieldArray, useWatch, type Control, @@ -21,6 +22,8 @@ import { Typography, } from "@mui/material"; import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field"; +import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; import { VENDOR_NOTES_MAX_LENGTH, @@ -135,6 +138,9 @@ function CompanyFields({ onClearSelectedCompany, }: VendorRosterFormFieldsProps) { const notes = useWatch({ control, name: "notes" }) ?? ""; + const { field: streetField } = useController({ control, name: "address" }); + const { field: cityField } = useController({ control, name: "city" }); + const { field: stateField } = useController({ control, name: "state" }); return ( @@ -148,50 +154,57 @@ function CompanyFields({ onClearSelectedCompany={onClearSelectedCompany} /> - ( - - )} + { + // One pick fills all three. City and State are written through + // their own controllers so react-hook-form marks them dirty and + // revalidates — a plain assignment would be discarded on save. + streetField.onChange(parts.street); + cityField.onChange(parts.city); + stateField.onChange(parts.state); + }} + label="Street Address (required)" + required + error={Boolean(errors.address)} + helperText={ + errors.address?.message ?? + "Pick a suggestion to auto-fill City/State, or type your own." + } /> - - } + + - ( - - )} - /> - } + ( - - )} + name="zip" + render={({ field }) => } + /> + void; + /** Fired only when a candidate row is chosen, never while typing. */ + onSelect: (parts: AddressParts) => void; + label?: string; + placeholder?: string; + required?: boolean; + error?: boolean; + helperText?: string; + disabled?: boolean; +}; + +/** + * Street Address input with mocked City/State completion. + * + * `freeSolo` is the whole point: typing without picking a row is a valid way to + * finish the field. The candidates are a convenience for filling City and State + * in one action, not a constraint on what the address may be. + * + * Shared rather than local to Vendors because Sites needs the identical control + * when it is built (SH-273). + */ +export function AddressAutocompleteField({ + value, + onInputChange, + onSelect, + label = "Street Address", + placeholder = "Start typing the street address…", + required = false, + error = false, + helperText, + disabled = false, +}: AddressAutocompleteFieldProps) { + return ( + + freeSolo + disabled={disabled} + options={suggestAddresses(value)} + // The options are already derived from the input; letting MUI filter them + // again would drop every row whose label does not literally contain the + // typed text. + filterOptions={(options) => options} + inputValue={value} + onInputChange={(_event, next) => onInputChange(next)} + onChange={(_event, selected) => { + if (selected && typeof selected !== "string") { + onSelect({ street: selected.street, city: selected.city, state: selected.state }); + } + }} + getOptionLabel={(option) => (typeof option === "string" ? option : option.label)} + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx new file mode 100644 index 00000000..a9b14ad9 --- /dev/null +++ b/src/components/common/address-map-preview.tsx @@ -0,0 +1,48 @@ +import { Box } from "@mui/material"; +import { + buildAddressQuery, + hasCompleteAddress, + mapsEmbedUrl, + type AddressParts, +} from "@/lib/address/vendor-address"; + +type AddressMapPreviewProps = { + address: Partial; + height?: number; +}; + +/** + * Small inline map for a structured address. + * + * Uses Google's keyless `output=embed` form deliberately: SH-271 asks for a + * preview with no API key, so there is no Maps JavaScript API, no Places + * request and no billing account behind this. + * + * Renders nothing until Street, City and State are all present — a preview of a + * half-typed address points somewhere misleading, which is worse than no map. + */ +export function AddressMapPreview({ address, height = 160 }: AddressMapPreviewProps) { + if (!hasCompleteAddress(address)) return null; + + const query = buildAddressQuery(address); + return ( + + + + ); +} diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index e532a7ba..2c245b97 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -57,9 +57,12 @@ const baseCompanyFields = { companyContact: z.never().optional(), companyPhone: northAmericanPhone, email: optionalEmail, - address: z.string(), - city: z.string(), - state: z.string(), + // SH-271: the single combined Address line became three structured parts, + // all required. `zip` stays optional and is not shown in the form — the + // ticket scopes the visible set to Street/City/State. + address: z.string().trim().min(1, "Street Address is required"), + city: z.string().trim().min(1, "City is required"), + state: z.string().trim().min(1, "State is required"), zip: z.string(), googleMapsUrl: httpsUrl, notes: z diff --git a/src/lib/address/vendor-address.ts b/src/lib/address/vendor-address.ts new file mode 100644 index 00000000..f98dbcbd --- /dev/null +++ b/src/lib/address/vendor-address.ts @@ -0,0 +1,135 @@ +/** + * Structured address helpers shared by Vendors and, when it is built, Sites. + * + * The suggestion list is deliberately MOCKED — SH-271 asks for a real-feeling + * autocomplete with no Google Places call and no API key. The algorithm, + * candidate pool and copy are ported from the approved prototype so dev and + * design agree on what a dispatcher sees. + */ + +export type AddressParts = { + street: string; + city: string; + state: string; +}; + +export type AddressSuggestion = AddressParts & { + /** What the option row reads as: ", , ". */ + label: string; +}; + +/** Minimum characters before any suggestion appears. */ +export const ADDRESS_SUGGESTION_MIN_LENGTH = 3; + +/** How many candidates the field offers at most. */ +export const ADDRESS_SUGGESTION_LIMIT = 4; + +/** + * Candidate cities, ported verbatim from the prototype. + * + * These are plausible distribution-centre metros rather than a geocoder: the + * point is that a dispatcher sees a short, stable list to pick from, not that + * the address is real. + */ +const SUGGESTION_CITIES: ReadonlyArray> = [ + { city: "Dallas", state: "TX" }, + { city: "Indianapolis", state: "IN" }, + { city: "Memphis", state: "TN" }, + { city: "St. Louis", state: "MO" }, + { city: "Orlando", state: "FL" }, + { city: "Austin", state: "TX" }, + { city: "Huntsville", state: "AL" }, + { city: "Rockford", state: "IL" }, + { city: "Columbus", state: "OH" }, + { city: "Tampa", state: "FL" }, + { city: "Cleveland", state: "OH" }, + { city: "Atlanta", state: "GA" }, + { city: "Chicago", state: "IL" }, + { city: "Phoenix", state: "AZ" }, + { city: "Portland", state: "OR" }, + { city: "Jersey City", state: "NJ" }, + { city: "Denver", state: "CO" }, + { city: "Miami", state: "FL" }, +]; + +/** + * Deterministic index from a seed string. + * + * Deterministic on purpose: the same typed text must offer the same candidates + * every keystroke and every render, or the list reshuffles under the pointer + * while someone is trying to click a row. + */ +function seededIndex(seed: string, size: number): number { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash % size; +} + +/** + * Up to four candidates for what the dispatcher has typed so far. + * + * The typed text is always kept as the street; only City and State are + * proposed. Selecting a candidate fills all three, and typing without selecting + * leaves plain free text — the field never locks the user out of their own + * input. + */ +export function suggestAddresses(input: string): AddressSuggestion[] { + const street = input.trim(); + if (street.length < ADDRESS_SUGGESTION_MIN_LENGTH) return []; + + const seen = new Set(); + const suggestions: AddressSuggestion[] = []; + for ( + let attempt = 0; + suggestions.length < ADDRESS_SUGGESTION_LIMIT && attempt < SUGGESTION_CITIES.length; + attempt += 1 + ) { + const candidate = + SUGGESTION_CITIES[seededIndex(`${street}#${attempt}`, SUGGESTION_CITIES.length)]; + if (!candidate) continue; + const key = `${candidate.city},${candidate.state}`; + if (seen.has(key)) continue; + seen.add(key); + suggestions.push({ + label: `${street}, ${candidate.city}, ${candidate.state}`, + street, + city: candidate.city, + state: candidate.state, + }); + } + return suggestions; +} + +/** The single-line form Google is asked about. Empty when there is nothing to ask. */ +export function buildAddressQuery(parts: Partial): string { + return [parts.street, parts.city, parts.state] + .map((part) => part?.trim() ?? "") + .filter(Boolean) + .join(" "); +} + +/** Link target for the clickable Street Address. Empty string when unmappable. */ +export function mapsSearchUrl(parts: Partial): string { + const query = buildAddressQuery(parts); + return query + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}` + : ""; +} + +/** + * Embed URL for the inline preview. + * + * `output=embed` is the keyless form: it renders in an iframe with no API key, + * no billing account and no Places request, which is what SH-271 requires. + */ +export function mapsEmbedUrl(parts: Partial): string { + const query = buildAddressQuery(parts); + return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : ""; +} + +/** A preview is only meaningful once all three parts are present. */ +export function hasCompleteAddress(parts: Partial): boolean { + return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim()); +} diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index feb5f59b..e96fd031 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -51,9 +51,25 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, }); + // SH-271 made the structured address required. These tests are about the + // phone-or-email rule, so the address is filled to keep the form's only + // outstanding problem the one under test. + fillRequiredAddress(); await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); } +function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) { + fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), { + target: { value: street }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { + target: { value: city }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { + target: { value: state }, + }); +} + describe("VendorCreateModal validation", () => { it("explains the company phone-or-email requirement after submission", async () => { renderCreateModal(); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index d13a9174..8777567d 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,9 +49,11 @@ function rosterWith(technicians: Array>) { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", - address: "", - city: "", - state: "", + // SH-271: Street/City/State are required, so a saved roster carries them. + // Tests that need the incomplete case blank them explicitly. + address: "6735 Trippel Rd", + city: "Theodore", + state: "AL", zip: "", googleMapsUrl: "", notes: "", @@ -343,7 +345,9 @@ describe("VendorDetailDrawer design parity", () => { expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument(); }); - it("labels the Google Maps field below Address even when no URL is stored", () => { + it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => { + // SH-271 removed the "Open in Google Maps" row and the stored URL behind + // it; the location is derived from the structured address instead. useVendorCompanyRoster.mockReturnValue( rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), ); @@ -353,15 +357,37 @@ describe("VendorDetailDrawer design parity", () => { withAuth: false, }); - expect(screen.getByText("Google Maps")).toBeInTheDocument(); + expect(screen.getByText("Street Address")).toBeInTheDocument(); + expect(screen.getByText("City")).toBeInTheDocument(); + expect(screen.getByText("State")).toBeInTheDocument(); + expect(screen.queryByText("Google Maps")).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument(); }); - it("links out to Google Maps when a URL is stored", () => { + it("makes the street address itself the link to Google Maps", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByRole("link", { name: "6735 Trippel Rd" })).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL", + ); + }); + + it("renders no map preview and no link until the address is complete", () => { + // A preview built from half an address points somewhere misleading. const roster = rosterWith([ { id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }, ]); - roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy"; + roster.data.address = ""; + roster.data.city = ""; + roster.data.state = ""; useVendorCompanyRoster.mockReturnValue(roster); renderWithProviders(, { @@ -369,10 +395,8 @@ describe("VendorDetailDrawer design parity", () => { withAuth: false, }); - expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute( - "href", - "https://maps.google.com/?q=1+Industrial+Pkwy", - ); + expect(screen.queryByRole("link", { name: /Trippel/ })).not.toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); }); it("puts the status opposite Total Jobs and drops the redundant Status label", () => { diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index d5772d1a..37dde861 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -14,6 +14,11 @@ const baseCompany = { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", + // SH-271 made the structured address required, so a company that is meant to + // parse cleanly has to carry one. + address: "6735 Trippel Rd", + city: "Theodore", + state: "AL", }; describe("vendorCompanyRosterSchema", () => { @@ -56,9 +61,11 @@ describe("vendorCompanyRosterSchema", () => { it("accepts a company with zero technicians when phone is present", () => { const result = vendorCompanyRosterSchema.safeParse({ - ...emptyVendorCompanyRosterForm, + ...baseCompany, name: "Solo Co", + email: "", companyPhone: "(314) 555-0100", + technicians: [], }); expect(result.success).toBe(true); if (result.success) expect(result.data).not.toHaveProperty("companyContact"); diff --git a/src/test/lib/address/vendor-address.test.ts b/src/test/lib/address/vendor-address.test.ts new file mode 100644 index 00000000..4a2736dc --- /dev/null +++ b/src/test/lib/address/vendor-address.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + ADDRESS_SUGGESTION_LIMIT, + ADDRESS_SUGGESTION_MIN_LENGTH, + buildAddressQuery, + hasCompleteAddress, + mapsEmbedUrl, + mapsSearchUrl, + suggestAddresses, +} from "@/lib/address/vendor-address"; + +describe("suggestAddresses", () => { + it("offers nothing until the minimum length is reached", () => { + expect(suggestAddresses("")).toEqual([]); + expect(suggestAddresses("12")).toEqual([]); + expect(suggestAddresses(" 12 ")).toEqual([]); + expect(suggestAddresses("123").length).toBeGreaterThan(0); + }); + + it("caps the list so the dropdown stays scannable", () => { + expect(suggestAddresses("6735 Trippel Rd").length).toBeLessThanOrEqual( + ADDRESS_SUGGESTION_LIMIT, + ); + }); + + it("keeps the typed text as the street and only proposes city and state", () => { + const [first] = suggestAddresses("415 Murphy Rd"); + expect(first?.street).toBe("415 Murphy Rd"); + expect(first?.city).toBeTruthy(); + expect(first?.state).toBeTruthy(); + expect(first?.label).toBe(`415 Murphy Rd, ${first?.city}, ${first?.state}`); + }); + + it("is stable for the same input", () => { + // The list must not reshuffle between renders, or a row moves out from + // under the pointer mid-click. + expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd")); + }); + + it("never repeats a city and state pair", () => { + const suggestions = suggestAddresses("3050 Gateway Commerce Dr"); + const keys = suggestions.map((s) => `${s.city},${s.state}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("trims the typed text before using it", () => { + const [first] = suggestAddresses(" 1715 Wall Triana Hwy "); + expect(first?.street).toBe("1715 Wall Triana Hwy"); + }); + + it("exposes the minimum length it enforces", () => { + expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH))).not.toEqual([]); + expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH - 1))).toEqual([]); + }); +}); + +describe("address queries and links", () => { + const full = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" }; + + it("joins the parts it has and skips the ones it does not", () => { + expect(buildAddressQuery(full)).toBe("6735 Trippel Rd Theodore AL"); + expect(buildAddressQuery({ street: "", city: "Dallas", state: "TX" })).toBe("Dallas TX"); + expect(buildAddressQuery({})).toBe(""); + }); + + it("builds a search link and an embed link for the same address", () => { + expect(mapsSearchUrl(full)).toBe( + "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL", + ); + expect(mapsEmbedUrl(full)).toBe( + "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed", + ); + }); + + it("returns empty strings rather than a link to nowhere", () => { + // An href of "" renders as no link; a link to a bare maps search would look + // like an address we do not have. + expect(mapsSearchUrl({})).toBe(""); + expect(mapsEmbedUrl({ street: " " })).toBe(""); + }); + + it("needs all three parts before a preview is meaningful", () => { + expect(hasCompleteAddress(full)).toBe(true); + expect(hasCompleteAddress({ street: "6735 Trippel Rd", city: "Theodore", state: " " })).toBe( + false, + ); + expect(hasCompleteAddress({ street: "6735 Trippel Rd" })).toBe(false); + }); +});