diff --git a/README.md b/README.md index fbd08bb..ec1c52d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ yarn add @appandflow/react-native-google-autocomplete npm i @appandflow/react-native-google-autocomplete ``` +> **Uses Places API (New).** This library calls the new +> [`places.googleapis.com/v1`](https://developers.google.com/maps/documentation/places/web-service/place-autocomplete) +> Autocomplete and Place Details endpoints. You must enable **Places API (New)** +> in your Google Cloud project — the legacy "Places API" is not enough. + ## Usage The `useGoogleAutocomplete` hook takes 2 arguments @@ -24,21 +29,40 @@ The `useGoogleAutocomplete` hook takes 2 arguments # Config object -| Property | Description | -| --------------- | ------------------------------------------------------------------------------------------------------ | -| debounce | optional - default 300 | -| debounceOptions | optional - Configuration options for debounce behavior. | -| language | optional - default 'en' | -| queryTypes | optional - default address - https://developers.google.com/places/web-service/autocomplete#place_types | -| minLength | optional - default 2 - this is the min length of the term search before start | -| components | optional - A grouping of places to which you would like to restrict your results | -| radius | optional - The distance (in meters) within which to return place results | -| lat | optional - The latitude. If provided, lng is required | -| lng | optional - The longitude. If provided, lat is required | -| strictBounds | optional - Returns only places that are strictly within the region defined by location and radius. | -| origin | optional - The origin point as { lat, lng } from which to calculate straight-line distance to the destinations (returned as distance_meters) | -| proxyUrl | optional - This is required if you want to use the hook in a Web based platform. Since we dont use the Google SDK, the http call will fail because of issues related to CORS unless a proxyUrl is provided | -| headers | optional - Custom headers to include in the Google Places API requests. Useful for passing platform-specific API restrictions such as `X-Android-Package`, `X-Android-Cert`, or `X-Ios-Bundle-Identifier`. | +| Property | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------ | +| debounce | optional - default 300 | +| debounceOptions | optional - Configuration options for debounce behavior. | +| minLength | optional - default 2 - min length of the term before search starts | +| languageCode | optional - default 'en' - request language, e.g. `'nl'` | +| regionCode | optional - CLDR region code used to format results, e.g. `'nl'` | +| includedRegionCodes | optional - restrict results to up to 15 country codes, e.g. `['nl', 'be']` | +| includedPrimaryTypes | optional - restrict to [place types](https://developers.google.com/maps/documentation/places/web-service/place-types), e.g. `['establishment']` for venues/POIs | +| locationBias | optional - bias results toward an area (soft). `{ circle: { center: { latitude, longitude }, radius } }` | +| locationRestriction | optional - restrict results to an area (hard). Same shape as `locationBias` | +| includeQueryPredictions | optional - also return query predictions (carry `isQueryPrediction: true`) | +| origin | optional - `{ lat, lng }` from which to calculate straight-line distance (returned as `distance_meters`) | +| sessionToken | optional - provide your own [session token](https://developers.google.com/maps/documentation/places/web-service/place-session-tokens). By default one is generated and rotated after each `searchDetails`/`clearSearch` | +| autocompleteFieldMask | optional - override the `X-Goog-FieldMask` for autocomplete requests | +| placeDetailsFieldMask | optional - override the `X-Goog-FieldMask` for place details requests | +| proxyUrl | optional - required for Web platforms to avoid CORS issues | +| headers | optional - Custom headers, e.g. `X-Android-Package`, `X-Android-Cert`, `X-Ios-Bundle-Identifier` | + +### Deprecated (legacy) options + +These still work and are mapped to the new API, but prefer the options above: + +| Legacy | Maps to | +| ------------------ | ---------------------------------------- | +| `language` | `languageCode` | +| `components` | `includedRegionCodes` (parses `country:xx`) | +| `queryTypes` | `includedPrimaryTypes` (only `establishment` maps; other values = no type filter) | +| `lat` / `lng` / `radius` | `locationBias.circle` | +| `strictBounds` | turns the lat/lng/radius into `locationRestriction` | + +> **Looking up venues** like _Johan Cruijff ArenA_, _Ziggo Dome_ or _Ahoy_? +> Set `includedPrimaryTypes: ['establishment']` (optionally with +> `includedRegionCodes: ['nl']`). # Exposed properties @@ -59,7 +83,8 @@ Clicking on a result logs the details of that result. ```ts const { locationResults, setTerm, clearSearch, searchDetails, term } = useGoogleAutocomplete(API_KEY, { - language: 'en', + languageCode: 'en', + includedPrimaryTypes: ['establishment'], debounce: 300, }); @@ -86,56 +111,51 @@ Clicking on a result logs the details of that result. ## Results -`locationResults` returns the following. The maximum result set by Google is 5 locations by query. +`locationResults` returns the following. The new-API prediction is normalized to +the field names below (backward compatible with previous versions); the raw +prediction is available on `_raw`. -```js +```ts export interface GoogleLocationResult { description: string; - id: string; - matched_substrings: Array<{ - length: number; - offset: number; - }>; place_id: string; - reference: string; structured_formatting: { main_text: string; secondary_text: string; main_text_matched_substrings: Array<{ length: number; + offset?: number; }>; }; - terms: Array<{ - offset: number; - value: string; - }>; types: string[]; distance_meters?: number; // Present when origin parameter is provided + isQueryPrediction?: boolean; // true for query predictions + _raw: object; // raw Places API (New) prediction } ``` -When calling the searchDetails this is what you get +When calling `searchDetails` this is what you get. Place Details (New) requires a +field mask — override `placeDetailsFieldMask` to request more/fewer fields. -```js +```ts export interface GoogleLocationDetailResult { - adr_address: string; - formatted_address: string; - icon: string; id: string; - name: string; place_id: string; - scope: string; - reference: string; - url: string; - utc_offset: number; + name: string; + formatted_address: string; + adr_address: string; vicinity: string; + url: string; // googleMapsUri + website: string; // websiteUri + utc_offset: number; // utcOffsetMinutes types: string[]; + primary_type?: string; geometry: { location: { lat: number; lng: number; }; - viewport: { + viewport?: { [type: string]: { lat: number; lng: number; @@ -147,6 +167,7 @@ export interface GoogleLocationDetailResult { short_name: string; types: string[]; }>; + _raw: object; // raw Places API (New) place } ``` @@ -184,4 +205,25 @@ const { locationResults, setTerm } = useGoogleAutocomplete(API_KEY, { ## Restrict by country -If you want to restrict the search by country you can add this as a props `components="country:ca"`. This here would example restrict it to Canada only. +Pass `includedRegionCodes` with up to 15 CLDR country codes: + +```ts +useGoogleAutocomplete(API_KEY, { + includedRegionCodes: ['ca'], // Canada only +}); +``` + +## Bias / restrict by location + +```ts +useGoogleAutocomplete(API_KEY, { + // soft bias around Amsterdam (50km) + locationBias: { + circle: { + center: { latitude: 52.3676, longitude: 4.9041 }, + radius: 50000, + }, + }, + // or a hard restriction with `locationRestriction` (same shape) +}); +``` diff --git a/example/src/App.tsx b/example/src/App.tsx index 6f01ab5..b4550dd 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -16,8 +16,12 @@ export default function App() { const { setTerm, locationResults, isSearching } = useGoogleAutocomplete( API_KEY, { - language: 'en', + languageCode: 'en', minLength: 3, + // Return venues / businesses / POIs (e.g. Johan Cruijff ArenA, Ziggo Dome) + includedPrimaryTypes: ['establishment'], + // Restrict to one or more countries + // includedRegionCodes: ['nl'], proxyUrl: isWeb ? 'https://cors-anywhere.herokuapp.com/' : undefined, // Example: Using origin to get distance from Times Square, NYC // Uncomment to see distance_meters in results @@ -40,7 +44,7 @@ export default function App() { Searching... )} {locationResults.map((location) => ( - + {location.structured_formatting.main_text} {location.distance_meters && ( diff --git a/package.json b/package.json index 99a6daa..f7eea04 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,6 @@ "version": "0.38.1" }, "dependencies": { - "query-string": "7.1.3", "use-debounce": "^10.0.1" } } diff --git a/src/GoogleAutocomplete.tsx b/src/GoogleAutocomplete.tsx index 045363a..e39f8ad 100644 --- a/src/GoogleAutocomplete.tsx +++ b/src/GoogleAutocomplete.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { GoogleService, + type AutocompleteRequest, type GoogleLocationResult, + type LocationBias, + type LocationRestriction, } from './services/google.service'; import { useDebounce } from 'use-debounce'; import { useIsMounted } from './useIsMounted'; @@ -9,6 +12,62 @@ import { Platform } from 'react-native'; const isWeb = Platform.OS === 'web'; +/** + * Generate a RFC4122 v4 UUID. Uses crypto.randomUUID when available, otherwise + * falls back to a Math.random based implementation so it works across all + * React Native / Expo runtimes without extra native deps. + */ +function generateSessionToken(): string { + const cryptoObj = + typeof globalThis !== 'undefined' + ? (globalThis as { crypto?: Crypto }).crypto + : undefined; + + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + // eslint-disable-next-line no-bitwise + const r = (Math.random() * 16) | 0; + // eslint-disable-next-line no-bitwise + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Parse a legacy `components` string (e.g. "country:nl|country:be") into + * `includedRegionCodes` for Places API (New). + */ +function parseComponents(components?: string): string[] | undefined { + if (!components) { + return undefined; + } + + const codes = Array.from(components.matchAll(/country:([a-zA-Z]{2,3})/g)).map( + (m) => m[1]!.toLowerCase() + ); + + return codes.length ? codes : undefined; +} + +/** + * Map the legacy `queryTypes` value to `includedPrimaryTypes`. The legacy + * default of `address` and the `geocode`/mixed values have no direct primary + * type in the new API, so only `establishment` is mapped; anything else means + * "no type filter" (return all place types). + */ +function mapQueryTypes(queryTypes?: string): string[] | undefined { + if (!queryTypes) { + return undefined; + } + if (queryTypes === 'establishment') { + return ['establishment']; + } + return undefined; +} + interface Options { /** * Minimun length of the input before start fetching - default: 2 @@ -30,18 +89,89 @@ interface Options { equalityFn?: (left: any, right: any) => boolean; }; + // --- Places API (New) options ---------------------------------------------- + + /** + * Language for the request, e.g. "en", "nl". Maps to `languageCode`. + * Prefer this over the deprecated `language`. + */ + languageCode?: string; + + /** + * Region code (CLDR), used to format results, e.g. "nl". + */ + regionCode?: string; + + /** + * Restrict results to up to 15 country/region codes, e.g. ["nl", "be"]. + */ + includedRegionCodes?: string[]; + + /** + * Restrict results to the given place types, e.g. ["establishment"]. + * See https://developers.google.com/maps/documentation/places/web-service/place-types + */ + includedPrimaryTypes?: string[]; + + /** + * Bias results toward an area (soft). Prefer this for venue search. + */ + locationBias?: LocationBias; + + /** + * Restrict results to an area (hard). + */ + locationRestriction?: LocationRestriction; + + /** + * Include query predictions in addition to place predictions. + * Returned results carry `isQueryPrediction: true`. + */ + includeQueryPredictions?: boolean; + + /** + * Custom field mask for the autocomplete request. + */ + autocompleteFieldMask?: string; + + /** + * Custom field mask for the place details request. Required fields for your + * use case must be included. Defaults to a broad set. + */ + placeDetailsFieldMask?: string; + + /** + * Provide your own session token. By default one is generated and rotated + * after each `searchDetails` / `clearSearch` for session-based billing. + * See https://developers.google.com/maps/documentation/places/web-service/place-session-tokens + */ + sessionToken?: string; + + /** + * The origin point from which to calculate straight-line distance to each + * result (returned as `distance_meters`). If omitted, distance is not + * returned. + */ + origin?: { + lat: number; + lng: number; + }; + + // --- Legacy options (deprecated, mapped to the new API) -------------------- + /** - * Language for Google query - default: en + * @deprecated Use `languageCode`. Language for Google query - default: en */ language?: string; /** - * A grouping of places to which you would like to restrict your results + * @deprecated Use `includedRegionCodes`. A "country:xx" components string. */ components?: string; /** - * See https://developers.google.com/places/web-service/autocomplete#place_types = default: address + * @deprecated Use `includedPrimaryTypes`. Only `establishment` maps cleanly; + * other values are ignored (no type filter). */ queryTypes?: | 'address' @@ -51,36 +181,26 @@ interface Options { | 'geocode|establishment'; /** - * The distance (in meters) within which to return place results. - * Note that setting a radius biases results to the indicated area, - * but may not fully restrict results to the specified area. + * @deprecated Use `locationBias` / `locationRestriction`. Radius in meters. */ - radius?: string; + radius?: string | number; /** - * The latitude to retrieve place information + * @deprecated Use `locationBias` / `locationRestriction`. Latitude. */ lat?: number; /** - * The longitude to retrieve place information + * @deprecated Use `locationBias` / `locationRestriction`. Longitude. */ lng?: number; /** - * Enable strict mode to return search result only in the area defined by radius, lat and lng + * @deprecated Use `locationRestriction`. When true the lat/lng/radius is + * treated as a hard restriction instead of a bias. */ strictBounds?: boolean; - /** - * The origin point from which to calculate straight-line distance to the destination (returned as distance_meters). - * If omitted, straight-line distance will not be returned. - */ - origin?: { - lat: number; - lng: number; - }; - /** * Proxy url if you want to use the web, this is needed cause of CORS issue */ @@ -94,14 +214,51 @@ interface Options { headers?: Record; } +/** + * Build the location bias/restriction from either the new options or the legacy + * lat/lng/radius/strictBounds options. + */ +function resolveLocation(opts: Options): { + locationBias?: LocationBias; + locationRestriction?: LocationRestriction; +} { + if (opts.locationBias || opts.locationRestriction) { + return { + locationBias: opts.locationBias, + locationRestriction: opts.locationRestriction, + }; + } + + const { lat, lng, radius, strictBounds } = opts; + + if ((lat == null) !== (lng == null)) { + throw new Error('Query: Location must have both lat & lng'); + } + + if (lat == null || lng == null) { + return {}; + } + + const circle: LocationBias = { + circle: { + center: { latitude: lat, longitude: lng }, + radius: typeof radius === 'string' ? Number(radius) : radius ?? 50000, + }, + }; + + return strictBounds + ? { locationRestriction: circle } + : { locationBias: circle }; +} + export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => { - const { - minLength = 2, - debounce = 300, - debounceOptions = {}, - language = 'en', - queryTypes = 'address', - } = opts; + const { minLength = 2, debounce = 300, debounceOptions = {} } = opts; + const languageCode = opts.languageCode ?? opts.language; + const includedRegionCodes = + opts.includedRegionCodes ?? parseComponents(opts.components); + const includedPrimaryTypes = + opts.includedPrimaryTypes ?? mapQueryTypes(opts.queryTypes); + const isMounted = useIsMounted(); const [isSearching, setIsSearching] = useState(false); const [term, setTerm] = useState(''); @@ -111,37 +268,51 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => { >([]); const [searchError, setSearchError] = useState(null); + const sessionTokenRef = useRef(opts.sessionToken ?? generateSessionToken()); + const search = async (value: string) => { if (isWeb && !opts.proxyUrl) { throw new Error('A proxy url is needed for web'); } setIsSearching(true); + setSearchError(null); try { - const results = await GoogleService.search( - value, - { - key: apiKey, - language, - types: queryTypes, - strictBounds: opts.strictBounds, - lat: opts.lat, - lng: opts.lng, - radius: opts.radius, - components: opts.components, - origin: opts.origin, - }, - opts.proxyUrl, - opts.headers - ); - - setLocationResults(results.predictions); + const { locationBias, locationRestriction } = resolveLocation(opts); + + const request: AutocompleteRequest = { + input: value, + languageCode, + regionCode: opts.regionCode, + includedRegionCodes, + includedPrimaryTypes, + locationBias, + locationRestriction, + includeQueryPredictions: opts.includeQueryPredictions, + sessionToken: sessionTokenRef.current, + origin: opts.origin + ? { latitude: opts.origin.lat, longitude: opts.origin.lng } + : undefined, + }; + + const results = await GoogleService.search(request, { + apiKey, + fieldMask: opts.autocompleteFieldMask, + proxyUrl: opts.proxyUrl, + headers: opts.headers, + }); + + if (isMounted()) { + setLocationResults(results); + } } catch (error) { - if (error instanceof Error) { + if (error instanceof Error && isMounted()) { setSearchError(error); } } finally { - setIsSearching(false); + if (isMounted()) { + setIsSearching(false); + } } }; @@ -150,23 +321,27 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => { throw new Error('A proxy url is needed for web'); } - return GoogleService.searchDetails( - placeId, - { - key: apiKey, - language, - types: queryTypes, - components: opts.components, - }, - opts.proxyUrl, - opts.headers - ); + const details = await GoogleService.searchDetails(placeId, { + apiKey, + fieldMask: opts.placeDetailsFieldMask, + languageCode, + regionCode: opts.regionCode, + sessionToken: sessionTokenRef.current, + proxyUrl: opts.proxyUrl, + headers: opts.headers, + }); + + // Selecting a place ends the session; rotate the token for the next one. + sessionTokenRef.current = opts.sessionToken ?? generateSessionToken(); + + return details; }; const clearSearch = () => { if (isMounted()) { setLocationResults([]); setIsSearching(false); + sessionTokenRef.current = opts.sessionToken ?? generateSessionToken(); } }; @@ -188,5 +363,6 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => { setTerm, term, searchDetails, + sessionToken: sessionTokenRef.current, }; }; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index bf84291..4c6ef20 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1 +1,157 @@ -it.todo('write a test'); +import { GoogleService } from '../services/google.service'; + +const mockFetch = (body: unknown, ok = true, status = 200) => + jest.fn().mockResolvedValue({ + ok, + status, + statusText: ok ? 'OK' : 'Error', + json: async () => body, + }); + +describe('GoogleService.search (Places API New autocomplete)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('posts to the new autocomplete endpoint with api key and field mask', async () => { + const fetchMock = mockFetch({ suggestions: [] }); + global.fetch = fetchMock as unknown as typeof fetch; + + await GoogleService.search( + { input: 'arena', languageCode: 'nl' }, + { apiKey: 'KEY' } + ); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://places.googleapis.com/v1/places:autocomplete'); + expect(init.method).toBe('POST'); + expect(init.headers['X-Goog-Api-Key']).toBe('KEY'); + expect(init.headers['X-Goog-FieldMask']).toContain( + 'suggestions.placePrediction' + ); + expect(JSON.parse(init.body)).toMatchObject({ + input: 'arena', + languageCode: 'nl', + }); + }); + + it('normalizes place predictions into the legacy result shape', async () => { + global.fetch = mockFetch({ + suggestions: [ + { + placePrediction: { + placeId: 'abc123', + text: { text: 'Johan Cruijff ArenA, Amsterdam' }, + structuredFormat: { + mainText: { + text: 'Johan Cruijff ArenA', + matches: [{ endOffset: 5 }], + }, + secondaryText: { text: 'Amsterdam, Netherlands' }, + }, + types: ['stadium', 'establishment'], + distanceMeters: 1234, + }, + }, + ], + }) as unknown as typeof fetch; + + const results = await GoogleService.search( + { input: 'johan' }, + { apiKey: 'KEY' } + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + place_id: 'abc123', + description: 'Johan Cruijff ArenA, Amsterdam', + structured_formatting: { + main_text: 'Johan Cruijff ArenA', + secondary_text: 'Amsterdam, Netherlands', + main_text_matched_substrings: [{ offset: 0, length: 5 }], + }, + types: ['stadium', 'establishment'], + distance_meters: 1234, + isQueryPrediction: false, + }); + }); + + it('flags query predictions', async () => { + global.fetch = mockFetch({ + suggestions: [ + { + queryPrediction: { + text: { text: 'pizza near me' }, + structuredFormat: { mainText: { text: 'pizza near me' } }, + }, + }, + ], + }) as unknown as typeof fetch; + + const results = await GoogleService.search( + { input: 'pizza' }, + { apiKey: 'KEY' } + ); + + expect(results[0]!.isQueryPrediction).toBe(true); + expect(results[0]!.place_id).toBe(''); + }); + + it('throws the API error message on non-ok responses', async () => { + global.fetch = mockFetch( + { error: { message: 'API key not valid' } }, + false, + 403 + ) as unknown as typeof fetch; + + await expect( + GoogleService.search({ input: 'x' }, { apiKey: 'BAD' }) + ).rejects.toThrow('API key not valid'); + }); +}); + +describe('GoogleService.searchDetails (Places API New details)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('GETs the place by id with a field mask and normalizes the result', async () => { + const fetchMock = mockFetch({ + id: 'abc123', + displayName: { text: 'Ziggo Dome' }, + formattedAddress: 'De Passage 100, Amsterdam', + location: { latitude: 52.3138, longitude: 4.9377 }, + types: ['stadium'], + primaryType: 'stadium', + googleMapsUri: 'https://maps.google.com/?cid=1', + websiteUri: 'https://www.ziggodome.nl/', + addressComponents: [ + { longText: 'Amsterdam', shortText: 'AMS', types: ['locality'] }, + ], + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const details = await GoogleService.searchDetails('abc123', { + apiKey: 'KEY', + sessionToken: 'tok', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('https://places.googleapis.com/v1/places/abc123'); + expect(url).toContain('sessionToken=tok'); + expect(init.method).toBe('GET'); + expect(init.headers['X-Goog-FieldMask']).toBeDefined(); + + expect(details).toMatchObject({ + place_id: 'abc123', + name: 'Ziggo Dome', + formatted_address: 'De Passage 100, Amsterdam', + website: 'https://www.ziggodome.nl/', + url: 'https://maps.google.com/?cid=1', + geometry: { location: { lat: 52.3138, lng: 4.9377 } }, + address_components: [ + { long_name: 'Amsterdam', short_name: 'AMS', types: ['locality'] }, + ], + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 123c9f9..b83e731 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,25 @@ import { useGoogleAutocomplete } from './GoogleAutocomplete'; import { + GoogleService, + DEFAULT_AUTOCOMPLETE_FIELD_MASK, + DEFAULT_PLACE_DETAILS_FIELD_MASK, type GoogleLocationDetailResult, type GoogleLocationResult, + type AutocompleteRequest, + type Circle, + type LocationBias, + type LocationRestriction, } from './services/google.service'; export { useGoogleAutocomplete, + GoogleService, + DEFAULT_AUTOCOMPLETE_FIELD_MASK, + DEFAULT_PLACE_DETAILS_FIELD_MASK, type GoogleLocationDetailResult, type GoogleLocationResult, + type AutocompleteRequest, + type Circle, + type LocationBias, + type LocationRestriction, }; diff --git a/src/services/google.service.ts b/src/services/google.service.ts index 1059ba7..e44fa84 100644 --- a/src/services/google.service.ts +++ b/src/services/google.service.ts @@ -1,46 +1,188 @@ -import queryString from 'query-string'; - -const BASE_URL = 'https://maps.googleapis.com/maps/api/place'; - -export interface Query { - language: string; - key: string; - types: - | 'address' - | 'geocode' - | '(cities)' - | 'establishment' - | 'geocode|establishment'; - components?: string; - radius?: string; - lat?: number; - lng?: number; - strictBounds?: boolean; +const BASE_URL = 'https://places.googleapis.com/v1'; + +/** + * A circular geographic area used for locationBias / locationRestriction. + * See https://developers.google.com/maps/documentation/places/web-service/place-autocomplete + */ +export interface Circle { + center: { + latitude: number; + longitude: number; + }; + radius: number; +} + +export interface LocationBias { + circle?: Circle; + rectangle?: { + low: { latitude: number; longitude: number }; + high: { latitude: number; longitude: number }; + }; +} + +export type LocationRestriction = LocationBias; + +/** + * Request body for the Places API (New) Autocomplete endpoint. + * https://developers.google.com/maps/documentation/places/web-service/place-autocomplete + */ +export interface AutocompleteRequest { + input: string; + languageCode?: string; + regionCode?: string; + includedRegionCodes?: string[]; + includedPrimaryTypes?: string[]; + locationBias?: LocationBias; + locationRestriction?: LocationRestriction; origin?: { - lat: number; - lng: number; + latitude: number; + longitude: number; + }; + includeQueryPredictions?: boolean; + sessionToken?: string; + inputOffset?: number; +} + +/** + * Default field mask for the autocomplete request. Field masks are optional for + * Autocomplete (New) but limit the response to the fields we normalize below. + */ +export const DEFAULT_AUTOCOMPLETE_FIELD_MASK = [ + 'suggestions.placePrediction.placeId', + 'suggestions.placePrediction.text', + 'suggestions.placePrediction.structuredFormat', + 'suggestions.placePrediction.types', + 'suggestions.placePrediction.distanceMeters', + 'suggestions.queryPrediction.text', + 'suggestions.queryPrediction.structuredFormat', +].join(','); + +/** + * Default field mask for the place details request. Field masks are REQUIRED for + * Place Details (New); omitting them returns an error. + */ +export const DEFAULT_PLACE_DETAILS_FIELD_MASK = [ + 'id', + 'name', + 'displayName', + 'formattedAddress', + 'adrFormatAddress', + 'shortFormattedAddress', + 'location', + 'viewport', + 'types', + 'primaryType', + 'googleMapsUri', + 'websiteUri', + 'addressComponents', + 'utcOffsetMinutes', +].join(','); + +// --- Raw shapes returned by Places API (New) ---------------------------------- + +interface RawText { + text: string; + matches?: Array<{ startOffset?: number; endOffset: number }>; +} + +interface RawStructuredFormat { + mainText?: RawText; + secondaryText?: RawText; +} + +interface RawPlacePrediction { + placeId: string; + text?: RawText; + structuredFormat?: RawStructuredFormat; + types?: string[]; + distanceMeters?: number; +} + +interface RawQueryPrediction { + text?: RawText; + structuredFormat?: RawStructuredFormat; +} + +interface RawSuggestion { + placePrediction?: RawPlacePrediction; + queryPrediction?: RawQueryPrediction; +} + +interface RawAutocompleteResponse { + suggestions?: RawSuggestion[]; +} + +interface RawPlaceDetails { + id: string; + name?: string; + displayName?: { text: string; languageCode?: string }; + formattedAddress?: string; + adrFormatAddress?: string; + shortFormattedAddress?: string; + location?: { latitude: number; longitude: number }; + viewport?: { + low: { latitude: number; longitude: number }; + high: { latitude: number; longitude: number }; }; + types?: string[]; + primaryType?: string; + googleMapsUri?: string; + websiteUri?: string; + utcOffsetMinutes?: number; + addressComponents?: Array<{ + longText: string; + shortText: string; + types: string[]; + }>; } +// --- Normalized shapes (kept backward compatible) ----------------------------- + +/** + * Normalized autocomplete prediction. Field names mirror the legacy library so + * existing consumers keep working, with the raw new-API prediction attached on + * `_raw` for access to anything not normalized. + */ +export interface GoogleLocationResult { + description: string; + place_id: string; + structured_formatting: { + main_text: string; + secondary_text: string; + main_text_matched_substrings: Array<{ + length: number; + offset?: number; + }>; + }; + types: string[]; + distance_meters?: number; + /** True when this is a query prediction rather than a place prediction. */ + isQueryPrediction?: boolean; + _raw: RawPlacePrediction | RawQueryPrediction; +} + +/** + * Normalized place details. Field names mirror the legacy library, with the raw + * new-API place attached on `_raw`. + */ export interface GoogleLocationDetailResult { - adr_address: string; - formatted_address: string; - icon: string; id: string; name: string; place_id: string; - scope: string; - reference: string; + formatted_address: string; + adr_address: string; + vicinity: string; url: string; + website: string; utc_offset: number; - vicinity: string; types: string[]; + primary_type?: string; geometry: { location: { lat: number; lng: number; }; - viewport: { + viewport?: { [type: string]: { lat: number; lng: number; @@ -52,130 +194,190 @@ export interface GoogleLocationDetailResult { short_name: string; types: string[]; }>; + _raw: RawPlaceDetails; } -export interface GoogleLocationResult { - description: string; - id: string; - matched_substrings: Array<{ - length: number; - offset: number; - }>; - place_id: string; - reference: string; - structured_formatting: { - main_text: string; - secondary_text: string; - main_text_matched_substrings: Array<{ - length: number; - }>; - }; - terms: Array<{ - offset: number; - value: string; - }>; - types: string[]; - distance_meters?: number; -} +function normalizeMatches( + text?: RawText +): Array<{ length: number; offset?: number }> { + if (!text?.matches) { + return []; + } -interface NormalizeQuery { - language: string; - key: string; - types: - | 'address' - | 'geocode' - | '(cities)' - | 'establishment' - | 'geocode|establishment'; - components?: string; - radius?: string; - location?: string; - origin?: string; - strictBounds?: boolean; + return text.matches.map((m) => ({ + offset: m.startOffset ?? 0, + length: m.endOffset - (m.startOffset ?? 0), + })); } -const normalizeQuery = (query: Query): NormalizeQuery => { - const { lat, lng, origin, ...rest } = query; +function normalizePrediction( + prediction: RawPlacePrediction | RawQueryPrediction, + isQueryPrediction: boolean +): GoogleLocationResult { + const placeId = (prediction as RawPlacePrediction).placeId ?? ''; + const structured = prediction.structuredFormat; - // The latitude/longitude around which to retrieve place information. This must be specified as latitude,longitude. - let location; - - // If one of the value is provide lat/lng both must be there - if ((lat && !lng) || (lng && !lat)) { - throw new Error('Query: Location must have both lat & lng'); - } - - if (lat && lng) { - location = `${lat},${lng}`; - } - - // The origin point from which to calculate straight-line distance. This must be specified as latitude,longitude. - let originStr; - if (origin) { - if (!origin.lat || !origin.lng) { - throw new Error('Query: Origin must have both lat & lng'); - } - originStr = `${origin.lat},${origin.lng}`; - } + return { + place_id: placeId, + description: prediction.text?.text ?? '', + structured_formatting: { + main_text: structured?.mainText?.text ?? prediction.text?.text ?? '', + secondary_text: structured?.secondaryText?.text ?? '', + main_text_matched_substrings: normalizeMatches(structured?.mainText), + }, + types: (prediction as RawPlacePrediction).types ?? [], + distance_meters: (prediction as RawPlacePrediction).distanceMeters, + isQueryPrediction, + _raw: prediction, + }; +} +function normalizeDetails(place: RawPlaceDetails): GoogleLocationDetailResult { return { - ...rest, - location, - origin: originStr, + id: place.id, + place_id: place.id, + name: place.displayName?.text ?? place.name ?? '', + formatted_address: place.formattedAddress ?? '', + adr_address: place.adrFormatAddress ?? '', + vicinity: place.shortFormattedAddress ?? '', + url: place.googleMapsUri ?? '', + website: place.websiteUri ?? '', + utc_offset: place.utcOffsetMinutes ?? 0, + types: place.types ?? [], + primary_type: place.primaryType, + geometry: { + location: { + lat: place.location?.latitude ?? 0, + lng: place.location?.longitude ?? 0, + }, + viewport: place.viewport + ? { + northeast: { + lat: place.viewport.high.latitude, + lng: place.viewport.high.longitude, + }, + southwest: { + lat: place.viewport.low.latitude, + lng: place.viewport.low.longitude, + }, + } + : undefined, + }, + address_components: (place.addressComponents ?? []).map((c) => ({ + long_name: c.longText, + short_name: c.shortText, + types: c.types, + })), + _raw: place, }; -}; +} + +async function parseError(res: Response): Promise { + let message = res.statusText; + try { + const body = await res.json(); + message = body?.error?.message ?? JSON.stringify(body) ?? message; + } catch { + // ignore body parse failure, fall back to statusText + } + return new Error(message || `Request failed with status ${res.status}`); +} export class GoogleService { + /** + * Autocomplete using Places API (New) - POST /v1/places:autocomplete. + */ public static async search( - term: string, - query: Query, - proxyUrl?: string, - headers?: Record - ): Promise<{ - predictions: GoogleLocationResult[]; - status: string; - }> { - const url = `${BASE_URL}/autocomplete/json?&input=${encodeURIComponent( - term - )}&${queryString.stringify({ ...normalizeQuery(query) })}${ - query.strictBounds ? '&strictbounds' : '' - }`; - - const _url = proxyUrl ? proxyUrl + url : url; - - const res = await fetch(_url, headers ? { headers } : undefined); + request: AutocompleteRequest, + options: { + apiKey: string; + fieldMask?: string; + proxyUrl?: string; + headers?: Record; + } + ): Promise { + const url = `${options.proxyUrl ?? ''}${BASE_URL}/places:autocomplete`; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': options.apiKey, + 'X-Goog-FieldMask': + options.fieldMask ?? DEFAULT_AUTOCOMPLETE_FIELD_MASK, + ...options.headers, + }, + body: JSON.stringify(request), + }); if (!res.ok) { - throw new Error(res.statusText); + throw await parseError(res); } - return res.json(); + const json: RawAutocompleteResponse = await res.json(); + const suggestions = json.suggestions ?? []; + + return suggestions + .map((s) => { + if (s.placePrediction) { + return normalizePrediction(s.placePrediction, false); + } + if (s.queryPrediction) { + return normalizePrediction(s.queryPrediction, true); + } + return null; + }) + .filter((r): r is GoogleLocationResult => r !== null); } + /** + * Place details using Places API (New) - GET /v1/places/{placeId}. + */ public static async searchDetails( - placeid: string, - query: Query & { fields?: string }, - proxyUrl?: string, - headers?: Record + placeId: string, + options: { + apiKey: string; + fieldMask?: string; + languageCode?: string; + regionCode?: string; + sessionToken?: string; + proxyUrl?: string; + headers?: Record; + } ): Promise { - const url = `${BASE_URL}/details/json?${queryString.stringify({ - ...normalizeQuery(query), - placeid, - })}`; - - const _url = proxyUrl ? proxyUrl + url : url; + const params = new URLSearchParams(); + if (options.languageCode) { + params.set('languageCode', options.languageCode); + } + if (options.regionCode) { + params.set('regionCode', options.regionCode); + } + if (options.sessionToken) { + params.set('sessionToken', options.sessionToken); + } - const res = await fetch(_url, headers ? { headers } : undefined); + const query = params.toString(); + const url = `${options.proxyUrl ?? ''}${BASE_URL}/places/${encodeURIComponent( + placeId + )}${query ? `?${query}` : ''}`; - const resJson: { - status: string; - result: GoogleLocationDetailResult; - } = await res.json(); + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': options.apiKey, + 'X-Goog-FieldMask': + options.fieldMask ?? DEFAULT_PLACE_DETAILS_FIELD_MASK, + ...options.headers, + }, + }); - if (!resJson.status) { - throw new Error(res.statusText); + if (!res.ok) { + throw await parseError(res); } - return Promise.resolve(resJson.result); + const place: RawPlaceDetails = await res.json(); + + return normalizeDetails(place); } } diff --git a/yarn.lock b/yarn.lock index fde0d47..67b44dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -60,7 +60,6 @@ __metadata: eslint-plugin-prettier: ^5.0.1 jest: ^29.7.0 prettier: ^3.0.3 - query-string: 7.1.3 react: 18.2.0 react-native: 0.74.3 react-native-builder-bob: ^0.25.0 @@ -6051,13 +6050,6 @@ __metadata: languageName: node linkType: hard -"decode-uri-component@npm:^0.2.2": - version: 0.2.2 - resolution: "decode-uri-component@npm:0.2.2" - checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 - languageName: node - linkType: hard - "decompress-response@npm:^6.0.0": version: 6.0.0 resolution: "decompress-response@npm:6.0.0" @@ -7431,13 +7423,6 @@ __metadata: languageName: node linkType: hard -"filter-obj@npm:^1.1.0": - version: 1.1.0 - resolution: "filter-obj@npm:1.1.0" - checksum: cf2104a7c45ff48e7f505b78a3991c8f7f30f28bd8106ef582721f321f1c6277f7751aacd5d83026cb079d9d5091082f588d14a72e7c5d720ece79118fa61e10 - languageName: node - linkType: hard - "finalhandler@npm:1.1.2": version: 1.1.2 resolution: "finalhandler@npm:1.1.2" @@ -12469,18 +12454,6 @@ __metadata: languageName: node linkType: hard -"query-string@npm:7.1.3": - version: 7.1.3 - resolution: "query-string@npm:7.1.3" - dependencies: - decode-uri-component: ^0.2.2 - filter-obj: ^1.1.0 - split-on-first: ^1.0.0 - strict-uri-encode: ^2.0.0 - checksum: 91af02dcd9cc9227a052841d5c2eecb80a0d6489d05625df506a097ef1c59037cfb5e907f39b84643cbfd535c955abec3e553d0130a7b510120c37d06e0f4346 - languageName: node - linkType: hard - "querystring@npm:^0.2.1": version: 0.2.1 resolution: "querystring@npm:0.2.1" @@ -13794,13 +13767,6 @@ __metadata: languageName: node linkType: hard -"split-on-first@npm:^1.0.0": - version: 1.1.0 - resolution: "split-on-first@npm:1.1.0" - checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30 - languageName: node - linkType: hard - "split2@npm:^3.0.0, split2@npm:^3.2.2": version: 3.2.2 resolution: "split2@npm:3.2.2" @@ -13906,13 +13872,6 @@ __metadata: languageName: node linkType: hard -"strict-uri-encode@npm:^2.0.0": - version: 2.0.0 - resolution: "strict-uri-encode@npm:2.0.0" - checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581 - languageName: node - linkType: hard - "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2"