mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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: "<typed street>, <city>, <state>". */
|
||
|
|
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<Omit<AddressParts, "street">> = [
|
||
|
|
{ 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<string>();
|
||
|
|
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<AddressParts>): 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<AddressParts>): 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<AddressParts>): 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<AddressParts>): boolean {
|
||
|
|
return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim());
|
||
|
|
}
|