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);
+ });
+});