UNPKG

@amicaldo/strapi-google-maps

Version:

A Google Maps custom field for Strapi, allowing you to pick a location.

521 lines (519 loc) 18 kB
import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { useRef, useEffect, useState, useReducer } from "react"; import { Loader, Grid, TextInput, Typography, Box, Button } from "@strapi/design-system"; import { useIntl } from "react-intl"; import { u as useConfig } from "./useConfig-COpBpiEq.mjs"; import { useGoogleMap, useJsApiLoader, GoogleMap } from "@react-google-maps/api"; import { ArrowClockwise } from "@strapi/icons"; import Geohash from "latlon-geohash"; import { useGeolocated } from "react-geolocated"; const noPoint = { lat: NaN, lng: NaN }; const isValidPoint = (point) => { return !isNaN(point.lat) && !isNaN(point.lng); }; const isSamePoint = (point1, point2) => { return point1.lat === point2.lat && point1.lng === point2.lng; }; function AdvancedMarker({ position }) { const map = useGoogleMap(); const markerRef = useRef(null); useEffect(() => { if (!map) return; if (!markerRef.current) { const createMarker = async () => { const { AdvancedMarkerElement } = await google.maps.importLibrary( "marker" ); markerRef.current = new AdvancedMarkerElement({ map, position: { lat: position.lat, lng: position.lng } }); }; createMarker(); } return () => { if (markerRef.current) { markerRef.current.map = null; } }; }, [map]); useEffect(() => { if (markerRef.current) { markerRef.current.position = { lat: position.lat, lng: position.lng }; } }, [position]); return null; } function findInputElement(element) { if (element.shadowRoot) { const input = element.shadowRoot.querySelector("input"); if (input) { return input; } } if (element.inputElement instanceof HTMLInputElement) { return element.inputElement; } return null; } function Search({ userCoords, onPlaceSelected, onPlaceDetails }) { const { formatMessage } = useIntl(); const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const initPlaceAutocomplete = async () => { try { await google.maps.importLibrary("places"); } catch (error) { console.error("[Google Maps] Error importing places library:", error); return; } const options = {}; if (userCoords) { options.locationBias = { radius: 5e4, // 50km radius for location biasing center: { lat: userCoords.latitude, lng: userCoords.longitude } }; } const placeAutocomplete = new google.maps.places.PlaceAutocompleteElement(options); const input = findInputElement(placeAutocomplete); if (input) { input.placeholder = formatMessage({ id: "google-maps.input.search.placeholder", defaultMessage: "Search for a place" }); } const handlePlaceSelect = async (event) => { const placePrediction = event.placePrediction; if (placePrediction) { try { const place = placePrediction.toPlace(); await place.fetchFields({ fields: [ "formattedAddress", "location", "addressComponents", "displayName", "id", "types" ] }); if (place.formattedAddress && place.location) { const coordinates = { lat: place.location.lat(), lng: place.location.lng() }; onPlaceSelected({ address: place.formattedAddress, coordinates }); const ac = place.addressComponents || []; const pick = (type) => ac.find((c) => Array.isArray(c.types) && c.types.includes(type)); const components = { streetNumber: pick("street_number")?.longText || pick("street_number")?.long_name, route: pick("route")?.shortText || pick("route")?.short_name, postalCode: pick("postal_code")?.longText || pick("postal_code")?.long_name, city: pick("locality")?.longText || pick("locality")?.long_name, state: pick("administrative_area_level_1")?.shortText || pick("administrative_area_level_1")?.short_name, country: pick("country")?.longText || pick("country")?.long_name }; onPlaceDetails?.({ address: place.formattedAddress, coordinates, components, place: { id: place.id, name: place.displayName, types: place.types } }); } } catch (error) { console.error("[Google Maps] Error fetching place details:", error); } } }; placeAutocomplete.addEventListener("gmp-select", handlePlaceSelect); const style = document.createElement("style"); style.textContent = ` gmp-place-autocomplete { position: absolute; left: 50%; transform: translateX(-50%); margin-top: 10px; width: 300px; --gmp-color-surface: white; --gmp-color-on-surface: #202124; --gmp-color-surface-variant: #f8f9fa; } gmp-place-autocomplete input { box-sizing: border-box; border: 1px solid transparent; width: 100%; height: 40px; padding: 0 12px; border-radius: 3px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); font-size: 14px; outline: none; text-overflow: ellipsis; } `; document.head.appendChild(style); if (containerRef.current) { containerRef.current.appendChild(placeAutocomplete); } return () => { placeAutocomplete.removeEventListener("gmp-select", handlePlaceSelect); if (containerRef.current?.contains(placeAutocomplete)) { containerRef.current.removeChild(placeAutocomplete); } if (document.head.contains(style)) { document.head.removeChild(style); } }; }; const cleanupPromise = initPlaceAutocomplete(); return () => { cleanupPromise.then((cleanup) => { if (cleanup) cleanup(); }).catch((error) => { console.error("[Google Maps] Error during cleanup:", error); }); }; }, [userCoords, formatMessage, onPlaceSelected]); return /* @__PURE__ */ jsx("div", { ref: containerRef, style: { position: "relative", width: "100%" } }); } const fallbackCenter = { lat: 51.51652494189269, lng: 7.45560626859687 }; const libraries = ["places", "marker"]; function MapView({ children, config, focusPoint, onCoordsChange, onAddressChange, onPlaceDetailsChange }) { const { isLoaded } = useJsApiLoader({ googleMapsApiKey: config?.googleMapsKey || "", libraries, id: "google-map-script" }); const [center, setCenter] = useState(fallbackCenter); const { coords: userCoords } = useGeolocated({ positionOptions: { enableHighAccuracy: false } }); useEffect(() => { if (focusPoint) { setCenter(focusPoint); } else if (userCoords) { setCenter({ lat: userCoords.latitude, lng: userCoords.longitude }); } else { setCenter(fallbackCenter); } }, [focusPoint, userCoords]); const onPlaceSelected = (place) => { onCoordsChange({ origin: "placeSearch", value: place.coordinates }); onAddressChange(place.address); }; if (!config || !isLoaded) { return /* @__PURE__ */ jsx("div", { style: { display: "flex", justifyContent: "center" }, children: /* @__PURE__ */ jsx(Loader, { small: true }) }); } return /* @__PURE__ */ jsxs( GoogleMap, { mapContainerStyle: { width: "100%", height: "400px" }, center, zoom: 20, options: { mapId: "DEMO_MAP_ID" }, onClick: ({ latLng }) => { const coords = latLng?.toJSON(); onCoordsChange({ origin: "map", value: coords }); try { const geocoder = new google.maps.Geocoder(); geocoder.geocode({ location: coords }).then(({ results }) => { const best = results && results.length > 0 ? results[0] : void 0; if (!best) return; const formatted = best.formatted_address || ""; onAddressChange(formatted); const ac = best.address_components || []; const pick = (type) => ac.find((c) => Array.isArray(c.types) && c.types.includes(type)); const components = { streetNumber: pick("street_number")?.long_name, route: pick("route")?.long_name, postalCode: pick("postal_code")?.long_name, city: pick("locality")?.long_name, state: pick("administrative_area_level_1")?.short_name, country: pick("country")?.long_name }; onPlaceDetailsChange?.({ address: formatted, coordinates: coords, components, place: { id: best.place_id, name: best.name, types: best.types } }); }).catch((error) => { console.error("[Google Maps] Error reverse geocoding:", error); }); } catch (error) { console.error("[Google Maps] Geocoder init failed:", error); } }, children: [ /* @__PURE__ */ jsx( Search, { userCoords, onPlaceSelected, onPlaceDetails: onPlaceDetailsChange } ), children ] } ); } function generateId(len) { function dec2hex(dec) { return dec.toString(16).padStart(2, "0"); } var arr = new Uint8Array(len / 2); window.crypto.getRandomValues(arr); return Array.from(arr, dec2hex).join(""); } function NumberFields({ coords, onChange }) { const windowInputValueDescriptor = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value" ); if (!windowInputValueDescriptor) return null; const latInputId = generateId(10); const lngInputId = generateId(10); const { lat, lng } = coords; useEffect(() => { const latInput = document.getElementById(latInputId); const lngInput = document.getElementById(lngInputId); const setInputValueNatively = (input, value) => windowInputValueDescriptor.set.call(input, isNaN(value) ? null : value); setInputValueNatively(latInput, lat); setInputValueNatively(lngInput, lng); const changeEvent = new Event("change", { bubbles: true }); latInput.dispatchEvent(changeEvent); lngInput.dispatchEvent(changeEvent); }, [lat, lng]); return /* @__PURE__ */ jsxs(Grid.Root, { gap: 3, children: [ /* @__PURE__ */ jsx(Grid.Item, { col: 6, children: /* @__PURE__ */ jsx( TextInput, { id: latInputId, placeholder: "Latitude", "aria-label": "Latitude", hint: "Latitude", name: "latitude", onChange: (e) => { if (!e.target.value) return; onChange({ lat: Number(e.target.value), lng }); }, size: "S" } ) }), /* @__PURE__ */ jsx(Grid.Item, { col: 6, children: /* @__PURE__ */ jsx( TextInput, { id: lngInputId, placeholder: "Longtitude", "aria-label": "Longtitude", hint: "Longtitude", name: "longtitude", onChange: (e) => { if (!e.target.value) return; onChange({ lat, lng: Number(e.target.value) }); }, size: "S" } ) }) ] }); } const isLocationEqual = (location1, location2) => { if (location1 === location2) return true; if (!location1 || !location2) return false; return location1.address === location2.address && location1.geohash === location2.geohash && isSamePoint(location1.coordinates, location2.coordinates); }; function Input({ attribute, onChange, value, name, required, label, intlLabel }) { const { formatMessage } = useIntl(); const resolvedLabel = intlLabel ? formatMessage(intlLabel) : typeof label === "string" && label || name || formatMessage({ id: "input.label", defaultMessage: "Location Picker" }); const config = useConfig(); const isInitialMount = useRef(true); const [focusPoint, setFocusPoint] = useState(); const [currentPoint, setCurrentPoint] = useReducer((state, action) => { const { origin, value: value2 } = action; const hasCoords = value2 && typeof value2.lat === "number" && typeof value2.lng === "number"; if ((origin === "coordsInput" || origin === "placeSearch" || origin === "fieldValue") && hasCoords && isValidPoint(value2) && !isSamePoint(state, value2)) { setFocusPoint(value2); } return hasCoords ? value2 : state; }, noPoint); const [currentAddress, setCurrentAddress] = useState(""); const [nothingSelectedWarning, setNothingSelectedWarning] = useState(false); const [currentDetails, setCurrentDetails] = useState(null); useEffect(() => { if (required && !isValidPoint(currentPoint)) { setNothingSelectedWarning(true); } else if (nothingSelectedWarning) { setNothingSelectedWarning(false); } }, [currentPoint]); useEffect(() => { if (!value) return; let parsedValue = value; if (typeof value === "string") { parsedValue = JSON.parse(value); } if (!parsedValue) return; const { address, coordinates } = parsedValue; if (address === currentAddress && isSamePoint(currentPoint, coordinates)) return; setCurrentPoint({ origin: "fieldValue", value: coordinates }); setCurrentAddress(address); }, [value]); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; return; } const newLocation = isValidPoint(currentPoint) ? { address: currentDetails?.address ?? currentAddress, coordinates: currentPoint, geohash: Geohash.encode(currentPoint.lat, currentPoint.lng), components: currentDetails?.components, place: currentDetails?.place } : null; let existingLocation = null; if (value) { if (typeof value === "string") { try { existingLocation = JSON.parse(value); } catch (e) { } } else { existingLocation = value; } } if (isLocationEqual(newLocation, existingLocation)) return; const newValue = newLocation ? JSON.stringify(newLocation) : null; onChange({ target: { name, value: newValue, type: attribute.type } }); }, [currentPoint, currentAddress, currentDetails]); useEffect(() => { if (config?.defaultLatitude && config?.defaultLongitude && !value) { setFocusPoint({ lat: parseFloat(config.defaultLatitude), lng: parseFloat(config.defaultLongitude) }); } }, [config]); const onReset = () => { setCurrentPoint({ origin: "reset", value: noPoint }); setCurrentAddress(""); }; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Typography, { variant: "pi", fontWeight: "bold", children: resolvedLabel }), !config && /* @__PURE__ */ jsx("div", { style: { display: "flex", justifyContent: "center" }, children: /* @__PURE__ */ jsx(Loader, { small: true }) }), !!config && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Box, { marginTop: 1, borderColor: nothingSelectedWarning ? "danger600" : "primary200", children: /* @__PURE__ */ jsx( MapView, { config, focusPoint, onCoordsChange: setCurrentPoint, onAddressChange: setCurrentAddress, onPlaceDetailsChange: (details) => { setCurrentDetails({ address: details.address, components: details.components, place: details.place }); }, children: isValidPoint(currentPoint) && /* @__PURE__ */ jsx(AdvancedMarker, { position: currentPoint }) } ) }), currentAddress && /* @__PURE__ */ jsx(Box, { marginTop: 2, children: /* @__PURE__ */ jsx( "input", { type: "text", value: currentAddress, onChange: (e) => setCurrentAddress(e.target.value), style: { width: "100%", padding: "8px 12px", border: "1px solid #d0d7e3", borderRadius: "4px", fontSize: "14px", backgroundColor: "#fff", fontFamily: "inherit" }, "aria-label": formatMessage({ id: "google-maps.selected-address", defaultMessage: "Selected address" }) } ) }), nothingSelectedWarning && /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "danger600", children: formatMessage({ id: "input.error.required" }) }) }), /* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsx( NumberFields, { coords: currentPoint, onChange: (point) => setCurrentPoint({ origin: "coordsInput", value: point }) } ) }), /* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsx(Button, { startIcon: /* @__PURE__ */ jsx(ArrowClockwise, {}), onClick: onReset, children: formatMessage({ id: "input.button.reset" }) }) }) ] }) ] }); } export { Input as default };