@amicaldo/strapi-google-maps
Version:
A Google Maps custom field for Strapi, allowing you to pick a location.
521 lines (519 loc) • 18 kB
JavaScript
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
};