/** * 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()); }