diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx
index 1f94d7fa..29be2a4f 100644
--- a/src/app/(protected)/locations/_components/site-form-fields.tsx
+++ b/src/app/(protected)/locations/_components/site-form-fields.tsx
@@ -27,7 +27,11 @@ export function SiteFormFields({
register,
formState: { errors },
} = form;
- const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
+ // ZIP is carried through unedited, so the preview shows the same line as the Street Address link.
+ const [address, city, state, zipCode] = useWatch({
+ control,
+ name: ["address", "city", "state", "zipCode"],
+ });
const { field: addressField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
@@ -84,7 +88,13 @@ export function SiteFormFields({
/>
-
+
;
-
-/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
-export function SiteMapPreview(props: SiteMapPreviewProps) {
- const complete = hasCompleteSiteAddress(props);
+type SiteMapPreviewProps = Pick & {
+ /**
+ * Add/Edit forms: hold the map back behind a placeholder until Street, City
+ * and State are all filled, so a half-typed address never points somewhere
+ * misleading. Saved sites (view mode, Work Order Site dialog) leave this off
+ * and always render the map for whatever address they have.
+ */
+ placeholderUntilComplete?: boolean;
+ /** Hide the "Location preview" caption where the map sits inside an info box. */
+ hideLabel?: boolean;
+ height?: number;
+};
+function MapPlaceholder({ height }: { height: number }) {
return (
-
-
- Location preview
+
+
+
+ Enter an address to preview the location
- {complete && (
-
- )}
- {!complete && (
-
-
- Enter an address to preview the location
-
-
- )}
+
+ );
+}
+
+/** Read-only key-less Google Maps embed of a site's address. */
+export function SiteMapPreview({
+ placeholderUntilComplete = false,
+ hideLabel = false,
+ height = 140,
+ ...address
+}: SiteMapPreviewProps) {
+ const src = buildSiteMapEmbedUrl(address);
+ const showMap = placeholderUntilComplete ? hasCompleteSiteAddress(address) : Boolean(src);
+
+ if (!showMap && !placeholderUntilComplete) {
+ return null;
+ }
+
+ return (
+
+ {!hideLabel && (
+
+ Location preview
+
+ )}
+ {showMap && (
+
+ )}
+ {!showMap && }
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
index da15ab57..253592ce 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
@@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({
city={locationDetail.city}
state={locationDetail.state}
zipCode={locationDetail.zipCode}
+ hideLabel
+ height={120}
/>
>
) : (
diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts
index 28a75792..bb4c1f18 100644
--- a/src/domain/locations/utils/site-maps.ts
+++ b/src/domain/locations/utils/site-maps.ts
@@ -1,23 +1,28 @@
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { Location } from "@/domain/locations/types/location";
+import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps";
+import { hasCompleteAddress } from "@/lib/address/vendor-address";
type SiteAddress = Pick;
/** Google Maps search link for a site address; opens in a new tab. */
export function buildSiteMapsUrl(location: SiteAddress): string {
- const query = formatLocationAddressPreview(location);
- return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
+ return googleMapsSearchUrl(formatLocationAddressPreview(location));
}
/** True once Street, City and State are all filled, so a map preview can render. */
export function hasCompleteSiteAddress(location: SiteAddress): boolean {
- return [location.address, location.city, location.state].every(
- (part) => (part?.trim() ?? "") !== "",
- );
+ return hasCompleteAddress({
+ street: location.address,
+ city: location.city,
+ state: location.state,
+ });
}
-/** Key-less Google Maps embed URL for the read-only preview. */
+/**
+ * Key-less Google Maps embed URL for the read-only preview, built from the same
+ * address line as the Street Address link. Empty when the site has no address.
+ */
export function buildSiteMapEmbedUrl(location: SiteAddress): string {
- const query = formatLocationAddressPreview(location);
- return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
+ return googleMapsEmbedUrl(formatLocationAddressPreview(location));
}
diff --git a/src/lib/address/google-maps.ts b/src/lib/address/google-maps.ts
new file mode 100644
index 00000000..3022c356
--- /dev/null
+++ b/src/lib/address/google-maps.ts
@@ -0,0 +1,28 @@
+/**
+ * Google Maps URLs shared by every address preview and address link.
+ *
+ * Each caller composes its own one-line address (Vendors join the parts with
+ * spaces, Sites use the same line as their Street Address link); these helpers
+ * only encode it into the URL shape Google expects. Both return an empty string
+ * for a blank query, so callers can skip rendering instead of pointing at an
+ * empty search.
+ */
+
+/** Link target that opens full Google Maps in a new tab. */
+export function googleMapsSearchUrl(query: string): string {
+ const trimmed = query.trim();
+ return trimmed
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(trimmed)}`
+ : "";
+}
+
+/**
+ * Iframe source for the read-only inline preview.
+ *
+ * `output=embed` is Google's keyless form: no API key, no billing account and
+ * no Places request, and the embed brings its own "View larger map" link.
+ */
+export function googleMapsEmbedUrl(query: string): string {
+ const trimmed = query.trim();
+ return trimmed ? `https://www.google.com/maps?q=${encodeURIComponent(trimmed)}&output=embed` : "";
+}
diff --git a/src/lib/address/vendor-address.ts b/src/lib/address/vendor-address.ts
index f98dbcbd..29834cdd 100644
--- a/src/lib/address/vendor-address.ts
+++ b/src/lib/address/vendor-address.ts
@@ -7,6 +7,8 @@
* design agree on what a dispatcher sees.
*/
+import { googleMapsEmbedUrl, googleMapsSearchUrl } from "./google-maps";
+
export type AddressParts = {
street: string;
city: string;
@@ -112,10 +114,7 @@ export function buildAddressQuery(parts: Partial): string {
/** 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)}`
- : "";
+ return googleMapsSearchUrl(buildAddressQuery(parts));
}
/**
@@ -125,8 +124,7 @@ export function mapsSearchUrl(parts: Partial): string {
* 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` : "";
+ return googleMapsEmbedUrl(buildAddressQuery(parts));
}
/** A preview is only meaningful once all three parts are present. */
diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx
index a9b6c328..d416b5e6 100644
--- a/src/test/app/(protected)/locations/add-site-dialog.test.tsx
+++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx
@@ -115,6 +115,22 @@ describe("AddSiteDialog", () => {
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
});
+ it("keeps the map placeholder while State is still empty", () => {
+ const { dialog } = renderDialog();
+
+ fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), {
+ target: { value: "3811 Distribution Dr" },
+ });
+ fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
+ target: { value: "Dallas" },
+ });
+
+ expect(
+ within(dialog).getByText("Enter an address to preview the location"),
+ ).toBeInTheDocument();
+ expect(within(dialog).queryByTitle("Site location map")).not.toBeInTheDocument();
+ });
+
it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => {
const user = userEvent.setup();
const { dialog } = renderDialog();
diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
index 8b5c2885..fd5cf055 100644
--- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx
+++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
@@ -117,7 +117,11 @@ describe("Sites page detail panel", () => {
"https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201",
);
expect(address).toHaveAttribute("target", "_blank");
- expect(within(region).getByTitle("Site location map")).toBeInTheDocument();
+ expect(within(region).getByTitle("Site location map")).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
+ );
+ expect(within(region).getByTitle("Site location map")).toHaveAttribute("loading", "lazy");
expect(within(region).getByText("TX ยท Area: West")).toBeInTheDocument();
expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument();
expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument();
@@ -126,6 +130,45 @@ describe("Sites page detail panel", () => {
expect(within(region).queryByText(/status/i)).not.toBeInTheDocument();
});
+ it("view mode always renders the map for a saved site, even one missing City and State", async () => {
+ getById.mockResolvedValue({ ...chicago, city: "", state: "" });
+ renderWithProviders(, { withAuth: false });
+
+ const region = await openSite("CHI2");
+
+ expect(within(region).getByTitle("Site location map")).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=100%20Main%20St&output=embed",
+ );
+ expect(
+ within(region).queryByText("Enter an address to preview the location"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("edit mode previews the stored ZIP and swaps the map for the placeholder while City is cleared", async () => {
+ renderWithProviders(, { withAuth: false });
+ const region = await openSite("DAL1");
+ fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
+ expect(within(region).getByTitle("Site location map")).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
+ );
+
+ const city = within(region).getByRole("textbox", { name: "City" });
+ fireEvent.change(city, { target: { value: "" } });
+
+ expect(
+ await within(region).findByText("Enter an address to preview the location"),
+ ).toBeInTheDocument();
+ expect(within(region).queryByTitle("Site location map")).not.toBeInTheDocument();
+
+ fireEvent.change(city, { target: { value: "Fort Worth" } });
+ expect(await within(region).findByTitle("Site location map")).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX%2075201&output=embed",
+ );
+ });
+
it("switches to edit in place, saves, and returns to view showing the saved values", async () => {
update.mockResolvedValue({});
renderWithProviders(, { withAuth: false });
diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
index b4a67ced..69dfd489 100644
--- a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
+++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
@@ -102,7 +102,13 @@ describe("Work order Site dialog edits the site record", () => {
"https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201",
);
expect(address).toHaveAttribute("target", "_blank");
- expect(screen.getByTitle("Site location map")).toBeInTheDocument();
+ const map = screen.getByTitle("Site location map");
+ expect(map).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
+ );
+ expect(map).toHaveAttribute("loading", "lazy");
+ expect(screen.queryByText("Enter an address to preview the location")).not.toBeInTheDocument();
expect(screen.getByText("(214) 555-0100")).toBeInTheDocument();
expect(screen.getByText("dal1@acme.test")).toBeInTheDocument();
expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument();
diff --git a/src/test/domain/locations/utils/site-maps.test.ts b/src/test/domain/locations/utils/site-maps.test.ts
new file mode 100644
index 00000000..c6dc040e
--- /dev/null
+++ b/src/test/domain/locations/utils/site-maps.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSiteMapEmbedUrl,
+ buildSiteMapsUrl,
+ hasCompleteSiteAddress,
+} from "@/domain/locations/utils/site-maps";
+
+const site = { address: "12 Dock Rd #4", city: "Smith & Sons", state: "TX", zipCode: "75201" };
+
+describe("site map URLs", () => {
+ it("embeds the same address line the Street Address link opens", () => {
+ const line = "12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX%2075201";
+ expect(buildSiteMapsUrl(site)).toBe(`https://www.google.com/maps/search/?api=1&query=${line}`);
+ expect(buildSiteMapEmbedUrl(site)).toBe(`https://www.google.com/maps?q=${line}&output=embed`);
+ });
+
+ it("has no embed URL for a site without any address", () => {
+ expect(buildSiteMapEmbedUrl({})).toBe("");
+ });
+
+ it("counts an address complete only when Street, City and State are all filled", () => {
+ expect(hasCompleteSiteAddress(site)).toBe(true);
+ expect(hasCompleteSiteAddress({ ...site, state: " " })).toBe(false);
+ expect(hasCompleteSiteAddress({ ...site, city: undefined })).toBe(false);
+ });
+});
diff --git a/src/test/lib/address/google-maps.test.ts b/src/test/lib/address/google-maps.test.ts
new file mode 100644
index 00000000..5bdc4fd9
--- /dev/null
+++ b/src/test/lib/address/google-maps.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps";
+
+describe("googleMapsEmbedUrl", () => {
+ it("builds the keyless embed URL for an address", () => {
+ expect(googleMapsEmbedUrl("3811 Distribution Dr, Dallas, TX 75201")).toBe(
+ "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
+ );
+ });
+
+ it("encodes commas, # and & so they stay inside the query", () => {
+ expect(googleMapsEmbedUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe(
+ "https://www.google.com/maps?q=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX&output=embed",
+ );
+ });
+
+ it("returns an empty string for a blank address", () => {
+ expect(googleMapsEmbedUrl("")).toBe("");
+ expect(googleMapsEmbedUrl(" ")).toBe("");
+ });
+});
+
+describe("googleMapsSearchUrl", () => {
+ it("encodes commas, # and & in the search link", () => {
+ expect(googleMapsSearchUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe(
+ "https://www.google.com/maps/search/?api=1&query=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX",
+ );
+ });
+
+ it("returns an empty string for a blank address", () => {
+ expect(googleMapsSearchUrl(" ")).toBe("");
+ });
+});