From 27492ee01f5c820f07fbcfa392ce9d9c7eace326 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Fri, 10 Apr 2026 15:40:38 -0400 Subject: [PATCH] feat: add location autocomplete --- bun.lock | 7 + src/app/api/location-autocomplete/route.ts | 61 ++++++ src/components/event-dialog.tsx | 155 +++++++++----- src/components/location-autocomplete.tsx | 222 +++++++++++++++++++++ src/lib/google-maps.ts | 170 ++++++++++++++++ tests/location-autocomplete.test.ts | 111 +++++++++++ 6 files changed, 679 insertions(+), 47 deletions(-) create mode 100644 src/app/api/location-autocomplete/route.ts create mode 100644 src/components/location-autocomplete.tsx create mode 100644 src/lib/google-maps.ts create mode 100644 tests/location-autocomplete.test.ts diff --git a/bun.lock b/bun.lock index 517d57c..3e89b5d 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@vis.gl/react-google-maps": "^1.8.3", "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -172,6 +173,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@googlemaps/js-api-loader": ["@googlemaps/js-api-loader@2.0.2", "", { "dependencies": { "@types/google.maps": "^3.53.1" } }, "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -442,6 +445,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -528,6 +533,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vis.gl/react-google-maps": ["@vis.gl/react-google-maps@1.8.3", "", { "dependencies": { "@googlemaps/js-api-loader": "^2.0.2", "@types/google.maps": "^3.54.10", "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], diff --git a/src/app/api/location-autocomplete/route.ts b/src/app/api/location-autocomplete/route.ts new file mode 100644 index 0000000..e708a60 --- /dev/null +++ b/src/app/api/location-autocomplete/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { + getGoogleMapsLocationCapability, + getGoogleMapsServerApiKey, + mapGooglePlacesSuggestions, +} from "@/lib/google-maps"; + +const AUTOCOMPLETE_FIELD_MASK = [ + "suggestions.placePrediction.place", + "suggestions.placePrediction.placeId", + "suggestions.placePrediction.text.text", + "suggestions.placePrediction.structuredFormat.mainText.text", + "suggestions.placePrediction.structuredFormat.secondaryText.text", +].join(","); + +export async function GET(request: Request) { + const capability = getGoogleMapsLocationCapability(); + if (!capability.enabled) { + return NextResponse.json({ suggestions: [] }, { status: 503 }); + } + + const apiKey = getGoogleMapsServerApiKey(); + if (!apiKey) { + return NextResponse.json({ suggestions: [] }, { status: 503 }); + } + + const { searchParams } = new URL(request.url); + const input = searchParams.get("input")?.trim(); + const sessionToken = searchParams.get("sessionToken")?.trim(); + + if (!input) { + return NextResponse.json({ suggestions: [] }); + } + + const response = await fetch( + "https://places.googleapis.com/v1/places:autocomplete", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": AUTOCOMPLETE_FIELD_MASK, + }, + body: JSON.stringify({ + input, + sessionToken: sessionToken || undefined, + }), + }, + ); + + if (!response.ok) { + return NextResponse.json({ suggestions: [] }, { status: 502 }); + } + + const payload = (await response.json()) as Parameters< + typeof mapGooglePlacesSuggestions + >[0]; + return NextResponse.json({ + suggestions: mapGooglePlacesSuggestions(payload), + }); +} diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx index ea62126..859f7a9 100644 --- a/src/components/event-dialog.tsx +++ b/src/components/event-dialog.tsx @@ -3,8 +3,8 @@ import { addHours, addMinutes, isValid, parseISO } from "date-fns"; import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { LucideMapPin } from "lucide-react"; import { DateTimePicker } from "@/components/date-time-picker"; +import { LocationAutocomplete } from "@/components/location-autocomplete"; import { RecurrencePicker } from "@/components/recurrence-picker"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -19,12 +19,12 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { validateRecurrence, parseRecurrenceRule } from "@/lib/recurrence"; import { + type EventFormValues, getDefaultEventFormValues, validateEventFormValues, - type EventFormValues, } from "@/lib/event-form"; +import { parseRecurrenceRule, validateRecurrence } from "@/lib/recurrence"; interface EventDialogProps { open: boolean; @@ -46,7 +46,11 @@ export const EventDialog = ({ onReset, }: EventDialogProps) => { const isAiDraft = dialogSource === "ai" && !editingId; - const titleText = editingId ? "Edit Event" : isAiDraft ? "Review AI Draft" : "New Event"; + const titleText = editingId + ? "Edit Event" + : isAiDraft + ? "Review AI Draft" + : "New Event"; const descriptionText = editingId ? "Update the event details below. Title and start date are required." : isAiDraft @@ -89,11 +93,16 @@ export const EventDialog = ({ { label: "+3 hours", minutes: 180 }, ]; - const handleApplyDuration = (minutes: number, currentAllDay: boolean, currentStart: string) => { + const handleApplyDuration = ( + minutes: number, + currentAllDay: boolean, + currentStart: string, + ) => { if (!currentStart) return; const base = parseISO(currentStart); if (!isValid(base)) return; - const next = minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); + const next = + minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); const pad = (value: number) => String(value).padStart(2, "0"); const result = currentAllDay ? `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}` @@ -108,14 +117,18 @@ export const EventDialog = ({ for (const [fieldName, messages] of Object.entries(fieldErrors)) { const firstMessage = messages?.[0]; if (firstMessage) { - setError(fieldName as keyof EventFormValues, { message: firstMessage }); + setError(fieldName as keyof EventFormValues, { + message: firstMessage, + }); } } return; } if (values.recurrenceRule) { - const recurrenceValidation = validateRecurrence(parseRecurrenceRule(values.recurrenceRule)); + const recurrenceValidation = validateRecurrence( + parseRecurrenceRule(values.recurrenceRule), + ); if (!recurrenceValidation.isValid) { setError("recurrenceRule", { message: @@ -143,14 +156,22 @@ export const EventDialog = ({
{isAiDraft && (
- This draft was generated from natural language. Double-check dates, times, location, recurrence, and links before saving. + This draft was generated from natural language. Double-check + dates, times, location, recurrence, and links before saving.
)}
- - {errors.title &&

{errors.title.message}

} + + {errors.title && ( +

{errors.title.message}

+ )}
@@ -163,18 +184,27 @@ export const EventDialog = ({ />
-
+
-
- - -
+ ( + + )} + />
- {errors.url &&

{errors.url.message}

} + {errors.url && ( +

{errors.url.message}

+ )}
@@ -182,11 +212,17 @@ export const EventDialog = ({ name="recurrenceRule" control={control} render={({ field }) => ( - + )} /> {errors.recurrenceRule && ( -

{errors.recurrenceRule.message}

+

+ {errors.recurrenceRule.message} +

)}
@@ -197,11 +233,16 @@ export const EventDialog = ({ field.onChange(checked === true)} + onCheckedChange={(checked) => + field.onChange(checked === true) + } /> )} /> -
@@ -211,45 +252,65 @@ export const EventDialog = ({ name="start" control={control} render={({ field }) => ( - + )} /> {!allDay && ( - ( -
- {DURATIONS.map(({ label, minutes }) => ( - - ))} -
- )} - /> + ( +
+ {DURATIONS.map(({ label, minutes }) => ( + + ))} +
+ )} + /> )} ( - + )} /> - {errors.start &&

{errors.start.message}

} - {errors.end &&

{errors.end.message}

} + {errors.start && ( +

{errors.start.message}

+ )} + {errors.end && ( +

{errors.end.message}

+ )}
- diff --git a/src/components/location-autocomplete.tsx b/src/components/location-autocomplete.tsx new file mode 100644 index 0000000..ce0bf04 --- /dev/null +++ b/src/components/location-autocomplete.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { LucideMapPin } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { + type GoogleMapsLocationCapability, + type GoogleMapsSuggestion, + getGoogleMapsLocationCapability, + getGoogleMapsPlaceLabel, + getLocationAutocompletePlaceholder, +} from "@/lib/google-maps"; +import { cn } from "@/lib/utils"; + +interface LocationAutocompleteProps { + capability?: GoogleMapsLocationCapability; + className?: string; + id: string; + onChange: (value: string) => void; + value: string; +} + +const SEARCH_HELP_TEXT = "Search Google Maps or keep typing a custom location."; +const SEARCH_UNAVAILABLE_TEXT = + "Google Maps search is unavailable right now. You can still type a location."; + +export const LocationAutocomplete = ({ + capability, + className, + id, + onChange, + value, +}: LocationAutocompleteProps) => { + const resolvedCapability = capability ?? getGoogleMapsLocationCapability(); + + if (!resolvedCapability.enabled) { + return ( + + ); + } + + return ( + + ); +}; + +const ManualLocationInput = ({ + className, + id, + onChange, + placeholder, + value, +}: Omit & { placeholder: string }) => { + return ( +
+
+ + onChange(event.target.value)} + placeholder={placeholder} + value={value} + /> +
+
+ ); +}; + +const ServerLocationInput = ({ + capability, + className, + id, + onChange, + value, +}: Omit & { + capability: GoogleMapsLocationCapability; +}) => { + const [hasSearchError, setHasSearchError] = useState(false); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const sessionTokenRef = useRef(null); + const skipNextLookupRef = useRef(false); + + const helperText = useMemo(() => { + return hasSearchError ? SEARCH_UNAVAILABLE_TEXT : SEARCH_HELP_TEXT; + }, [hasSearchError]); + + useEffect(() => { + if (skipNextLookupRef.current) { + skipNextLookupRef.current = false; + return; + } + + const query = value.trim(); + if (!query || hasSearchError) { + setSuggestions([]); + setIsLoadingSuggestions(false); + return; + } + + if (!sessionTokenRef.current) { + sessionTokenRef.current = crypto.randomUUID(); + } + + const controller = new AbortController(); + setIsLoadingSuggestions(true); + + void fetch( + `/api/location-autocomplete?input=${encodeURIComponent(query)}&sessionToken=${encodeURIComponent(sessionTokenRef.current)}`, + { signal: controller.signal }, + ) + .then(async (response) => { + if (!response.ok) { + throw new Error("Autocomplete request failed"); + } + + return (await response.json()) as { + suggestions?: GoogleMapsSuggestion[]; + }; + }) + .then((payload) => { + setSuggestions(payload.suggestions ?? []); + }) + .catch((error) => { + if (controller.signal.aborted) { + return; + } + + console.error("Location autocomplete failed", error); + setHasSearchError(true); + setSuggestions([]); + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsLoadingSuggestions(false); + } + }); + + return () => controller.abort(); + }, [hasSearchError, value]); + + const handleSuggestionSelect = (suggestion: GoogleMapsSuggestion) => { + const nextValue = getGoogleMapsPlaceLabel({ + formattedAddress: suggestion.formattedAddress, + name: suggestion.text, + predictionText: suggestion.text, + }); + + if (!nextValue) { + return; + } + + skipNextLookupRef.current = true; + sessionTokenRef.current = null; + setSuggestions([]); + onChange(nextValue); + }; + + const showSuggestions = suggestions.length > 0 && !hasSearchError; + + return ( +
+
+ + { + setHasSearchError(false); + onChange(event.target.value); + }} + placeholder={getLocationAutocompletePlaceholder(capability)} + role="combobox" + value={value} + /> + {showSuggestions ? ( +
+
+ {suggestions.map((suggestion) => { + const suggestionKey = + suggestion.placeId || suggestion.text || "suggestion"; + + return ( +
+ +
+ ); + })} +
+
+ ) : null} +
+

{helperText}

+ {isLoadingSuggestions ? ( +

+ Loading place suggestions… +

+ ) : null} +
+ ); +}; diff --git a/src/lib/google-maps.ts b/src/lib/google-maps.ts new file mode 100644 index 0000000..80690bf --- /dev/null +++ b/src/lib/google-maps.ts @@ -0,0 +1,170 @@ +export interface GoogleMapsLocationConfig { + enabled?: boolean | null; + region?: string | null; + publicEnabled?: boolean | null; + serverApiKey?: string | null; +} + +export interface GoogleMapsLocationCapability { + enabled: boolean; + reason: "configured" | "missing_server_api_key" | "disabled"; + region: string; +} + +export interface GoogleMapsPlaceLabelSource { + displayName?: string | { text?: string | null } | null; + formattedAddress?: string | null; + name?: string | null; + predictionText?: string | null; +} + +export interface GoogleMapsSuggestion { + formattedAddress?: string; + placeId?: string; + text: string; +} + +const DEFAULT_GOOGLE_MAPS_REGION = "us"; + +const normalizeConfigValue = (value?: string | null) => { + const trimmedValue = value?.trim(); + return trimmedValue ? trimmedValue : null; +}; + +const getDisplayNameText = ( + displayName?: string | { text?: string | null } | null, +) => { + if (typeof displayName === "string") { + return normalizeConfigValue(displayName); + } + + return normalizeConfigValue(displayName?.text); +}; + +const parseEnabledValue = (value?: string | null) => { + const normalizedValue = normalizeConfigValue(value)?.toLowerCase(); + + if (!normalizedValue) { + return true; + } + + return !["0", "false", "no", "off"].includes(normalizedValue); +}; + +export const getGoogleMapsLocationCapability = ( + config: GoogleMapsLocationConfig = {}, +): GoogleMapsLocationCapability => { + const enabled = + typeof config.enabled === "boolean" + ? config.enabled + : typeof config.publicEnabled === "boolean" + ? config.publicEnabled + : parseEnabledValue( + process.env.NEXT_PUBLIC_GOOGLE_MAPS_AUTOCOMPLETE_ENABLED, + ); + const serverApiKey = normalizeConfigValue( + config.serverApiKey ?? process.env.GOOGLE_MAPS_API_KEY, + ); + const region = + normalizeConfigValue(config.region ?? process.env.GOOGLE_MAPS_REGION) ?? + DEFAULT_GOOGLE_MAPS_REGION; + + if (!enabled) { + return { + enabled: false, + reason: "disabled", + region, + }; + } + + if (typeof window !== "undefined") { + return { + enabled: true, + reason: "configured", + region, + }; + } + + if (!serverApiKey) { + return { + enabled: false, + reason: "missing_server_api_key", + region, + }; + } + + return { + enabled: true, + reason: "configured", + region, + }; +}; + +export const getGoogleMapsServerApiKey = () => + normalizeConfigValue(process.env.GOOGLE_MAPS_API_KEY); + +export const getGoogleMapsPlaceLabel = (source: GoogleMapsPlaceLabelSource) => { + return ( + normalizeConfigValue(source.predictionText) ?? + getDisplayNameText(source.displayName) ?? + normalizeConfigValue(source.formattedAddress) ?? + normalizeConfigValue(source.name) ?? + "" + ); +}; + +export const getLocationAutocompletePlaceholder = ( + capability: GoogleMapsLocationCapability, +) => { + return capability.enabled + ? "Search Google Maps or type a location" + : "Location"; +}; + +interface GooglePlacesAutocompleteResponse { + suggestions?: Array<{ + placePrediction?: { + place?: string; + placeId?: string; + text?: { text?: string | null }; + structuredFormat?: { + mainText?: { text?: string | null }; + secondaryText?: { text?: string | null }; + }; + }; + }>; +} + +export const mapGooglePlacesSuggestions = ( + payload: GooglePlacesAutocompleteResponse, +): GoogleMapsSuggestion[] => { + const suggestions: GoogleMapsSuggestion[] = []; + + for (const suggestion of payload.suggestions ?? []) { + const placePrediction = suggestion.placePrediction; + const text = + normalizeConfigValue(placePrediction?.text?.text) ?? + normalizeConfigValue(placePrediction?.structuredFormat?.mainText?.text) ?? + ""; + + if (!text) { + continue; + } + + suggestions.push({ + formattedAddress: + normalizeConfigValue( + placePrediction?.structuredFormat?.secondaryText?.text, + ) ?? undefined, + placeId: + normalizeConfigValue(placePrediction?.placeId) ?? + normalizeConfigValue( + placePrediction?.place?.replace(/^places\//, ""), + ) ?? + undefined, + text, + }); + } + + return suggestions; +}; diff --git a/tests/location-autocomplete.test.ts b/tests/location-autocomplete.test.ts new file mode 100644 index 0000000..4dfee34 --- /dev/null +++ b/tests/location-autocomplete.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + getGoogleMapsLocationCapability, + getGoogleMapsPlaceLabel, + mapGooglePlacesSuggestions, +} from "@/lib/google-maps"; + +const renderLocationAutocomplete = async (capability: { + enabled: boolean; + region: string; + reason: "configured" | "missing_server_api_key" | "disabled"; +}) => { + const { LocationAutocomplete } = await import("@/components/location-autocomplete"); + + return renderToStaticMarkup( + createElement(LocationAutocomplete, { + capability, + id: "event-location", + onChange: () => {}, + value: "", + }), + ); +}; + +describe("Google Maps location capability boundary", () => { + test("disables Google Maps search when the server API key is missing", () => { + expect(getGoogleMapsLocationCapability({ serverApiKey: "" })).toEqual({ + enabled: false, + reason: "missing_server_api_key", + region: "us", + }); + }); + + test("enables Google Maps search when the server API key is present", () => { + expect( + getGoogleMapsLocationCapability({ serverApiKey: "maps-server-key", region: "gb" }), + ).toEqual({ + enabled: true, + reason: "configured", + region: "gb", + }); + }); +}); + +describe("LocationAutocomplete fallback mode", () => { + test("renders a plain text location field when Google Maps configuration is unavailable", async () => { + const markup = await renderLocationAutocomplete({ + enabled: false, + reason: "missing_server_api_key", + region: "us", + }); + + expect(markup).toContain('data-location-mode="manual"'); + expect(markup).toContain('placeholder="Location"'); + expect(markup).not.toContain("Search Google Maps"); + }); +}); + +describe("LocationAutocomplete configured mode", () => { + test("renders a server-backed Google Maps-assisted input path while keeping manual typing available", async () => { + const markup = await renderLocationAutocomplete({ + enabled: true, + reason: "configured", + region: "us", + }); + + expect(markup).toContain('data-location-mode="google-maps-server"'); + expect(markup).toContain('placeholder="Search Google Maps or type a location"'); + expect(markup).toContain("Search Google Maps or keep typing a custom location."); + }); +}); + +describe("Google Maps place label selection", () => { + test("prefers the chosen prediction label for the visible location value", () => { + expect( + getGoogleMapsPlaceLabel({ + displayName: "Google HQ", + formattedAddress: "1600 Amphitheatre Parkway, Mountain View, CA", + predictionText: "Googleplex", + }), + ).toBe("Googleplex"); + }); +}); + +describe("Google Places server response mapping", () => { + test("maps server autocomplete predictions into lightweight suggestion records", () => { + expect( + mapGooglePlacesSuggestions({ + suggestions: [ + { + placePrediction: { + place: "places/abc123", + structuredFormat: { + secondaryText: { text: "Mountain View, CA" }, + }, + text: { text: "Googleplex" }, + }, + }, + ], + }), + ).toEqual([ + { + formattedAddress: "Mountain View, CA", + placeId: "abc123", + text: "Googleplex", + }, + ]); + }); +});