diff --git a/packages/visual-editor/src/components/Locator.tsx b/packages/visual-editor/src/components/Locator.tsx deleted file mode 100644 index d60cf5f98..000000000 --- a/packages/visual-editor/src/components/Locator.tsx +++ /dev/null @@ -1,2676 +0,0 @@ -import { FieldLabel, setDeep, WithPuckProps } from "@puckeditor/core"; -import { - FieldValueFilter, - FieldValueStaticFilter, - FilterSearchResponse, - Matcher, - NearFilterValue, - provideHeadless, - Result, - SearchHeadlessProvider, - SelectableStaticFilter, - useSearchActions, - useSearchState, -} from "@yext/search-headless-react"; -import { - AnalyticsProvider, - AppliedFilters, - CardProps, - executeSearch, - Facets, - FilterSearch, - getUserLocation, - Coordinate, - MapboxMap, - MapMarkerOptions, - OnDragHandler, - OnSelectParams, - Pagination, - PinComponentProps, - SearchI18nextProvider, - useAnalytics as useSearchAnalytics, - VerticalResults, -} from "@yext/search-ui-react"; -import React, { useEffect } from "react"; -import { useCollapse } from "react-collapsed"; -import { useTranslation } from "react-i18next"; -import { - FaChevronUp, - FaDotCircle, - FaRegCircle, - FaSlidersH, - FaTimes, -} from "react-icons/fa"; -import { - type MultiSelectorOption, - type MultiSelectorValue, -} from "../fields/MultiSelectorField.tsx"; -import { ImageField } from "../fields/ImageField.tsx"; -import { YextAutoField } from "../fields/YextAutoField.tsx"; -import { useDocument } from "../hooks/useDocument.tsx"; -import { usePreviewWindow } from "../hooks/usePreviewWindow.ts"; -import { getViewport, useWindowWidth } from "../hooks/useViewport.ts"; -import { Button } from "./atoms/button.tsx"; -import { ImageStylingFields } from "./contentBlocks/image/styling.ts"; -import { TranslatableString } from "../types/types.ts"; -import { - resolveLocalizedAssetImage, - TranslatableAssetImage, -} from "../types/images.ts"; -import { - getPreferredDistanceUnit, - toMeters, - toMiles, -} from "../utils/i18n/distance.ts"; -import { msg, pt } from "../utils/i18n/platform.ts"; -import { resolveComponentData } from "../utils/resolveComponentData.tsx"; -import { - createSearchAnalyticsConfig, - createSearchHeadlessConfig, -} from "../utils/searchHeadlessConfig.ts"; -import { getThemeColorCssValue } from "../utils/colors.ts"; -import { ThemeColor, backgroundColors } from "../utils/themeConfigOptions.ts"; -import { - LocatorConfig, - StreamDocument, -} from "../utils/types/StreamDocument.ts"; -import { getValueFromQueryString } from "../utils/urlQueryString.tsx"; -import { Body } from "./atoms/body.tsx"; -import { Heading } from "./atoms/heading.tsx"; -import { - DEFAULT_LOCATOR_RESULT_CARD_PROPS, - DistanceDisplayOption, - Location, - LocatorResultCard, - LocatorResultCardFields, - LocatorResultCardProps, -} from "./LocatorResultCard.tsx"; -import { MapPinIcon } from "./MapPinIcon.js"; -import { useAnalytics } from "@yext/pages-components"; -import { useTemplateMetadata } from "../internal/hooks/useMessageReceivers.ts"; -import { - DEFAULT_ENTITY_TYPE, - LocatorEntityType, - isLocatorEntityType, - getLocatorEntityTypeSourceMap, - getEntityTypeLabel, -} from "../utils/locatorEntityTypes.ts"; -import { - toPuckFields, - YextComponentConfig, - type YextCustomFieldRenderProps, - YextFields, -} from "../fields/fields.ts"; -import { isVisualEditorTestEnv } from "./testing/utils.ts"; - -const RESULTS_LIMIT = 20; -const LOCATION_FIELD = "builtin.location"; -const COUNTRY_CODE_FIELD = "address.countryCode"; -const DEFAULT_MAP_CENTER: Coordinate = { - latitude: 40.741611, - longitude: -74.005371, -}; // New York City -const DEFAULT_RADIUS = 25; -const HOURS_FIELD = "builtin.hours"; -const INITIAL_LOCATION_KEY = "initialLocation"; -const DEFAULT_TITLE = "Find a Location"; -const DEFAULT_DISTANCE_DISPLAY = "distanceFromUser"; -const DEFAULT_LOCATION_STYLE = { - pinIcon: { type: "none" }, - pinColor: backgroundColors.background6.value, -}; -const DEFAULT_PIN_ICON_WIDTH = 14; -const MAX_PIN_ICON_WIDTH = 27; -const PIN_ICON_MAX_FILE_SIZE_BYTES = 128 * 1024; -const BOOLEAN_SUPPORTED_FIELDS = [ - "primaryHeading", - "secondaryHeading", - "tertiaryHeading", -] as const; - -type LocationStyleConfig = Record< - string, - { - color?: ThemeColor; - icon?: string; - customImage?: { - url: string; - width?: number; - aspectRatio?: number; - }; - } ->; - -const LOCATOR_PIN_ICON_FIELD: ImageField = { - type: "image", - label: msg("fields.icon", "Icon"), - hideAltTextField: true, - maxFileSizeBytes: PIN_ICON_MAX_FILE_SIZE_BYTES, -}; - -const getConfiguredMapCenterOrDefault = (mapStartingLocation?: { - latitude: string; - longitude: string; -}): Coordinate => { - if (mapStartingLocation?.latitude && mapStartingLocation.longitude) { - try { - return parseMapStartingLocation(mapStartingLocation); - } catch (e) { - console.error(e); - } - } - - return DEFAULT_MAP_CENTER; -}; - -const getLocatorConfigFromPageSet = (pageSet?: string): LocatorConfig => { - if (!pageSet) { - return {}; - } - - try { - return JSON.parse(pageSet)?.typeConfig?.locatorConfig ?? {}; - } catch { - console.error("Failed to parse locator config from page set"); - return {}; - } -}; - -const translateDistanceUnit = ( - t: (key: string, options?: Record) => string, - unit: "mile" | "kilometer", - count: number -) => { - if (unit === "mile") { - return t("mile", { count, defaultValue: "mile" }); - } - - return t("kilometer", { count, defaultValue: "kilometer" }); -}; - -const makiIconModules = import.meta.glob( - "../../node_modules/@mapbox/maki/icons/*.svg", - { - eager: true, - import: "default", - } -) as Record; - -const makiIconEntries = Object.entries(makiIconModules).map(([path, icon]) => { - const name = path.split("/").pop()?.replace(".svg", "") || path; - return [name, icon]; -}); - -const makiIconMap: Record = Object.fromEntries(makiIconEntries); - -const formatMakiIconLabel = (name: string) => - name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); - -const makiIconOptions = makiIconEntries.map(([name, icon]) => ({ - label: formatMakiIconLabel(name), - value: name, - icon, -})); - -const DEFAULT_MAKI_ICON_NAME = makiIconOptions[0]?.value; - -const ResultCardPropsField = ({ - value, - onChange, -}: { - value?: LocatorResultCardProps; - onChange: (value: LocatorResultCardProps) => void; -}) => { - const streamDocument = useDocument(); - const templateMetadata = useTemplateMetadata(); - const entityTypeSourceMap = getLocatorEntityTypeSourceMap(); - const entityTypeScopes = React.useMemo(() => { - const locatorConfig = getLocatorConfigFromPageSet(streamDocument?._pageset); - return locatorConfig.entityTypeScope ?? []; - }, [streamDocument]); - - /** - * Builds the field schema for the result card editor, including: - * - Conditionally removing the primary CTA section when entity scope is not attached to a page set. - * - Toggling constant value vs. field selector visibility per section. - */ - const resultCardFields = React.useMemo(() => { - if (!value?.entityType) { - return LocatorResultCardFields; - } - let fields = LocatorResultCardFields; - const entityTypeHasSourcePageSet = !!entityTypeSourceMap[value.entityType]; - const scopeExistsForEntityType = - entityTypeScopes.find( - (scope) => scope.entityType === value.entityType - ) !== undefined; - - fields = setDeep( - fields, - `objectFields.primaryCTA.objectFields.link.visible`, - !entityTypeHasSourcePageSet && scopeExistsForEntityType - ); - - // For each section, show either the field selector or the constant value editor. - BOOLEAN_SUPPORTED_FIELDS.forEach((key) => { - const headingConfig = value[key]; - const constantValueEnabled = headingConfig?.constantValueEnabled ?? false; - const field = headingConfig?.field; - const fieldTypeId = field - ? templateMetadata?.locatorDisplayFields?.[field]?.field_type_id - : undefined; - const booleanFieldSelected = - !constantValueEnabled && fieldTypeId === "type.boolean"; - - fields = setDeep( - fields, - `objectFields.${key}.objectFields.field.visible`, - !constantValueEnabled - ); - fields = setDeep( - fields, - `objectFields.${key}.objectFields.constantValue.visible`, - constantValueEnabled - ); - fields = setDeep( - fields, - `objectFields.${key}.objectFields.trueDisplayText.visible`, - !constantValueEnabled && booleanFieldSelected - ); - fields = setDeep( - fields, - `objectFields.${key}.objectFields.falseDisplayText.visible`, - booleanFieldSelected - ); - }); - - const imageConstantValueEnabled = - value.image?.constantValueEnabled ?? false; - fields = setDeep( - fields, - "objectFields.image.objectFields.field.visible", - !imageConstantValueEnabled - ); - fields = setDeep( - fields, - "objectFields.image.objectFields.constantValue.visible", - imageConstantValueEnabled - ); - - return fields; - }, [entityTypeSourceMap, entityTypeScopes, templateMetadata, value]); - - return ( - - ); -}; - -function getFacetFieldOptions( - entityTypes: LocatorEntityType[] -): MultiSelectorOption[] { - const facetFields: MultiSelectorOption[] = []; - const addedValues: Set = new Set(); - entityTypes.forEach((entityType) => - getFacetFieldOptionsForEntityType(entityType).forEach((option) => { - if (option?.value && !addedValues.has(option.value)) { - facetFields.push(option); - addedValues.add(option.value); - } - }) - ); - return facetFields.sort((a, b) => a.label.localeCompare(b.label)); -} - -function getFacetFieldOptionsForEntityType( - entityType: LocatorEntityType -): MultiSelectorOption[] { - let filterOptions: MultiSelectorOption[] = [ - { - label: msg("fields.options.facets.city", "City"), - value: "address.city", - }, - { - label: msg("fields.options.facets.postalCode", "Postal Code"), - value: "address.postalCode", - }, - { - label: msg("fields.options.facets.region", "Region"), - value: "address.region", - }, - { - label: msg("fields.options.facets.brandName", "Brand Name"), - value: "brandReference.name", - }, - ]; - switch (entityType) { - case "location": - filterOptions = filterOptions.concat( - { - label: msg("fields.options.facets.associations", "Associations"), - value: "associations", - }, - { - label: msg("fields.options.facets.brands", "Brands"), - value: "brands", - }, - { - label: msg("fields.options.facets.keywords", "Keywords"), - value: "keywords", - }, - { - label: msg("fields.options.facets.languages", "Languages"), - value: "languages", - }, - { - label: msg("fields.options.facets.paymentOptions", "Payment Options"), - value: "paymentOptions", - }, - { - label: msg("fields.options.facets.products", "Products"), - value: "products", - }, - { - label: msg("fields.options.facets.services", "Services"), - value: "services", - }, - { - label: msg("fields.options.facets.specialties", "Specialties"), - value: "specialities", - } - ); - break; - case "restaurant": - filterOptions = filterOptions.concat( - { - label: msg( - "fields.options.facets.acceptsReservations", - "Accepts Reservations" - ), - value: "acceptsReservations", - }, - { - label: msg("fields.options.facets.associations", "Associations"), - value: "associations", - }, - { - label: msg("fields.options.facets.brands", "Brands"), - value: "brands", - }, - { - label: msg("fields.options.facets.keywords", "Keywords"), - value: "keywords", - }, - { - label: msg("fields.options.facets.languages", "Languages"), - value: "languages", - }, - { - label: msg("fields.options.facets.mealsServed", "Meals Served"), - value: "mealsServed", - }, - { - label: msg("fields.options.facets.neighborhood", "Neighborhood"), - value: "neighborhood", - }, - { - label: msg("fields.options.facets.paymentOptions", "Payment Options"), - value: "paymentOptions", - }, - { - label: msg( - "fields.options.facets.pickupAndDeliveryServices", - "Pickup and Delivery Services" - ), - value: "pickupAndDeliveryServices", - }, - { - label: msg("fields.options.facets.priceRange", "Price Range"), - value: "priceRange", - }, - { - label: msg("fields.options.facets.services", "Services"), - value: "services", - }, - { - label: msg("fields.options.facets.specialties", "Specialties"), - value: "specialities", - } - ); - break; - case "healthcareFacility": - filterOptions = filterOptions.concat( - { - label: msg( - "fields.options.facets.acceptingNewPatients", - "Accepting New Patients" - ), - value: "acceptingNewPatients", - }, - { - label: msg( - "fields.options.facets.conditionsTreated", - "Conditions Treated" - ), - value: "conditionsTreated", - }, - { - label: msg( - "fields.options.facets.insuranceAccepted", - "Insurance Accepted" - ), - value: "insuranceAccepted", - }, - { - label: msg("fields.options.facets.paymentOptions", "Payment Options"), - value: "paymentOptions", - }, - { - label: msg("fields.options.facets.services", "Services"), - value: "services", - } - ); - break; - case "healthcareProfessional": - filterOptions = filterOptions.concat( - { - label: msg( - "fields.options.facets.acceptingNewPatients", - "Accepting New Patients" - ), - value: "acceptingNewPatients", - }, - { - label: msg( - "fields.options.facets.admittingHospitals", - "Admitting Hospitals" - ), - value: "admittingHospitals", - }, - { - label: msg("fields.options.facets.brands", "Brands"), - value: "brands", - }, - { - label: msg("fields.options.facets.certifications", "Certifications"), - value: "certifications", - }, - { - label: msg( - "fields.options.facets.conditionsTreated", - "Conditions Treated" - ), - value: "conditionsTreated", - }, - { - label: msg("fields.options.facets.degrees", "Degrees"), - value: "degrees", - }, - { - label: msg("fields.options.facets.gender", "Gender"), - value: "gender", - }, - { - label: msg( - "fields.options.facets.insuranceAccepted", - "Insurance Accepted" - ), - value: "insuranceAccepted", - }, - { - label: msg("fields.options.facets.languages", "Languages"), - value: "languages", - }, - { - label: msg("fields.options.facets.neighborhood", "Neighborhood"), - value: "neighborhood", - }, - { - label: msg("fields.options.facets.officeName", "Office Name"), - value: "officeName", - }, - { - label: msg("fields.options.facets.services", "Services"), - value: "services", - } - ); - break; - case "hotel": - filterOptions = filterOptions.concat( - { label: msg("fields.options.facets.bar", "Bar"), value: "bar" }, - { - label: msg("fields.options.facets.catsAllowed", "Cats Allowed"), - value: "catsAllowed", - }, - { - label: msg("fields.options.facets.dogsAllowed", "Dogs Allowed"), - value: "dogsAllowed", - }, - { - label: msg("fields.options.facets.parking", "Parking"), - value: "parking", - }, - { label: msg("fields.options.facets.pools", "Pools"), value: "pools" } - ); - break; - case "financialProfessional": - filterOptions = filterOptions.concat( - { - label: msg("fields.options.facets.certifications", "Certifications"), - value: "certifications", - }, - { - label: msg("fields.options.facets.interests", "Interests"), - value: "interests", - }, - { - label: msg("fields.options.facets.languages", "Languages"), - value: "languages", - }, - { - label: msg("fields.options.facets.services", "Services"), - value: "services", - }, - { - label: msg("fields.options.facets.specialties", "Specialties"), - value: "specialties", - }, - { - label: msg( - "fields.options.facets.yearsOfExperience", - "Years of Experience" - ), - value: "yearsOfExperience", - } - ); - break; - default: - break; - } - return filterOptions; -} - -export interface LocatorProps { - /** - * The visual theme for the map tiles, chosen from a predefined list of Mapbox styles. - * @defaultValue 'mapbox://styles/mapbox/streets-v12' - */ - mapStyle?: string; - - /** - * Props to customize the locator map pin styles. - * Controls map pin appearance depending on the result's entity type. - * The number of entries is locked to the locator entity types for the page set. - */ - locationStyles: Array<{ - /** The entity type this style applies to. */ - entityType: LocatorEntityType; - /** Whether to render an icon in the pin. */ - pinIcon?: { - type: "none" | "icon" | "customImage"; - /** Defaults to the first available Maki icon when type is 'icon'. */ - iconName?: string; - /** Image rendered within the pin when type is 'customImage'. */ - image?: TranslatableAssetImage; - /** - * Width of the custom image rendered within the pin. - * @defaultValue 14 - * */ - width?: number; - /** Aspect ratio of the custom image rendered within the pin. */ - aspectRatio?: number; - }; - /** The color applied to the pin. */ - pinColor?: ThemeColor; - }>; - - /** - * Configuration for the filters available in the locator search experience. - */ - filters: { - /** - * If 'true', displays a button to filter for locations that are currently open. - * @defaultValue false - */ - openNowButton: boolean; - /** - * If 'true', displays several distance options to filter searches to only locations within - * a certain radius. - * @defaultValue false - */ - showDistanceOptions: boolean; - /** Accent color for filter button and icons. */ - accentColor?: ThemeColor; - /** Which fields are facetable in the search experience */ - facetFields?: MultiSelectorValue; - }; - - /** - * The starting location for the map. - */ - mapStartingLocation?: { - latitude: string; - longitude: string; - }; - /** - * Configuration for the locator page heading. - * Allows customizing the title text and its color. - */ - pageHeading?: { - /** The title displayed at the top of the locator page. */ - title: TranslatableString; - /** - * The color applied to the locator page title. - * @defaultValue inherited from theme - */ - color?: ThemeColor; - }; - /** - * Props to customize the locator result card component. - * Controls which fields are displayed and their styling depending on the result's entity type. - * The number of entries is locked to the locator entity types for the page set. - */ - resultCard: Array<{ - /** Props to customize the locator result card component. */ - props: LocatorResultCardProps; - }>; - /** Controls which distance value to display on each locator result card. */ - distanceDisplay?: DistanceDisplayOption; -} - -const locatorFields: YextFields = { - mapStyle: { - type: "basicSelector", - label: msg("fields.mapStyle", "Map Style"), - options: [ - { - label: msg("fields.options.default", "Default"), - value: "mapbox://styles/mapbox/streets-v12", - }, - { - label: msg("fields.options.satellite", "Satellite"), - value: "mapbox://styles/mapbox/satellite-streets-v12", - }, - { - label: msg("fields.options.light", "Light"), - value: "mapbox://styles/mapbox/light-v11", - }, - { - label: msg("fields.options.dark", "Dark"), - value: "mapbox://styles/mapbox/dark-v11", - }, - { - label: msg("fields.options.navigationDay", "Navigation (Day)"), - value: "mapbox://styles/mapbox/navigation-day-v1", - }, - { - label: msg("fields.options.navigationNight", "Navigation (Night)"), - value: "mapbox://styles/mapbox/navigation-night-v1", - }, - ], - }, - locationStyles: { - type: "array", - label: msg("fields.pinStyles", "Location styles"), - getItemSummary: (item: LocatorProps["locationStyles"][number]) => - getEntityTypeLabel(item.entityType), - arrayFields: { - entityType: { - label: msg("fields.entityType", "Entity Type"), - type: "text", - visible: false, - }, - pinIcon: { - type: "custom", - render: ({ - value, - onChange, - }: YextCustomFieldRenderProps< - LocatorProps["locationStyles"][number]["pinIcon"] - >) => { - const selectedType = value?.type ?? "none"; - return ( -
- - onChange({ - ...value, - type, - iconName: - type === "icon" - ? (value?.iconName ?? DEFAULT_MAKI_ICON_NAME) - : undefined, - }) - } - /> - {selectedType === "icon" && ( - - onChange({ ...value, type: "icon", iconName }) - } - /> - )} - {selectedType === "customImage" && ( - <> - - onChange({ ...value, type: "customImage", image }) - } - /> - - - onChange({ - ...value, - type: "customImage", - width, - }) - } - /> - - - onChange({ - ...value, - type: "customImage", - aspectRatio, - }) - } - /> - - )} -
- ); - }, - }, - pinColor: { - type: "basicSelector", - label: msg("fields.pinColor", "Pin Color"), - options: "BACKGROUND_COLOR", - }, - }, - defaultItemProps: { - entityType: DEFAULT_ENTITY_TYPE, - pinIcon: { type: "none" }, - pinColor: backgroundColors.background6.value, - }, - }, - filters: { - label: msg("fields.filters", "Filters"), - type: "object", - objectFields: { - openNowButton: { - label: msg("fields.options.includeOpenNow", "Include Open Now Button"), - type: "radio", - options: [ - { label: msg("fields.options.yes", "Yes"), value: true }, - { label: msg("fields.options.no", "No"), value: false }, - ], - }, - showDistanceOptions: { - label: msg( - "fields.options.showDistanceOptions", - "Include Distance Options" - ), - type: "radio", - options: [ - { label: msg("fields.options.yes", "Yes"), value: true }, - { label: msg("fields.options.no", "No"), value: false }, - ], - }, - accentColor: { - type: "basicSelector", - label: msg("fields.accentColor", "Accent Color"), - options: "SITE_COLOR", - }, - facetFields: { - type: "multiSelector", - label: msg("fields.dynamicFilters", "Dynamic Filters"), - dropdownLabel: msg("fields.field", "Field"), - options: () => { - const entityTypeSourceMap = getLocatorEntityTypeSourceMap(); - const entityTypes = - Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); - return getFacetFieldOptions(entityTypes); - }, - placeholderOptionLabel: msg( - "fields.options.selectAField", - "Select a field" - ), - } as any, // TODO(SUMO-8378): remove 'as any' when puck fixes objectFields typing - }, - }, - mapStartingLocation: { - type: "object", - label: msg("fields.options.mapStartingLocation", "Map Starting Location"), - objectFields: { - latitude: { - label: msg("fields.latitude", "Latitude"), - type: "text", - }, - longitude: { - label: msg("fields.longitude", "Longitude"), - type: "text", - }, - }, - }, - pageHeading: { - type: "object", - label: msg("fields.pageHeading", "Page Heading"), - objectFields: { - title: { - type: "translatableString", - label: msg("fields.title", "Title"), - filter: { types: ["type.string"] }, - }, - color: { - type: "basicSelector", - label: msg("fields.color", "Color"), - options: "SITE_COLOR", - }, - }, - }, - resultCard: { - type: "array", - label: msg("fields.resultCard", "Result Card"), - getItemSummary: (item: LocatorProps["resultCard"][number]) => - getEntityTypeLabel(item.props.entityType), - arrayFields: { - props: { - type: "custom", - render: ({ - value, - onChange, - }: YextCustomFieldRenderProps< - LocatorProps["resultCard"][number]["props"] - >) => , - }, - }, - defaultItemProps: { - props: DEFAULT_LOCATOR_RESULT_CARD_PROPS, - }, - }, - distanceDisplay: { - type: "basicSelector", - label: msg("fields.distanceDisplay", "Distance Display"), - options: [ - { - label: msg("fields.options.distanceFromUser", "Distance from User"), - value: "distanceFromUser", - }, - { - label: msg("fields.options.distanceFromSearch", "Distance from Search"), - value: "distanceFromSearch", - }, - { - label: msg("fields.options.hidden", "Hidden"), - value: "hidden", - }, - ], - }, -}; - -/** - * Available on Locator templates. - */ -export const LocatorComponent: YextComponentConfig = { - fields: locatorFields, - /** - * Locks array lengths for `locationStyles` and `resultCard` to the current - * locator entity types so each entity type has exactly one entry. - */ - resolveFields: (_data, params) => { - const entityDocument = params.metadata?.streamDocument; - const entityTypeSourceMap = entityDocument - ? getLocatorEntityTypeSourceMap(entityDocument) - : { [DEFAULT_ENTITY_TYPE]: undefined }; - const entityTypes = Object.keys( - entityTypeSourceMap - ) as (keyof typeof entityTypeSourceMap)[]; - const entityTypeCount = entityTypes.length; - - let updatedFields: YextFields = { ...locatorFields }; - updatedFields = setDeep( - updatedFields, - "locationStyles.min", - entityTypeCount - ); - updatedFields = setDeep( - updatedFields, - "locationStyles.max", - entityTypeCount - ); - updatedFields = setDeep(updatedFields, "resultCard.min", entityTypeCount); - updatedFields = setDeep(updatedFields, "resultCard.max", entityTypeCount); - - return toPuckFields(updatedFields); - }, - defaultProps: { - locationStyles: [], - resultCard: [], - filters: { - openNowButton: false, - showDistanceOptions: false, - }, - pageHeading: { - title: { defaultValue: DEFAULT_TITLE }, - }, - distanceDisplay: DEFAULT_DISTANCE_DISPLAY, - }, - label: msg("components.locator", "Locator"), - /** - * Reconciles `props.locationStyles` and `props.resultCard` so - * each list has exactly one entry per current locator entity type. - * Missing or mismatched entries are rebuilt from existing - * values and backfilled with defaults. - */ - resolveData: (data, params) => { - const entityDocument = params.metadata?.streamDocument; - const entityTypeSourceMap = entityDocument - ? getLocatorEntityTypeSourceMap(entityDocument) - : { [DEFAULT_ENTITY_TYPE]: undefined }; - const entityTypes = Object.keys( - entityTypeSourceMap - ) as (keyof typeof entityTypeSourceMap)[]; - - const previousLocationStyles = data.props.locationStyles ?? []; - const previousResultCard = data.props.resultCard ?? []; - const hasSameEntityTypes = (currentEntityTypes: string[]) => - currentEntityTypes.length === entityTypes.length && - entityTypes.every((entityType) => - currentEntityTypes.includes(entityType) - ); - - const locationStylesByEntityType = new globalThis.Map( - previousLocationStyles - .filter((item) => !!item.entityType) - .map((item) => [item.entityType, item] as const) - ); - const resultCardsByEntityType = new globalThis.Map( - previousResultCard - .filter((item) => !!item?.props?.entityType) - .map((item) => [item.props.entityType, item] as const) - ); - - const previouslocationStyleEntityTypes = previousLocationStyles.map( - (item) => item.entityType - ); - const previousresultCardEntityTypes = previousResultCard.map( - (item) => item.props?.entityType - ); - - const shouldReconcileLocationStyles = - previousLocationStyles.length === 0 || - !hasSameEntityTypes(previouslocationStyleEntityTypes); - const shouldReconcileResultCards = - previousResultCard.length === 0 || - !hasSameEntityTypes(previousresultCardEntityTypes); - - if (shouldReconcileLocationStyles) { - const newLocationStyles = entityTypes.map((entityType) => ({ - ...DEFAULT_LOCATION_STYLE, - ...locationStylesByEntityType.get(entityType), - entityType, - })); - data = setDeep(data, "props.locationStyles", newLocationStyles); - } - - if (shouldReconcileResultCards) { - const newResultCards = entityTypes.map((entityType) => { - const existing = resultCardsByEntityType.get(entityType); - return { - props: { - ...(existing?.props ?? DEFAULT_LOCATOR_RESULT_CARD_PROPS), - entityType, - }, - }; - }); - data = setDeep(data, "props.resultCard", newResultCards); - } - - return data; - }, - render: (props) => , -}; - -const LocatorWrapper = (props: WithPuckProps) => { - const streamDocument = useDocument(); - const { searchAnalyticsConfig, searcher } = React.useMemo(() => { - const searchHeadlessConfig = createSearchHeadlessConfig( - streamDocument, - props.puck.metadata?.experienceKeyEnvVar - ); - if (searchHeadlessConfig === undefined) { - return { searchAnalyticsConfig: undefined, searcher: undefined }; - } - - const searchAnalyticsConfig = createSearchAnalyticsConfig(streamDocument); - return { - searchAnalyticsConfig, - searcher: provideHeadless(searchHeadlessConfig), - }; - }, [streamDocument.id, streamDocument.locale]); - - if (searcher === undefined || searchAnalyticsConfig === undefined) { - console.warn( - "Could not create Locator component because Search Headless or Search Analytics config is undefined. Please check your environment variables." - ); - return <>; - } - searcher.setSessionTrackingEnabled(true); - return ( - - - - - - - - ); -}; - -type SearchState = "not started" | "loading" | "complete"; - -const LoadingMapPlaceholder = () => { - const { t } = useTranslation(); - - return ( -
-
- - {t("loadingMap", "Loading Map...")} - -
-
- ); -}; - -const LocatorTestMap = () => { - return ( -
- - ); -}; - -const LocatorInternal = ({ - mapStyle, - locationStyles, - filters: { openNowButton, showDistanceOptions, accentColor, facetFields }, - mapStartingLocation, - resultCard: resultCardConfigs, - distanceDisplay, - pageHeading, -}: LocatorProps) => { - // Adds unified [enable|disable]YextAnalytics to the window for both Pages and Search - // analytics. Typically used during consent banner implementation. - const searchAnalytics = useSearchAnalytics(); - const pagesAnalytics = useAnalytics(); - useEffect(() => { - (window as any).enableYextAnalytics = () => { - searchAnalytics?.optIn(); - pagesAnalytics?.optIn(); - }; - (window as any).disableYextAnalytics = () => { - searchAnalytics?.optOut(); - pagesAnalytics?.optOut(); - }; - - return () => { - delete (window as any).enableYextAnalytics; - delete (window as any).disableYextAnalytics; - }; - }, [searchAnalytics, pagesAnalytics]); - - const { t, i18n } = useTranslation(); - const previewWindow = usePreviewWindow(); - const windowWidth = useWindowWidth(previewWindow); - const { isMobile } = getViewport(windowWidth); - const preferredUnit = getPreferredDistanceUnit(i18n.language); - const streamDocument = useDocument(); - const entityTypeSourceMap = getLocatorEntityTypeSourceMap(streamDocument); - const entityTypes = - Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); - const resultCount = useSearchState( - (state) => state.vertical.resultsCount || 0 - ); - const searchResults = useSearchState( - (state) => (state.vertical.results || []) as Result[] - ); - const queryParamString = - typeof window === "undefined" ? "" : window.location.search; - const initialLocationParam = getValueFromQueryString( - INITIAL_LOCATION_KEY, - queryParamString - ); - - const iframe = - typeof document === "undefined" - ? undefined - : (document.getElementById("preview-frame") as HTMLIFrameElement); - - let mapboxApiKey = streamDocument._env?.YEXT_MAPBOX_API_KEY; - if ( - iframe?.contentDocument && - streamDocument._env?.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY - ) { - // If we are in the layout editor, use the non-URL-restricted Mapbox API key - mapboxApiKey = streamDocument._env.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY; - } - - const [showSearchAreaButton, setShowSearchAreaButton] = React.useState(false); - const [mapCenter, setMapCenter] = React.useState(); - const [mapRadius, setMapRadius] = React.useState(); - /** Explicit filter radius selected by the user, in meters */ - const [selectedDistanceMeters, setSelectedDistanceMeters] = React.useState< - number | null - >(null); - const [selectedDistanceOption, setSelectedDistanceOption] = React.useState< - number | null - >(null); - /** Radius of last location near filter returned by the filter search API */ - const apiFilterRadius = React.useRef(null); - - const handleDrag: OnDragHandler = (center, bounds) => { - setMapCenter({ - latitude: center.latitude, - longitude: center.longitude, - }); - setMapRadius(center.distanceTo(bounds.getNorthEast())); - setShowSearchAreaButton(true); - }; - - const [isOpenNowSelected, setIsOpenNowSelected] = React.useState(false); - const openNowFilter: SelectableStaticFilter = React.useMemo( - () => ({ - filter: { - kind: "fieldValue", - fieldId: HOURS_FIELD, - matcher: Matcher.OpenAt, - value: "now", - }, - selected: isOpenNowSelected, - displayName: t("openNow", "Open Now"), - }), - [isOpenNowSelected] - ); - - const handleSearchAreaClick = () => { - if (mapCenter && mapRadius) { - searchActions.setOffset(0); - const locationFilter: SelectableStaticFilter = { - selected: true, - displayName: "", - filter: { - kind: "fieldValue", - fieldId: "builtin.location", - value: { - lat: mapCenter.latitude, - lng: mapCenter.longitude, - radius: mapRadius, - name: t("customSearchArea", "Custom Search Area"), - }, - matcher: Matcher.Near, - }, - }; - searchActions.setStaticFilters([locationFilter, openNowFilter]); - searchActions.executeVerticalQuery(); - setSearchState("loading"); - setShowSearchAreaButton(false); - } - }; - - const searchActions = useSearchActions(); - - const selectedFacets: string[] = React.useMemo( - () => - facetFields?.selections - ?.filter((selection) => selection.value !== undefined) - ?.map((selection) => selection.value as string) ?? [], - [facetFields] - ); - React.useEffect(() => { - searchActions.setFacetAllowList(selectedFacets); - }, [searchActions, selectedFacets]); - - const filterDisplayName = useSearchState( - (state) => - state.filters?.static?.find( - (filter) => - filter.filter.kind === "fieldValue" && - (filter.filter.fieldId === LOCATION_FIELD || - filter.filter.fieldId === COUNTRY_CODE_FIELD) - )?.displayName - ); - const handleFilterSelect = (params: OnSelectParams) => { - const newDisplayName = params.newDisplayName; - const filter = params.newFilter; - - let locationFilter: SelectableStaticFilter; - let nearFilterValue: NearFilterValue | undefined; - switch (filter.matcher) { - case Matcher.Near: { - nearFilterValue = filter.value as NearFilterValue; - apiFilterRadius.current = nearFilterValue.radius; - // only overwrite radius from filter if display options are enabled - const radius = - showDistanceOptions && selectedDistanceMeters - ? selectedDistanceMeters - : nearFilterValue.radius; - locationFilter = buildNearLocationFilterFromPrevious( - nearFilterValue, - newDisplayName, - radius - ); - break; - } - case Matcher.Equals: { - apiFilterRadius.current = null; - locationFilter = buildEqualsLocationFilter(filter, newDisplayName); - break; - } - default: { - throw new Error(`Unsupported matcher type: ${filter.matcher}`); - } - } - - searchActions.setOffset(0); - searchActions.setStaticFilters([locationFilter, openNowFilter]); - searchActions.executeVerticalQuery(); - setSearchState("loading"); - if ( - nearFilterValue?.lat && - nearFilterValue?.lng && - areValidCoordinates(nearFilterValue.lat, nearFilterValue.lng) - ) { - setMapCenter({ - latitude: nearFilterValue.lat, - longitude: nearFilterValue.lng, - }); - setMapRadius(nearFilterValue.radius); - } - }; - - const searchLoading = useSearchState((state) => state.searchStatus.isLoading); - - const [searchState, setSearchState] = - React.useState("not started"); - - React.useEffect(() => { - if (!searchLoading && searchState === "loading") { - setSearchState("complete"); - } - }, [searchLoading, searchState]); - - React.useEffect(() => { - if (selectedDistanceOption === null) { - setSelectedDistanceMeters(null); - return; - } - setSelectedDistanceMeters(toMeters(selectedDistanceOption, preferredUnit)); - }, [preferredUnit, selectedDistanceOption]); - - const resultsRef = React.useRef>([]); - const resultsContainer = React.useRef(null); - const [mobileResults, setMobileResults] = React.useState[]>( - [] - ); - // Tracks the selected pin index to highlight the corresponding result card. - const [selectedResultIndex, setSelectedResultIndex] = React.useState< - number | null - >(null); - - const setResultsRef = React.useCallback((index: number) => { - if (!resultsRef?.current) return null; - return (result: HTMLDivElement) => (resultsRef.current[index] = result); - }, []); - - const scrollToResult = React.useCallback( - (result: Result | undefined) => { - if (result) { - if (typeof result.index === "number") { - setSelectedResultIndex(result.index); - } - let scrollPos = 0; - // the search results that are listed above this result - const previousResultsRef = resultsRef.current.filter( - (r, index) => r && result.index && index < result.index - ); - - // sum up the height of all search results that are listed above this result - if (previousResultsRef.length > 0) { - scrollPos = previousResultsRef - .map((elem) => elem?.scrollHeight ?? 0) - .reduce((total, height) => total + height); - } - - resultsContainer.current?.scroll({ - top: scrollPos, - behavior: "smooth", - }); - } else { - setSelectedResultIndex(null); - } - }, - [resultsContainer] - ); - - const markerOptionsOverride = React.useCallback( - (selected: boolean): MapMarkerOptions => { - return { - offset: (selected ? [0, -21] : [0, -14]) as [number, number], - }; - }, - [] - ); - - const getResultCardProps = React.useCallback( - (entityType?: LocatorEntityType) => { - const existingConfig = (resultCardConfigs ?? []).find( - (item) => item.props.entityType === entityType - ); - if (existingConfig) { - return existingConfig.props; - } - return DEFAULT_LOCATOR_RESULT_CARD_PROPS; - }, - [resultCardConfigs] - ); - - const CardComponent = React.useCallback( - (result: CardProps) => { - let resultCardProps = DEFAULT_LOCATOR_RESULT_CARD_PROPS; - const resultEntityType = result.result.entityType; - if (resultEntityType && isLocatorEntityType(resultEntityType)) { - resultCardProps = getResultCardProps(resultEntityType); - } else { - console.warn( - "Unexpected entityType from search result: ", - resultEntityType - ); - } - return ( - - ); - }, - [ - distanceDisplay, - getResultCardProps, - entityTypeSourceMap, - selectedResultIndex, - ] - ); - - const [userLocationRetrieved, setUserLocationRetrieved] = - React.useState(false); - - const locationStylesConfig = React.useMemo(() => { - const config: LocationStyleConfig = {}; - (locationStyles ?? []).forEach((locationStyle) => { - const entityType = locationStyle.entityType; - if (!entityType) return; - const iconValue = - locationStyle.pinIcon?.type === "icon" - ? locationStyle.pinIcon.iconName - : undefined; - const customImageValue = - locationStyle.pinIcon?.type === "customImage" - ? resolveLocalizedAssetImage( - locationStyle.pinIcon.image, - i18n.language - ) - : undefined; - const customImageUrl = customImageValue?.url?.trim(); - config[entityType] = { - color: locationStyle.pinColor, - icon: - typeof iconValue === "string" ? makiIconMap[iconValue] : undefined, - customImage: customImageUrl - ? { - url: customImageUrl, - width: locationStyle.pinIcon?.width, - aspectRatio: locationStyle.pinIcon?.aspectRatio, - } - : undefined, - }; - }); - return config; - }, [i18n.language, locationStyles]); - - const initialMapCenter = React.useMemo( - () => getConfiguredMapCenterOrDefault(mapStartingLocation), - [mapStartingLocation] - ); - const [centerCoords, setCenterCoords] = - React.useState(initialMapCenter); - const [isInitialMapLocationResolved, setIsInitialMapLocationResolved] = - React.useState(false); - const canShowMoreMobileResults = - isMobile && mobileResults.length < resultCount; - - const mapProps = React.useMemo( - () => ({ - mapStyle, - centerCoords, - onDragHandler: handleDrag, - scrollToResult: scrollToResult, - markerOptionsOverride: markerOptionsOverride, - locationStyleConfig: locationStylesConfig, - }), - [ - centerCoords, - handleDrag, - mapStyle, - markerOptionsOverride, - scrollToResult, - locationStylesConfig, - ] - ); - - React.useEffect(() => { - let isCancelled = false; - - const resolveLocationAndSearch = async () => { - setIsInitialMapLocationResolved(false); - setCenterCoords(initialMapCenter); - setMapCenter(initialMapCenter); - - const radius = - showDistanceOptions && selectedDistanceMeters - ? selectedDistanceMeters - : toMeters(DEFAULT_RADIUS, preferredUnit); - // default location filter to configured starting location or NYC - let initialLocationFilter = buildNearLocationFilterFromCoords( - initialMapCenter.latitude, - initialMapCenter.longitude, - radius - ); - const doSearch = () => { - searchActions.setVerticalLimit(RESULTS_LIMIT); - searchActions.setOffset(0); - searchActions.setStaticFilters([initialLocationFilter]); - searchActions.executeVerticalQuery(); - setSearchState("loading"); - if ( - initialLocationFilter.filter.kind === "fieldValue" && - initialLocationFilter.filter.matcher === Matcher.Near - ) { - const filterValue = initialLocationFilter.filter - .value as NearFilterValue; - const centerCoords: Coordinate = { - longitude: filterValue.lng, - latitude: filterValue.lat, - }; - if ( - !isCancelled && - areValidCoordinates(centerCoords.latitude, centerCoords.longitude) - ) { - setCenterCoords(centerCoords); - setMapCenter(centerCoords); - setMapRadius(filterValue.radius); - } - } - if (!isCancelled) { - setIsInitialMapLocationResolved(true); - } - }; - - const foundStartingLocationFromQueryParam = async ( - queryParam: string - ): Promise => { - return searchActions - .executeFilterSearch(queryParam, false, [ - { - fieldApiName: LOCATION_FIELD, - entityType: entityTypes[0] ?? DEFAULT_ENTITY_TYPE, - fetchEntities: false, - }, - ]) - .then((response: FilterSearchResponse | undefined) => { - const firstResult = response?.sections[0]?.results[0]; - const resultFilter = firstResult?.filter; - if (!firstResult || !resultFilter) { - return false; - } - - switch (resultFilter.matcher) { - case Matcher.Near: { - const filterFromResult = resultFilter.value as NearFilterValue; - initialLocationFilter = buildNearLocationFilterFromPrevious( - filterFromResult, - firstResult.value - ); - apiFilterRadius.current = filterFromResult.radius; - return true; - } - case Matcher.Equals: { - initialLocationFilter = buildEqualsLocationFilter( - resultFilter, - firstResult.value - ); - apiFilterRadius.current = null; - return true; - } - default: { - return false; - } - } - }) - .catch((e) => { - console.warn("Filter search for initial location failed:", e); - return false; - }); - }; - - // 1. Check if a location could be determined from the initialLocation query parameter - if ( - initialLocationParam && - (await foundStartingLocationFromQueryParam(initialLocationParam)) - ) { - doSearch(); - return; - } - - try { - // 2. Try to get user location via Geolocation API - const location = await getUserLocation(); - const lat = location.coords.latitude; - const lng = location.coords.longitude; - setUserLocationRetrieved(true); - - // Try to reverse-geocode the coordinates to a human-readable place name using Mapbox - let displayName: string | undefined; - try { - if (mapboxApiKey) { - const lang = - (streamDocument.locale as string) || - (typeof navigator !== "undefined" - ? navigator.language - : undefined) || - "en"; - const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxApiKey}&types=place,region,country&limit=1&language=${encodeURIComponent( - lang - )}`; - - const res = await fetch(url); - if (res.ok) { - const data = await res.json(); - const feature = data.features && data.features[0]; - displayName = feature?.place_name || undefined; - } - } - } catch (e) { - console.warn("Reverse geocoding failed:", e); - } finally { - initialLocationFilter = buildNearLocationFilterFromCoords( - lat, - lng, - radius, - displayName - ); - } - } catch { - // Fall back to the configured starting location or default center. - } - - doSearch(); - }; - - resolveLocationAndSearch().catch((e) => { - if (!isCancelled) { - setIsInitialMapLocationResolved(true); - } - console.error("Failed perform search:", e); - }); - return () => { - isCancelled = true; - }; - }, [initialLocationParam, initialMapCenter, searchActions]); - - const handleOpenNowClick = (selected: boolean) => { - if (selected === isOpenNowSelected) { - // Prevents us from trying to set Open Now filter to false when it's not set - return; - } - searchActions.setFilterOption({ - filter: { - kind: "fieldValue", - fieldId: HOURS_FIELD, - matcher: Matcher.OpenAt, - value: "now", - }, - selected, - displayName: t("openNow", "Open Now"), - }); - setIsOpenNowSelected(selected); - searchActions.setOffset(0); - executeSearch(searchActions); - }; - - const searchFilters = useSearchState((state) => state.filters); - const currentOffset = useSearchState((state) => state.vertical.offset); - const previousOffset = React.useRef(undefined); - const prevIsMobile = React.useRef(isMobile); - - // Scroll to top when pagination changes - React.useEffect(() => { - if ( - !isMobile && - currentOffset !== previousOffset.current && - previousOffset.current !== undefined - ) { - resultsContainer.current?.scroll({ - top: 0, - behavior: "smooth", - }); - } - previousOffset.current = currentOffset; - }, [currentOffset, isMobile]); - - React.useEffect(() => { - const switchedToMobile = isMobile && !prevIsMobile.current; - - prevIsMobile.current = isMobile; - - if (!switchedToMobile || searchLoading || (currentOffset ?? 0) === 0) { - return; - } - - // Always reload from offset 0 if switching to mobile - setMobileResults([]); - searchActions.setOffset(0); - executeSearch(searchActions); - setSearchState("loading"); - }, [currentOffset, isMobile, searchActions, searchLoading]); - - React.useEffect(() => { - if (!isMobile || searchLoading) { - return; - } - - setMobileResults((previousResults) => { - // Mobile keeps a single growing list: later offsets append, while - // offset 0 replaces the list after a fresh search. - return (currentOffset ?? 0) > 0 && searchResults.length > 0 - ? [...previousResults, ...searchResults] - : searchResults; - }); - }, [currentOffset, isMobile, searchLoading, searchResults]); - - const handleDistanceClick = ( - distance: number, - distanceUnit: "mile" | "kilometer" - ) => { - const existingFilters = searchFilters.static || []; - let updatedFilters: SelectableStaticFilter[]; - const distanceInMeters = toMeters(distance, distanceUnit); - if (selectedDistanceOption === distance) { - setSelectedDistanceMeters(null); - setSelectedDistanceOption(null); - // revert to API radius (or default if none was found) if user clicks the same distance again - updatedFilters = updateRadiusInNearFiltersOnLocationField( - existingFilters, - apiFilterRadius.current ?? toMeters(DEFAULT_RADIUS, preferredUnit) - ); - } else { - setSelectedDistanceOption(distance); - updatedFilters = updateRadiusInNearFiltersOnLocationField( - existingFilters, - distanceInMeters - ); - } - searchActions.setStaticFilters(updatedFilters); - searchActions.setOffset(0); - executeSearch(searchActions); - }; - - const handleClearFiltersClick = () => { - const existingFilters = searchFilters.static || []; - // revert to API radius (or default if none was found) - const partiallyUpdatedFilters = updateRadiusInNearFiltersOnLocationField( - existingFilters, - apiFilterRadius.current ?? toMeters(DEFAULT_RADIUS, preferredUnit) - ); - const updatedFilters = deselectOpenNowFilters(partiallyUpdatedFilters); - - // Both open now and distance filters must be updated in the same setStaticFilters call to - // avoid problems due to the asynchronous nature of state updates. - searchActions.setStaticFilters(updatedFilters); - searchActions.resetFacets(); - // Execute search to update AppliedFilters components - searchActions.setOffset(0); - executeSearch(searchActions); - setSelectedDistanceMeters(null); - setSelectedDistanceOption(null); - }; - - // If something else causes the filters to update, check if the hours filter is still present - // and toggle off the Open Now toggle if not. - React.useEffect(() => { - setIsOpenNowSelected( - searchFilters.static - ? !!searchFilters.static.find((staticFilter) => { - return ( - staticFilter.filter.kind === "fieldValue" && - staticFilter.filter.fieldId === HOURS_FIELD && - staticFilter.selected === true - ); - }) - : false - ); - }, [searchFilters]); - - const hasFacetOptions = - ( - useSearchState((state) => - state.filters.facets?.filter((f) => f.options.length) - ) ?? [] - ).length > 0; - const hasFilterModalToggle = - openNowButton || showDistanceOptions || hasFacetOptions; - const filterAccentColorCssVariable = - getThemeColorCssValue(accentColor?.selectedColor) ?? - "var(--colors-palette-primary-dark)"; - const [showFilterModal, setShowFilterModal] = React.useState(false); - const filterToggleButtonRef = React.useRef(null); - const filterModalCloseButtonRef = React.useRef(null); - const hasOpenedFilterModalRef = React.useRef(false); - const resolvedHeading = - (pageHeading?.title && - resolveComponentData(pageHeading.title, i18n.language, streamDocument)) || - t("findALocation", "Find a Location"); - - const requireMapOptIn: boolean = streamDocument.__?.visualEditorConfig - ? JSON.parse(streamDocument.__?.visualEditorConfig)?.requireMapOptIn - : false; - // If no opt-in is required, the map is already enabled. - const [mapEnabled, setMapEnabled] = React.useState(!requireMapOptIn); - // Adds unified [enable|disable]Map functions to the window. - useEffect(() => { - (window as any).enableMap = () => setMapEnabled(true); - (window as any).disableMap = () => setMapEnabled(false); - - return () => { - delete (window as any).enableMap; - delete (window as any).disableMap; - }; - }, []); - - useEffect(() => { - if (showFilterModal) { - hasOpenedFilterModalRef.current = true; - filterModalCloseButtonRef.current?.focus(); - return; - } - - if (hasOpenedFilterModalRef.current) { - filterToggleButtonRef.current?.focus(); - } - }, [showFilterModal]); - - return ( -
- {/* Left Section: FilterSearch + Results. Full width for small screens */} -
-
- - {resolvedHeading} - - handleFilterSelect(params)} - placeholder={t("searchHere", "Search here...")} - ariaLabel={t("findALocation", "Find a Location")} - customCssClasses={{ - filterSearchContainer: "font-body-fontFamily", - focusedOption: "bg-gray-200 hover:bg-gray-200 block", - option: "hover:bg-gray-100 px-4 py-3", - inputElement: - "rounded-md p-4 h-11 font-body-fontFamily font-body-fontWeight text-body-fontSize placeholder:text-gray-700", - currentLocationButton: - "h-7 w-7 font-body-fontFamily font-body-fontWeight text-body-fontSize text-palette-primary-dark", - label: - "font-body-fontFamily font-body-fontWeight text-body-fontSize text-palette-primary-dark", - }} - showCurrentLocationButton={userLocationRetrieved} - geolocationProps={{ - radius: - preferredUnit === "mile" - ? DEFAULT_RADIUS - : toMiles(DEFAULT_RADIUS), // this component uses miles, not meters - }} - /> -
-
-
-
- - {hasFilterModalToggle && ( - - )} -
-
- -
-
- {resultCount > 0 && ( -
- {isMobile ? ( - { - if (searchLoading || mobileResults.length >= resultCount) { - return; - } - - searchActions.setOffset(mobileResults.length); - executeSearch(searchActions); - setSearchState("loading"); - }} - /> - ) : ( - - )} -
- )} - {!isMobile && resultCount > RESULTS_LIMIT && ( -
- -
- )} - setShowFilterModal(false)} - handleClearFiltersClick={handleClearFiltersClick} - accentColorCssValue={filterAccentColorCssVariable} - closeButtonRef={filterModalCloseButtonRef} - /> -
-
- - {/* Right Section: Map. Hidden for small screens */} -
- {mapEnabled && isInitialMapLocationResolved && } - {mapEnabled && !isInitialMapLocationResolved && ( - - )} - {!mapEnabled && ( -
-
- - {t( - "mapRequiresOptIn", - "This map can only be displayed if cookies are enabled" - )} - -
- -
-
-
- )} - {showSearchAreaButton && ( -
- -
- )} -
-
- ); -}; - -interface MobileLocatorResultsSectionProps { - CardComponent: React.ComponentType>; - results: Result[]; - hasMoreResults: boolean; - handleShowMoreResults: () => void; -} - -const MobileLocatorResultsSection = ({ - CardComponent, - results, - hasMoreResults, - handleShowMoreResults, -}: MobileLocatorResultsSectionProps) => { - const { t } = useTranslation(); - - return ( - <> - {results.length > 0 && ( -
- {results.map((result, position) => ( -
- -
- ))} -
- )} - {hasMoreResults && ( - // Mobile replaces numbered pagination with incremental loading. -
- -
- )} - - ); -}; - -interface ResultsCountSummaryProps { - searchState: SearchState; - resultCount: number; - selectedDistanceOption: number | null; - filterDisplayName?: string; -} - -const ResultsCountSummary = (props: ResultsCountSummaryProps) => { - const { - searchState, - resultCount, - selectedDistanceOption, - filterDisplayName, - } = props; - const { t, i18n } = useTranslation(); - - if (resultCount === 0) { - if (searchState === "not started") { - return ( - - {t( - "useOurLocatorToFindALocationNearYou", - "Use our locator to find a location near you" - )} - - ); - } else if (searchState === "complete") { - return ( - - {t("noResultsFoundForThisArea", "No results found for this area")} - - ); - } else { - return
; - } - } else { - if (filterDisplayName) { - if (selectedDistanceOption) { - const unit = getPreferredDistanceUnit(i18n.language); - return ( - - {t("locationsWithinDistanceOf", { - count: resultCount, - distance: selectedDistanceOption, - unit: translateDistanceUnit(t, unit, selectedDistanceOption), - name: filterDisplayName, - })} - - ); - } else { - return ( - - {t("locationsNear", { - count: resultCount, - name: filterDisplayName, - })} - - ); - } - } else { - return ( - - {t("locationWithCount", { - count: resultCount, - })} - - ); - } - } -}; - -interface MapProps { - mapStyle?: string; - centerCoords?: Coordinate; - onDragHandler?: OnDragHandler; - scrollToResult?: (result: Result | undefined) => void; - markerOptionsOverride?: (selected: boolean) => MapMarkerOptions; - locationStyleConfig?: LocationStyleConfig; -} - -const Map: React.FC = ({ - mapStyle, - centerCoords, - onDragHandler, - scrollToResult, - markerOptionsOverride, - locationStyleConfig, -}) => { - const entityDocument: StreamDocument = useDocument(); - - const documentIsUndefined = typeof document === "undefined"; - const iframe = documentIsUndefined - ? undefined - : (document.getElementById("preview-frame") as HTMLIFrameElement); - - const locatorMapDiv = documentIsUndefined - ? null - : ((iframe?.contentDocument || document)?.getElementById( - "locatorMapDiv" - ) as HTMLDivElement | null); - - const mapPadding = React.useMemo( - () => getMapboxMapPadding(locatorMapDiv), - [locatorMapDiv] - ); - const mapboxOptions = React.useMemo( - () => ({ - center: centerCoords, - fitBoundsOptions: { padding: mapPadding }, - ...(mapStyle ? { style: mapStyle } : {}), - }), - [centerCoords, mapPadding, mapStyle] - ); - const PinComponent = React.useMemo( - () => - function PinComponent(pinProps: PinComponentProps) { - return ( - - ); - }, - [locationStyleConfig] - ); - - if (isVisualEditorTestEnv()) { - return ; - } - - // During page generation we don't exist in a browser context - //@ts-expect-error MapboxGL is not loaded in the iframe content window - if (iframe?.contentDocument && !iframe.contentWindow?.mapboxgl) { - // We are in an iframe, and mapboxgl is not loaded in yet - return ; - } - - let mapboxApiKey = entityDocument._env?.YEXT_MAPBOX_API_KEY; - if ( - iframe?.contentDocument && - entityDocument._env?.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY - ) { - // If we are in the layout editor, use the non-URL-restricted Mapbox API key - mapboxApiKey = entityDocument._env.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY; - } - - return ( - - ); -}; - -type LocatorMapPinProps = PinComponentProps & { - locationStyleConfig?: LocationStyleConfig; -}; - -const LocatorMapPin = (props: LocatorMapPinProps) => { - const { result, selected, locationStyleConfig } = props; - const entityType = result.entityType; - const entityLocationStyle = entityType - ? locationStyleConfig?.[entityType] - : undefined; - - return ( - - ); -}; - -interface FilterModalProps { - showFilterModal: boolean; - showOpenNowOption: boolean; // whether to show the Open Now filter option - isOpenNowSelected: boolean; // whether the Open Now filter is currently selected by the user - showDistanceOptions: boolean; // whether to show the Distance filter option - selectedDistanceOption: number | null; - handleCloseModalClick: () => void; - handleOpenNowClick: (selected: boolean) => void; - handleDistanceClick: ( - distance: number, - distanceUnit: "mile" | "kilometer" - ) => void; - handleClearFiltersClick: () => void; - accentColorCssValue: string; - closeButtonRef: React.Ref; -} - -const FilterModal = (props: FilterModalProps) => { - const { - showFilterModal, - showOpenNowOption, - isOpenNowSelected, - showDistanceOptions, - selectedDistanceOption, - handleCloseModalClick, - handleOpenNowClick, - handleDistanceClick, - handleClearFiltersClick, - accentColorCssValue, - closeButtonRef, - } = props; - const { t } = useTranslation(); - const popupRef = React.useRef(null); - - return showFilterModal ? ( - - ) : null; -}; - -interface OpenNowFilterProps { - isSelected: boolean; - onChange: (selected: boolean) => void; -} - -const OpenNowFilter = (props: OpenNowFilterProps) => { - const { isSelected, onChange } = props; - const { t } = useTranslation(); - const { isExpanded, getToggleProps, getCollapseProps } = useCollapse({ - defaultExpanded: true, - }); - const iconClassName = isExpanded - ? "w-3 text-gray-400" - : "w-3 text-gray-400 transform rotate-180"; - - const openNowCheckBoxId = "openNowCheckBox"; - return ( -
- -
-
- onChange(!isSelected)} - /> - -
-
-
- ); -}; - -interface DistanceFilterProps { - onChange: (distance: number, unit: "mile" | "kilometer") => void; - selectedDistanceOption: number | null; - accentColorCssValue: string; -} - -const DistanceFilter = (props: DistanceFilterProps) => { - const { selectedDistanceOption, onChange, accentColorCssValue } = props; - const { t, i18n } = useTranslation(); - const { isExpanded, getToggleProps, getCollapseProps } = useCollapse({ - defaultExpanded: true, - }); - const iconClassName = isExpanded - ? "w-3 text-gray-400" - : "w-3 text-gray-400 transform rotate-180"; - const distanceOptions = [5, 10, 25, 50]; - const unit = getPreferredDistanceUnit(i18n.language); - - return ( -
- -
- {distanceOptions.map((distanceOption) => ( -
- - - {`< ${distanceOption} ${translateDistanceUnit(t, unit, distanceOption)}`} - -
- ))} -
-
- ); -}; - -const getMapboxMapPadding = (divElement: HTMLDivElement | null) => { - if (!divElement) { - return 50; - } - - const { width, height } = divElement.getBoundingClientRect(); - const mapVerticalPadding = Math.max(50, height * 0.2); - const mapHorizontalPadding = Math.max(50, width * 0.2); - return { - top: mapVerticalPadding, - bottom: mapVerticalPadding, - left: mapHorizontalPadding, - right: mapHorizontalPadding, - }; -}; - -const parseMapStartingLocation = (mapStartingLocation: { - latitude: string; - longitude: string; -}): Coordinate => { - const lat = parseFloat(mapStartingLocation.latitude); - const lng = parseFloat(mapStartingLocation.longitude); - - let err = []; - if (isNaN(lat) || lat < -90 || lat > 90) { - err.push("Latitude must be a number between -90 and 90."); - } - if (isNaN(lng) || lng < -180 || lng > 180) { - err.push("Longitude must be a number between -180 and 180."); - } - if (err.length) { - throw new Error(err.join("\n")); - } - - return { - latitude: lat, - longitude: lng, - }; -}; - -/** - * Returns true if the given filter is a "near" filter on the builtin.location field; otherwise, - * returns false. - */ -const isLocationNearFilter = (filter: SelectableStaticFilter) => - filter.filter.kind === "fieldValue" && - filter.filter.fieldId === LOCATION_FIELD && - filter.filter.matcher === Matcher.Near; - -/** - * Returns true if the given filter is an "open at" filter on the builtin.hours field; otherwise, - * returns false. - */ -const isOpenNowFilter = (filter: SelectableStaticFilter) => - filter.filter.kind === "fieldValue" && - filter.filter.fieldId === HOURS_FIELD && - filter.filter.matcher === Matcher.OpenAt; - -/** - * Builds a "near" static filter on the builtin.location field from a previous near filter - * value, with optional overrides for display name and radius - */ -function buildNearLocationFilterFromPrevious( - previousValue: NearFilterValue, - displayName?: string, - radius?: number -): SelectableStaticFilter { - return { - selected: true, - displayName, - filter: { - kind: "fieldValue", - fieldId: LOCATION_FIELD, - value: { - ...previousValue, - radius: radius ?? previousValue.radius, - }, - matcher: Matcher.Near, - }, - }; -} - -/** - * Builds a "near" static filter on the builtin.location field from given coordinates, with - * optional radius and display name. - */ -function buildNearLocationFilterFromCoords( - lat: number, - lng: number, - radius: number, - displayName?: string -): SelectableStaticFilter { - return { - selected: true, - displayName, - filter: { - kind: "fieldValue", - fieldId: LOCATION_FIELD, - value: { - lat, - lng, - radius, - }, - matcher: Matcher.Near, - }, - }; -} - -/** - * Builds an "equals" static filter on the builtin.location field from a previous equals filter, - * with a new display name. - */ -function buildEqualsLocationFilter( - filter: FieldValueFilter, - newDisplayName: string -): SelectableStaticFilter { - return { - displayName: newDisplayName, - selected: true, - filter: { - kind: "fieldValue", - fieldId: filter.fieldId, - value: filter.value, - matcher: Matcher.Equals, - }, - }; -} - -/** - * Helper function to iterate through a list of static filters and update all near filters on the - * location field to have the new radius. - */ -function updateRadiusInNearFiltersOnLocationField( - filters: SelectableStaticFilter[], - newRadius: number -): SelectableStaticFilter[] { - return filters.map((filter) => { - if (isLocationNearFilter(filter)) { - const previousFilter = filter.filter as FieldValueStaticFilter; - const previousValue = previousFilter.value as NearFilterValue; - return { - ...filter, - filter: { - ...previousFilter, - value: { - ...previousValue, - radius: newRadius, - }, - }, - } as SelectableStaticFilter; - } - return filter; - }); -} - -/** - * Helper function to iterate through a list of static filters and set the selected field to - * false on any Open Now filters. - */ -function deselectOpenNowFilters( - filters: SelectableStaticFilter[] -): SelectableStaticFilter[] { - return filters.map((filter) => { - if (isOpenNowFilter(filter)) { - return { - ...filter, - selected: false, - }; - } - return filter; - }); -} - -/** Checks whether a given lat and lng are valid coordinates */ -function areValidCoordinates(lat: number, lng: number): boolean { - return ( - !isNaN(lat) && - !isNaN(lng) && - lat >= -90 && - lat <= 90 && - lng >= -180 && - lng <= 180 - ); -} diff --git a/packages/visual-editor/src/components/categories/LocatorCategory.tsx b/packages/visual-editor/src/components/categories/LocatorCategory.tsx index 2c066111d..54d549706 100644 --- a/packages/visual-editor/src/components/categories/LocatorCategory.tsx +++ b/packages/visual-editor/src/components/categories/LocatorCategory.tsx @@ -1,4 +1,4 @@ -import { LocatorProps, LocatorComponent } from "../Locator.tsx"; +import { LocatorProps, LocatorComponent } from "../locator/Locator.tsx"; export interface LocatorCategoryProps { Locator: LocatorProps; diff --git a/packages/visual-editor/src/components/index.ts b/packages/visual-editor/src/components/index.ts index 23678dba8..cebfed689 100644 --- a/packages/visual-editor/src/components/index.ts +++ b/packages/visual-editor/src/components/index.ts @@ -11,12 +11,12 @@ export { type DirectoryProps, type DirectoryStyles, } from "./directory/Directory.tsx"; -export { LocatorComponent, type LocatorProps } from "./Locator.tsx"; +export { LocatorComponent, type LocatorProps } from "./locator/Locator.tsx"; export { LocatorResultCard, type Location, type LocatorResultCardProps, -} from "./LocatorResultCard.tsx"; +} from "./locator/LocatorResultCard.tsx"; export { CustomCodeSection, type CustomCodeSectionProps, diff --git a/packages/visual-editor/src/components/locator/Filters.tsx b/packages/visual-editor/src/components/locator/Filters.tsx new file mode 100644 index 000000000..0a6ef9dae --- /dev/null +++ b/packages/visual-editor/src/components/locator/Filters.tsx @@ -0,0 +1,655 @@ +import { + FieldValueFilter, + FieldValueStaticFilter, + Matcher, + NearFilterValue, + SelectableStaticFilter, +} from "@yext/search-headless-react"; +import { AppliedFilters, Facets } from "@yext/search-ui-react"; +import React from "react"; +import { type MultiSelectorOption } from "../../fields/MultiSelectorField.tsx"; +import { useCollapse } from "react-collapsed"; +import { useTranslation } from "react-i18next"; +import { FaChevronUp, FaDotCircle, FaRegCircle, FaTimes } from "react-icons/fa"; +import { getPreferredDistanceUnit } from "../../utils/i18n/distance.ts"; +import { msg } from "../../utils/i18n/platform.ts"; +import { LocatorEntityType } from "../../utils/locatorEntityTypes.ts"; +import { Body } from "../atoms/body.tsx"; +import { translateDistanceUnit } from "./Results.tsx"; + +export const LOCATION_FIELD = "builtin.location"; +export const COUNTRY_CODE_FIELD = "address.countryCode"; +export const HOURS_FIELD = "builtin.hours"; + +interface FilterModalProps { + showFilterModal: boolean; + showOpenNowOption: boolean; // whether to show the Open Now filter option + isOpenNowSelected: boolean; // whether the Open Now filter is currently selected by the user + showDistanceOptions: boolean; // whether to show the Distance filter option + selectedDistanceOption: number | null; + handleCloseModalClick: () => void; + handleOpenNowClick: (selected: boolean) => void; + handleDistanceClick: ( + distance: number, + distanceUnit: "mile" | "kilometer" + ) => void; + handleClearFiltersClick: () => void; + accentColorCssValue: string; + closeButtonRef: React.Ref; +} + +export const FilterModal = ({ + showFilterModal, + showOpenNowOption, + isOpenNowSelected, + showDistanceOptions, + selectedDistanceOption, + handleCloseModalClick, + handleOpenNowClick, + handleDistanceClick, + handleClearFiltersClick, + accentColorCssValue, + closeButtonRef, +}: FilterModalProps) => { + const { t } = useTranslation(); + const popupRef = React.useRef(null); + + return showFilterModal ? ( + + ) : null; +}; + +interface OpenNowFilterProps { + isSelected: boolean; + onChange: (selected: boolean) => void; +} + +const OpenNowFilter = ({ isSelected, onChange }: OpenNowFilterProps) => { + const { t } = useTranslation(); + const { isExpanded, getToggleProps, getCollapseProps } = useCollapse({ + defaultExpanded: true, + }); + const iconClassName = isExpanded + ? "w-3 text-gray-400" + : "w-3 text-gray-400 transform rotate-180"; + + const openNowCheckBoxId = "openNowCheckBox"; + return ( +
+ +
+
+ onChange(!isSelected)} + /> + +
+
+
+ ); +}; + +interface DistanceFilterProps { + onChange: (distance: number, unit: "mile" | "kilometer") => void; + selectedDistanceOption: number | null; + accentColorCssValue: string; +} + +const DistanceFilter = ({ + onChange, + selectedDistanceOption, + accentColorCssValue, +}: DistanceFilterProps) => { + const { t, i18n } = useTranslation(); + const { isExpanded, getToggleProps, getCollapseProps } = useCollapse({ + defaultExpanded: true, + }); + const iconClassName = isExpanded + ? "w-3 text-gray-400" + : "w-3 text-gray-400 transform rotate-180"; + const distanceOptions = [5, 10, 25, 50]; + const unit = getPreferredDistanceUnit(i18n.language); + + return ( +
+ +
+ {distanceOptions.map((distanceOption) => ( +
+ + + {`< ${distanceOption} ${translateDistanceUnit(t, unit, distanceOption)}`} + +
+ ))} +
+
+ ); +}; + +export function getFacetFieldOptions( + entityTypes: LocatorEntityType[] +): MultiSelectorOption[] { + const facetFields: MultiSelectorOption[] = []; + const addedValues: Set = new Set(); + entityTypes.forEach((entityType) => + getFacetFieldOptionsForEntityType(entityType).forEach((option) => { + if (option?.value && !addedValues.has(option.value)) { + facetFields.push(option); + addedValues.add(option.value); + } + }) + ); + return facetFields.sort((a, b) => a.label.localeCompare(b.label)); +} + +function getFacetFieldOptionsForEntityType( + entityType: LocatorEntityType +): MultiSelectorOption[] { + let filterOptions: MultiSelectorOption[] = [ + { + label: msg("fields.options.facets.city", "City"), + value: "address.city", + }, + { + label: msg("fields.options.facets.postalCode", "Postal Code"), + value: "address.postalCode", + }, + { + label: msg("fields.options.facets.region", "Region"), + value: "address.region", + }, + { + label: msg("fields.options.facets.brandName", "Brand Name"), + value: "brandReference.name", + }, + ]; + switch (entityType) { + case "location": + filterOptions = filterOptions.concat( + { + label: msg("fields.options.facets.associations", "Associations"), + value: "associations", + }, + { + label: msg("fields.options.facets.brands", "Brands"), + value: "brands", + }, + { + label: msg("fields.options.facets.keywords", "Keywords"), + value: "keywords", + }, + { + label: msg("fields.options.facets.languages", "Languages"), + value: "languages", + }, + { + label: msg("fields.options.facets.paymentOptions", "Payment Options"), + value: "paymentOptions", + }, + { + label: msg("fields.options.facets.products", "Products"), + value: "products", + }, + { + label: msg("fields.options.facets.services", "Services"), + value: "services", + }, + { + label: msg("fields.options.facets.specialties", "Specialties"), + value: "specialities", + } + ); + break; + case "restaurant": + filterOptions = filterOptions.concat( + { + label: msg( + "fields.options.facets.acceptsReservations", + "Accepts Reservations" + ), + value: "acceptsReservations", + }, + { + label: msg("fields.options.facets.associations", "Associations"), + value: "associations", + }, + { + label: msg("fields.options.facets.brands", "Brands"), + value: "brands", + }, + { + label: msg("fields.options.facets.keywords", "Keywords"), + value: "keywords", + }, + { + label: msg("fields.options.facets.languages", "Languages"), + value: "languages", + }, + { + label: msg("fields.options.facets.mealsServed", "Meals Served"), + value: "mealsServed", + }, + { + label: msg("fields.options.facets.neighborhood", "Neighborhood"), + value: "neighborhood", + }, + { + label: msg("fields.options.facets.paymentOptions", "Payment Options"), + value: "paymentOptions", + }, + { + label: msg( + "fields.options.facets.pickupAndDeliveryServices", + "Pickup and Delivery Services" + ), + value: "pickupAndDeliveryServices", + }, + { + label: msg("fields.options.facets.priceRange", "Price Range"), + value: "priceRange", + }, + { + label: msg("fields.options.facets.services", "Services"), + value: "services", + }, + { + label: msg("fields.options.facets.specialties", "Specialties"), + value: "specialities", + } + ); + break; + case "healthcareFacility": + filterOptions = filterOptions.concat( + { + label: msg( + "fields.options.facets.acceptingNewPatients", + "Accepting New Patients" + ), + value: "acceptingNewPatients", + }, + { + label: msg( + "fields.options.facets.conditionsTreated", + "Conditions Treated" + ), + value: "conditionsTreated", + }, + { + label: msg( + "fields.options.facets.insuranceAccepted", + "Insurance Accepted" + ), + value: "insuranceAccepted", + }, + { + label: msg("fields.options.facets.paymentOptions", "Payment Options"), + value: "paymentOptions", + }, + { + label: msg("fields.options.facets.services", "Services"), + value: "services", + } + ); + break; + case "healthcareProfessional": + filterOptions = filterOptions.concat( + { + label: msg( + "fields.options.facets.acceptingNewPatients", + "Accepting New Patients" + ), + value: "acceptingNewPatients", + }, + { + label: msg( + "fields.options.facets.admittingHospitals", + "Admitting Hospitals" + ), + value: "admittingHospitals", + }, + { + label: msg("fields.options.facets.brands", "Brands"), + value: "brands", + }, + { + label: msg("fields.options.facets.certifications", "Certifications"), + value: "certifications", + }, + { + label: msg( + "fields.options.facets.conditionsTreated", + "Conditions Treated" + ), + value: "conditionsTreated", + }, + { + label: msg("fields.options.facets.degrees", "Degrees"), + value: "degrees", + }, + { + label: msg("fields.options.facets.gender", "Gender"), + value: "gender", + }, + { + label: msg( + "fields.options.facets.insuranceAccepted", + "Insurance Accepted" + ), + value: "insuranceAccepted", + }, + { + label: msg("fields.options.facets.languages", "Languages"), + value: "languages", + }, + { + label: msg("fields.options.facets.neighborhood", "Neighborhood"), + value: "neighborhood", + }, + { + label: msg("fields.options.facets.officeName", "Office Name"), + value: "officeName", + }, + { + label: msg("fields.options.facets.services", "Services"), + value: "services", + } + ); + break; + case "hotel": + filterOptions = filterOptions.concat( + { label: msg("fields.options.facets.bar", "Bar"), value: "bar" }, + { + label: msg("fields.options.facets.catsAllowed", "Cats Allowed"), + value: "catsAllowed", + }, + { + label: msg("fields.options.facets.dogsAllowed", "Dogs Allowed"), + value: "dogsAllowed", + }, + { + label: msg("fields.options.facets.parking", "Parking"), + value: "parking", + }, + { label: msg("fields.options.facets.pools", "Pools"), value: "pools" } + ); + break; + case "financialProfessional": + filterOptions = filterOptions.concat( + { + label: msg("fields.options.facets.certifications", "Certifications"), + value: "certifications", + }, + { + label: msg("fields.options.facets.interests", "Interests"), + value: "interests", + }, + { + label: msg("fields.options.facets.languages", "Languages"), + value: "languages", + }, + { + label: msg("fields.options.facets.services", "Services"), + value: "services", + }, + { + label: msg("fields.options.facets.specialties", "Specialties"), + value: "specialties", + }, + { + label: msg( + "fields.options.facets.yearsOfExperience", + "Years of Experience" + ), + value: "yearsOfExperience", + } + ); + break; + default: + break; + } + return filterOptions; +} + +/** + * Returns true if the given filter is a "near" filter on the builtin.location field; otherwise, + * returns false. + */ +const isLocationNearFilter = (filter: SelectableStaticFilter) => + filter.filter.kind === "fieldValue" && + filter.filter.fieldId === LOCATION_FIELD && + filter.filter.matcher === Matcher.Near; + +/** + * Returns true if the given filter is an "open at" filter on the builtin.hours field; otherwise, + * returns false. + */ +const isOpenNowFilter = (filter: SelectableStaticFilter) => + filter.filter.kind === "fieldValue" && + filter.filter.fieldId === HOURS_FIELD && + filter.filter.matcher === Matcher.OpenAt; + +/** + * Builds a "near" static filter on the builtin.location field from a previous near filter + * value, with optional overrides for display name and radius + */ +export function buildNearLocationFilterFromPrevious( + previousValue: NearFilterValue, + displayName?: string, + radius?: number +): SelectableStaticFilter { + return { + selected: true, + displayName, + filter: { + kind: "fieldValue", + fieldId: LOCATION_FIELD, + value: { + ...previousValue, + radius: radius ?? previousValue.radius, + }, + matcher: Matcher.Near, + }, + }; +} + +/** + * Builds a "near" static filter on the builtin.location field from given coordinates, with + * optional radius and display name. + */ +export function buildNearLocationFilterFromCoords( + lat: number, + lng: number, + radius: number, + displayName?: string +): SelectableStaticFilter { + return { + selected: true, + displayName, + filter: { + kind: "fieldValue", + fieldId: LOCATION_FIELD, + value: { + lat, + lng, + radius, + }, + matcher: Matcher.Near, + }, + }; +} + +/** + * Builds an "equals" static filter on the builtin.location field from a previous equals filter, + * with a new display name. + */ +export function buildEqualsLocationFilter( + filter: FieldValueFilter, + newDisplayName: string +): SelectableStaticFilter { + return { + displayName: newDisplayName, + selected: true, + filter: { + kind: "fieldValue", + fieldId: filter.fieldId, + value: filter.value, + matcher: Matcher.Equals, + }, + }; +} + +/** + * Helper function to iterate through a list of static filters and update all near filters on the + * location field to have the new radius. + */ +export function updateRadiusInNearFiltersOnLocationField( + filters: SelectableStaticFilter[], + newRadius: number +): SelectableStaticFilter[] { + return filters.map((filter) => { + if (isLocationNearFilter(filter)) { + const previousFilter = filter.filter as FieldValueStaticFilter; + const previousValue = previousFilter.value as NearFilterValue; + return { + ...filter, + filter: { + ...previousFilter, + value: { + ...previousValue, + radius: newRadius, + }, + }, + } as SelectableStaticFilter; + } + + return filter; + }); +} + +/** + * Helper function to iterate through a list of static filters and set the selected field to + * false on any Open Now filters. + */ +export function deselectOpenNowFilters( + filters: SelectableStaticFilter[] +): SelectableStaticFilter[] { + return filters.map((filter) => { + if (isOpenNowFilter(filter)) { + return { + ...filter, + selected: false, + }; + } + + return filter; + }); +} diff --git a/packages/visual-editor/src/components/Locator.test.tsx b/packages/visual-editor/src/components/locator/Locator.test.tsx similarity index 98% rename from packages/visual-editor/src/components/Locator.test.tsx rename to packages/visual-editor/src/components/locator/Locator.test.tsx index dfd2dd41e..ba54598fb 100644 --- a/packages/visual-editor/src/components/Locator.test.tsx +++ b/packages/visual-editor/src/components/locator/Locator.test.tsx @@ -5,22 +5,22 @@ import { ComponentTest, logSuppressedWcagViolations, transformTests, -} from "./testing/componentTests.setup.ts"; +} from "../testing/componentTests.setup.ts"; import { act, render as reactRender, screen, waitFor, } from "@testing-library/react"; -import { injectTranslations } from "../utils/i18n/components.ts"; -import { migrate } from "../utils/migrate.ts"; -import { migrationRegistry } from "./migrations/migrationRegistry.ts"; -import { VisualEditorProvider } from "../utils/VisualEditorProvider.tsx"; +import { injectTranslations } from "../../utils/i18n/components.ts"; +import { migrate } from "../../utils/migrate.ts"; +import { migrationRegistry } from "../migrations/migrationRegistry.ts"; +import { VisualEditorProvider } from "../../utils/VisualEditorProvider.tsx"; import { LocatorComponent } from "./Locator.tsx"; import { Render, Config, resolveAllData } from "@puckeditor/core"; import { page } from "@vitest/browser/context"; -import { backgroundColors } from "../utils/themeConfigOptions.ts"; -import { MainContent } from "./structure/MainContent.tsx"; +import { backgroundColors } from "../../utils/themeConfigOptions.ts"; +import { MainContent } from "../structure/MainContent.tsx"; vi.mock("@yext/search-ui-react", async () => { const actual = await vi.importActual( diff --git a/packages/visual-editor/src/components/locator/Locator.tsx b/packages/visual-editor/src/components/locator/Locator.tsx new file mode 100644 index 000000000..3786140bb --- /dev/null +++ b/packages/visual-editor/src/components/locator/Locator.tsx @@ -0,0 +1,522 @@ +import { FieldLabel, setDeep } from "@puckeditor/core"; +import { type MultiSelectorValue } from "../../fields/MultiSelectorField.tsx"; +import { YextAutoField } from "../../fields/YextAutoField.tsx"; +import { TranslatableString } from "../../types/types.ts"; +import { msg, pt } from "../../utils/i18n/platform.ts"; +import { TranslatableAssetImage } from "../../types/images.ts"; +import { + backgroundColors, + ThemeColor, +} from "../../utils/themeConfigOptions.ts"; +import { + DEFAULT_ENTITY_TYPE, + getEntityTypeLabel, + getLocatorEntityTypeSourceMap, + isLocatorEntityType, + LocatorEntityType, +} from "../../utils/locatorEntityTypes.ts"; +import { + toPuckFields, + type YextCustomFieldRenderProps, + YextComponentConfig, + YextFields, +} from "../../fields/fields.ts"; +import { ImageStylingFields } from "../contentBlocks/image/styling.ts"; +import { getFacetFieldOptions } from "./Filters.tsx"; +import { + DEFAULT_LOCATOR_RESULT_CARD_PROPS, + DistanceDisplayOption, + LocatorResultCardProps, +} from "./LocatorResultCard.tsx"; +import { ResultCardPropsField } from "./Results.tsx"; +import { LocatorWrapper } from "./LocatorWrapper.tsx"; +import { + DEFAULT_LOCATION_STYLE, + DEFAULT_MAKI_ICON_NAME, + DEFAULT_PIN_ICON_WIDTH, + LOCATOR_PIN_ICON_FIELD, + MAX_PIN_ICON_WIDTH, + makiIconOptions, +} from "./Map.tsx"; +const DEFAULT_TITLE = "Find a Location"; +const DEFAULT_DISTANCE_DISPLAY = "distanceFromUser"; + +export interface LocatorProps { + /** + * The visual theme for the map tiles, chosen from a predefined list of Mapbox styles. + * @defaultValue 'mapbox://styles/mapbox/streets-v12' + */ + mapStyle?: string; + + /** + * Props to customize the locator map pin styles. + * Controls map pin appearance depending on the result's entity type. + * The number of entries is locked to the locator entity types for the page set. + */ + locationStyles: Array<{ + /** The entity type this style applies to. */ + entityType: LocatorEntityType; + /** Whether to render an icon in the pin. */ + pinIcon?: { + type: "none" | "icon" | "customImage"; + /** Defaults to the first available Maki icon when type is 'icon'. */ + iconName?: string; + /** Image rendered within the pin when type is 'customImage'. */ + image?: TranslatableAssetImage; + /** + * Width of the custom image rendered within the pin. + * @defaultValue 14 + * */ + width?: number; + /** Aspect ratio of the custom image rendered within the pin. */ + aspectRatio?: number; + }; + /** The color applied to the pin. */ + pinColor?: ThemeColor; + }>; + + /** + * Configuration for the filters available in the locator search experience. + */ + filters: { + /** + * If 'true', displays a button to filter for locations that are currently open. + * @defaultValue false + */ + openNowButton: boolean; + /** + * If 'true', displays several distance options to filter searches to only locations within + * a certain radius. + * @defaultValue false + */ + showDistanceOptions: boolean; + /** Accent color for filter button and icons. */ + accentColor?: ThemeColor; + /** Which fields are facetable in the search experience */ + facetFields?: MultiSelectorValue; + }; + + /** + * The starting location for the map. + */ + mapStartingLocation?: { + latitude: string; + longitude: string; + }; + /** + * Configuration for the locator page heading. + * Allows customizing the title text and its color. + */ + pageHeading?: { + /** The title displayed at the top of the locator page. */ + title: TranslatableString; + /** + * The color applied to the locator page title. + * @defaultValue inherited from theme + */ + color?: ThemeColor; + }; + /** + * Props to customize the locator result card component. + * Controls which fields are displayed and their styling depending on the result's entity type. + * The number of entries is locked to the locator entity types for the page set. + */ + resultCard: Array<{ + /** Props to customize the locator result card component. */ + props: LocatorResultCardProps; + }>; + /** Controls which distance value to display on each locator result card. */ + distanceDisplay?: DistanceDisplayOption; +} + +const locatorFields: YextFields = { + mapStyle: { + type: "basicSelector", + label: msg("fields.mapStyle", "Map Style"), + options: [ + { + label: msg("fields.options.default", "Default"), + value: "mapbox://styles/mapbox/streets-v12", + }, + { + label: msg("fields.options.satellite", "Satellite"), + value: "mapbox://styles/mapbox/satellite-streets-v12", + }, + { + label: msg("fields.options.light", "Light"), + value: "mapbox://styles/mapbox/light-v11", + }, + { + label: msg("fields.options.dark", "Dark"), + value: "mapbox://styles/mapbox/dark-v11", + }, + { + label: msg("fields.options.navigationDay", "Navigation (Day)"), + value: "mapbox://styles/mapbox/navigation-day-v1", + }, + { + label: msg("fields.options.navigationNight", "Navigation (Night)"), + value: "mapbox://styles/mapbox/navigation-night-v1", + }, + ], + }, + locationStyles: { + type: "array", + label: msg("fields.pinStyles", "Location styles"), + getItemSummary: (item: LocatorProps["locationStyles"][number]) => + getEntityTypeLabel(item.entityType), + arrayFields: { + entityType: { + label: msg("fields.entityType", "Entity Type"), + type: "text", + visible: false, + }, + pinIcon: { + type: "custom", + render: ({ + value, + onChange, + }: YextCustomFieldRenderProps< + LocatorProps["locationStyles"][number]["pinIcon"] + >) => { + const selectedType = value?.type ?? "none"; + return ( +
+ + onChange({ + ...value, + type, + iconName: + type === "icon" + ? (value?.iconName ?? DEFAULT_MAKI_ICON_NAME) + : undefined, + }) + } + /> + {selectedType === "icon" && ( + + onChange({ ...value, type: "icon", iconName }) + } + /> + )} + {selectedType === "customImage" && ( + <> + + onChange({ ...value, type: "customImage", image }) + } + /> + + + onChange({ + ...value, + type: "customImage", + width, + }) + } + /> + + + onChange({ + ...value, + type: "customImage", + aspectRatio, + }) + } + /> + + )} +
+ ); + }, + }, + pinColor: { + type: "basicSelector", + label: msg("fields.pinColor", "Pin Color"), + options: "BACKGROUND_COLOR", + }, + }, + defaultItemProps: { + entityType: DEFAULT_ENTITY_TYPE, + pinIcon: { type: "none" }, + pinColor: backgroundColors.background6.value, + }, + }, + filters: { + label: msg("fields.filters", "Filters"), + type: "object", + objectFields: { + openNowButton: { + label: msg("fields.options.includeOpenNow", "Include Open Now Button"), + type: "radio", + options: [ + { label: msg("fields.options.yes", "Yes"), value: true }, + { label: msg("fields.options.no", "No"), value: false }, + ], + }, + showDistanceOptions: { + label: msg( + "fields.options.showDistanceOptions", + "Include Distance Options" + ), + type: "radio", + options: [ + { label: msg("fields.options.yes", "Yes"), value: true }, + { label: msg("fields.options.no", "No"), value: false }, + ], + }, + accentColor: { + type: "basicSelector", + label: msg("fields.accentColor", "Accent Color"), + options: "SITE_COLOR", + }, + facetFields: { + type: "multiSelector", + label: msg("fields.dynamicFilters", "Dynamic Filters"), + dropdownLabel: msg("fields.field", "Field"), + options: () => { + const entityTypeSourceMap = getLocatorEntityTypeSourceMap(); + const entityTypes = + Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); + return getFacetFieldOptions(entityTypes); + }, + placeholderOptionLabel: msg( + "fields.options.selectAField", + "Select a field" + ), + } as any, // TODO(SUMO-8378): remove 'as any' when puck fixes objectFields typing + }, + }, + mapStartingLocation: { + type: "object", + label: msg("fields.options.mapStartingLocation", "Map Starting Location"), + objectFields: { + latitude: { + label: msg("fields.latitude", "Latitude"), + type: "text", + }, + longitude: { + label: msg("fields.longitude", "Longitude"), + type: "text", + }, + }, + }, + pageHeading: { + type: "object", + label: msg("fields.pageHeading", "Page Heading"), + objectFields: { + title: { + type: "translatableString", + label: msg("fields.title", "Title"), + filter: { types: ["type.string"] }, + }, + color: { + type: "basicSelector", + label: msg("fields.color", "Color"), + options: "SITE_COLOR", + }, + }, + }, + resultCard: { + type: "array", + label: msg("fields.resultCard", "Result Card"), + getItemSummary: (item: LocatorProps["resultCard"][number]) => + getEntityTypeLabel(item.props.entityType), + arrayFields: { + props: { + type: "custom", + render: ({ + value, + onChange, + }: YextCustomFieldRenderProps< + LocatorProps["resultCard"][number]["props"] + >) => , + }, + }, + defaultItemProps: { + props: DEFAULT_LOCATOR_RESULT_CARD_PROPS, + }, + }, + distanceDisplay: { + type: "basicSelector", + label: msg("fields.distanceDisplay", "Distance Display"), + options: [ + { + label: msg("fields.options.distanceFromUser", "Distance from User"), + value: "distanceFromUser", + }, + { + label: msg("fields.options.distanceFromSearch", "Distance from Search"), + value: "distanceFromSearch", + }, + { + label: msg("fields.options.hidden", "Hidden"), + value: "hidden", + }, + ], + }, +}; + +/** + * Available on Locator templates. + */ +export const LocatorComponent: YextComponentConfig = { + fields: locatorFields, + /** + * Locks array lengths for `locationStyles` and `resultCard` to the current + * locator entity types so each entity type has exactly one entry. + */ + resolveFields: (_data, params) => { + const entityDocument = params.metadata?.streamDocument; + const entityTypeSourceMap = entityDocument + ? getLocatorEntityTypeSourceMap(entityDocument) + : { [DEFAULT_ENTITY_TYPE]: undefined }; + const entityTypes = Object.keys( + entityTypeSourceMap + ) as (keyof typeof entityTypeSourceMap)[]; + const entityTypeCount = entityTypes.length; + + let updatedFields: YextFields = { ...locatorFields }; + updatedFields = setDeep( + updatedFields, + "locationStyles.min", + entityTypeCount + ); + updatedFields = setDeep( + updatedFields, + "locationStyles.max", + entityTypeCount + ); + updatedFields = setDeep(updatedFields, "resultCard.min", entityTypeCount); + updatedFields = setDeep(updatedFields, "resultCard.max", entityTypeCount); + + return toPuckFields(updatedFields); + }, + defaultProps: { + locationStyles: [], + resultCard: [], + filters: { + openNowButton: false, + showDistanceOptions: false, + }, + pageHeading: { + title: { defaultValue: DEFAULT_TITLE }, + }, + distanceDisplay: DEFAULT_DISTANCE_DISPLAY, + }, + label: msg("components.locator", "Locator"), + /** + * Reconciles `props.locationStyles` and `props.resultCard` so + * each list has exactly one entry per current locator entity type. + * Missing or mismatched entries are rebuilt from existing + * values and backfilled with defaults. + */ + resolveData: (data, params) => { + const entityDocument = params.metadata?.streamDocument; + const entityTypeSourceMap = entityDocument + ? getLocatorEntityTypeSourceMap(entityDocument) + : { [DEFAULT_ENTITY_TYPE]: undefined }; + const entityTypes = Object.keys( + entityTypeSourceMap + ) as (keyof typeof entityTypeSourceMap)[]; + + const previousLocationStyles = data.props.locationStyles ?? []; + const previousResultCard = data.props.resultCard ?? []; + const hasSameEntityTypes = (currentEntityTypes: string[]) => + currentEntityTypes.length === entityTypes.length && + entityTypes.every((entityType) => + currentEntityTypes.includes(entityType) + ); + + const locationStylesByEntityType = new globalThis.Map( + previousLocationStyles + .filter((item) => !!item.entityType) + .map((item) => [item.entityType, item] as const) + ); + const resultCardsByEntityType = new globalThis.Map( + previousResultCard + .filter((item) => !!item?.props?.entityType) + .map((item) => [item.props.entityType, item] as const) + ); + + const previousLocationStyleEntityTypes = previousLocationStyles.map( + (item) => item.entityType + ); + const previousResultCardEntityTypes = previousResultCard.map( + (item) => item.props?.entityType + ); + + const shouldReconcileLocationStyles = + previousLocationStyles.length === 0 || + !hasSameEntityTypes(previousLocationStyleEntityTypes); + const shouldReconcileResultCards = + previousResultCard.length === 0 || + !hasSameEntityTypes(previousResultCardEntityTypes); + + if (shouldReconcileLocationStyles) { + const newLocationStyles = entityTypes.map((entityType) => ({ + ...DEFAULT_LOCATION_STYLE, + ...locationStylesByEntityType.get(entityType), + entityType, + })); + data = setDeep(data, "props.locationStyles", newLocationStyles); + } + + if (shouldReconcileResultCards) { + const newResultCards = entityTypes.map((entityType) => { + const existing = resultCardsByEntityType.get(entityType); + return { + props: { + ...(existing?.props ?? DEFAULT_LOCATOR_RESULT_CARD_PROPS), + entityType, + }, + }; + }); + data = setDeep(data, "props.resultCard", newResultCards); + } + + return data; + }, + render: (props) => , +}; diff --git a/packages/visual-editor/src/components/LocatorResultCard.tsx b/packages/visual-editor/src/components/locator/LocatorResultCard.tsx similarity index 96% rename from packages/visual-editor/src/components/LocatorResultCard.tsx rename to packages/visual-editor/src/components/locator/LocatorResultCard.tsx index 949cc2655..34858c34b 100644 --- a/packages/visual-editor/src/components/LocatorResultCard.tsx +++ b/packages/visual-editor/src/components/locator/LocatorResultCard.tsx @@ -5,36 +5,36 @@ import { Coordinate, useCardAnalyticsCallback, } from "@yext/search-ui-react"; -import { Background } from "./atoms/background.tsx"; +import { Background } from "../atoms/background.tsx"; import { backgroundColors, ThemeColor, HeadingLevel, ThemeOptions, -} from "../utils/themeConfigOptions.ts"; -import { Body, BodyProps } from "./atoms/body.tsx"; -import { CTA, CTAVariant } from "./atoms/cta.tsx"; -import { Heading } from "./atoms/heading.tsx"; -import { Image } from "./atoms/image.tsx"; -import { msg, pt } from "../utils/i18n/platform.ts"; -import { PhoneAtom } from "./atoms/phone.tsx"; -import { useTemplateProps } from "../hooks/useDocument.tsx"; -import { resolveComponentData } from "../utils/resolveComponentData.tsx"; -import { HoursStatusAtom } from "./atoms/hoursStatus.tsx"; -import { HoursTableAtom } from "./atoms/hoursTable.tsx"; -import { type BasicSelectorField } from "../fields/BasicSelectorField.tsx"; +} from "../../utils/themeConfigOptions.ts"; +import { Body, BodyProps } from "../atoms/body.tsx"; +import { CTA, CTAVariant } from "../atoms/cta.tsx"; +import { Heading } from "../atoms/heading.tsx"; +import { Image } from "../atoms/image.tsx"; +import { msg, pt } from "../../utils/i18n/platform.ts"; +import { PhoneAtom } from "../atoms/phone.tsx"; +import { useTemplateProps } from "../../hooks/useDocument.tsx"; +import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { HoursStatusAtom } from "../atoms/hoursStatus.tsx"; +import { HoursTableAtom } from "../atoms/hoursTable.tsx"; +import { type BasicSelectorField } from "../../fields/BasicSelectorField.tsx"; import type { YextCustomFieldRenderProps, YextObjectField, -} from "../fields/fields.ts"; +} from "../../fields/fields.ts"; import { buildLocatorDisplayOptions, type ImageField, -} from "../fields/ImageField.tsx"; -import { ConstantValueModeToggler } from "../fields/EntityFieldSelectorField.tsx"; -import { type EmbeddedStringOption } from "../editor/EmbeddedFieldStringInput.tsx"; -import { TranslatableString } from "../types/types.ts"; -import { TranslatableAssetImage } from "../types/images.ts"; +} from "../../fields/ImageField.tsx"; +import { ConstantValueModeToggler } from "../../fields/EntityFieldSelectorField.tsx"; +import { type EmbeddedStringOption } from "../../editor/EmbeddedFieldStringInput.tsx"; +import { TranslatableString } from "../../types/types.ts"; +import { TranslatableAssetImage } from "../../types/images.ts"; import { Address, AddressType, @@ -45,39 +45,39 @@ import { import { HoursTableProps, HoursTableStyleFields, -} from "./contentBlocks/HoursTable.tsx"; -import { getImageUrl } from "./contentBlocks/image/Image.tsx"; +} from "../contentBlocks/HoursTable.tsx"; +import { getImageUrl } from "../contentBlocks/image/Image.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, -} from "./atoms/accordion.js"; +} from "../atoms/accordion.js"; import { FaAngleRight, FaMapMarkerAlt, FaRegClock, FaRegEnvelope, } from "react-icons/fa"; -import { useTemplateMetadata } from "../internal/hooks/useMessageReceivers.ts"; -import { FieldTypeData } from "../internal/types/templateMetadata.ts"; +import { useTemplateMetadata } from "../../internal/hooks/useMessageReceivers.ts"; +import { FieldTypeData } from "../../internal/types/templateMetadata.ts"; import { formatDistance, fromMeters, getPreferredDistanceUnit, -} from "../utils/i18n/distance.ts"; +} from "../../utils/i18n/distance.ts"; import { DEFAULT_ENTITY_TYPE, LocatorEntityType, -} from "../utils/locatorEntityTypes.ts"; -import { resolveLocatorResultUrl } from "../utils/urls/resolveLocatorResultUrl.ts"; +} from "../../utils/locatorEntityTypes.ts"; +import { resolveLocatorResultUrl } from "../../utils/urls/resolveLocatorResultUrl.ts"; import { getBackgroundColorClasses, getBackgroundColorStyle, getTextColorClass, getTextColorStyle, -} from "../utils/colors.ts"; -import { themeManagerCn } from "../utils/cn.ts"; +} from "../../utils/colors.ts"; +import { themeManagerCn } from "../../utils/cn.ts"; export interface LocatorResultCardProps { /** The entity type this result card applies to. */ diff --git a/packages/visual-editor/src/components/locator/LocatorWrapper.tsx b/packages/visual-editor/src/components/locator/LocatorWrapper.tsx new file mode 100644 index 000000000..38e4ef9d3 --- /dev/null +++ b/packages/visual-editor/src/components/locator/LocatorWrapper.tsx @@ -0,0 +1,1026 @@ +import { WithPuckProps } from "@puckeditor/core"; +import { + FilterSearchResponse, + Matcher, + NearFilterValue, + provideHeadless, + Result, + SearchHeadlessProvider, + SelectableStaticFilter, + useSearchActions, + useSearchState, +} from "@yext/search-headless-react"; +import { useAnalytics } from "@yext/pages-components"; +import { + AnalyticsProvider, + AppliedFilters, + CardProps, + Coordinate, + executeSearch, + FilterSearch, + getUserLocation, + MapMarkerOptions, + OnDragHandler, + OnSelectParams, + Pagination, + SearchI18nextProvider, + useAnalytics as useSearchAnalytics, + VerticalResults, +} from "@yext/search-ui-react"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { FaSlidersH } from "react-icons/fa"; +import { useDocument } from "../../hooks/useDocument.tsx"; +import { usePreviewWindow } from "../../hooks/usePreviewWindow.ts"; +import { getViewport, useWindowWidth } from "../../hooks/useViewport.ts"; +import { resolveLocalizedAssetImage } from "../../types/images.ts"; +import { + getPreferredDistanceUnit, + toMeters, + toMiles, +} from "../../utils/i18n/distance.ts"; +import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { + createSearchAnalyticsConfig, + createSearchHeadlessConfig, +} from "../../utils/searchHeadlessConfig.ts"; +import { getThemeColorCssValue } from "../../utils/colors.ts"; +import { getValueFromQueryString } from "../../utils/urlQueryString.tsx"; +import { Button } from "../atoms/button.tsx"; +import { Body } from "../atoms/body.tsx"; +import { Heading } from "../atoms/heading.tsx"; +import { + DEFAULT_ENTITY_TYPE, + LocatorEntityType, + getLocatorEntityTypeSourceMap, + isLocatorEntityType, +} from "../../utils/locatorEntityTypes.ts"; +import { + DEFAULT_LOCATOR_RESULT_CARD_PROPS, + Location, + LocatorResultCard, +} from "./LocatorResultCard.tsx"; +import type { LocatorProps } from "./Locator.tsx"; +import { + COUNTRY_CODE_FIELD, + FilterModal, + LOCATION_FIELD, + buildEqualsLocationFilter, + buildNearLocationFilterFromCoords, + buildNearLocationFilterFromPrevious, + deselectOpenNowFilters, + HOURS_FIELD, + updateRadiusInNearFiltersOnLocationField, +} from "./Filters.tsx"; +import { + areValidCoordinates, + DEFAULT_RADIUS, + getConfiguredMapCenterOrDefault, + LocatorMap, + LoadingMapPlaceholder, + LocationStyleConfig, + makiIconMap, +} from "./Map.tsx"; +import { + MobileLocatorResultsSection, + ResultsCountSummary, + RESULTS_LIMIT, + SearchState, +} from "./Results.tsx"; + +export const INITIAL_LOCATION_KEY = "initialLocation"; + +export const LocatorWrapper = (props: WithPuckProps) => { + const streamDocument = useDocument(); + const { searchAnalyticsConfig, searcher } = React.useMemo(() => { + const searchHeadlessConfig = createSearchHeadlessConfig( + streamDocument, + props.puck.metadata?.experienceKeyEnvVar + ); + if (searchHeadlessConfig === undefined) { + return { searchAnalyticsConfig: undefined, searcher: undefined }; + } + + const searchAnalyticsConfig = createSearchAnalyticsConfig(streamDocument); + return { + searchAnalyticsConfig, + searcher: provideHeadless(searchHeadlessConfig), + }; + }, [streamDocument.id, streamDocument.locale]); + + if (searcher === undefined || searchAnalyticsConfig === undefined) { + console.warn( + "Could not create Locator component because Search Headless or Search Analytics config is undefined. Please check your environment variables." + ); + return <>; + } + + searcher.setSessionTrackingEnabled(true); + return ( + + + + + + + + ); +}; + +const LocatorInternal = ({ + mapStyle, + locationStyles, + filters: { openNowButton, showDistanceOptions, accentColor, facetFields }, + mapStartingLocation, + resultCard: resultCardConfigs, + distanceDisplay, + pageHeading, +}: LocatorProps) => { + // Adds unified [enable|disable]YextAnalytics to the window for both Pages and Search + // analytics. Typically used during consent banner implementation. + const searchAnalytics = useSearchAnalytics(); + const pagesAnalytics = useAnalytics(); + useEffect(() => { + (window as any).enableYextAnalytics = () => { + searchAnalytics?.optIn(); + pagesAnalytics?.optIn(); + }; + (window as any).disableYextAnalytics = () => { + searchAnalytics?.optOut(); + pagesAnalytics?.optOut(); + }; + + return () => { + delete (window as any).enableYextAnalytics; + delete (window as any).disableYextAnalytics; + }; + }, [searchAnalytics, pagesAnalytics]); + + const { t, i18n } = useTranslation(); + const previewWindow = usePreviewWindow(); + const windowWidth = useWindowWidth(previewWindow); + const { isMobile } = getViewport(windowWidth); + const preferredUnit = getPreferredDistanceUnit(i18n.language); + const streamDocument = useDocument(); + const entityTypeSourceMap = getLocatorEntityTypeSourceMap(streamDocument); + const entityTypes = + Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); + const resultCount = useSearchState( + (state) => state.vertical.resultsCount || 0 + ); + const searchResults = useSearchState( + (state) => (state.vertical.results || []) as Result[] + ); + const queryParamString = + typeof window === "undefined" ? "" : window.location.search; + const initialLocationParam = getValueFromQueryString( + INITIAL_LOCATION_KEY, + queryParamString + ); + + const iframe = + typeof document === "undefined" + ? undefined + : (document.getElementById("preview-frame") as HTMLIFrameElement); + + let mapboxApiKey = streamDocument._env?.YEXT_MAPBOX_API_KEY; + if ( + iframe?.contentDocument && + streamDocument._env?.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY + ) { + // If we are in the layout editor, use the non-URL-restricted Mapbox API key + mapboxApiKey = streamDocument._env.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY; + } + + const [showSearchAreaButton, setShowSearchAreaButton] = React.useState(false); + const [mapCenter, setMapCenter] = React.useState(); + const [mapRadius, setMapRadius] = React.useState(); + /** Explicit filter radius selected by the user, in meters */ + const [selectedDistanceMeters, setSelectedDistanceMeters] = React.useState< + number | null + >(null); + const [selectedDistanceOption, setSelectedDistanceOption] = React.useState< + number | null + >(null); + /** Radius of last location near filter returned by the filter search API */ + const apiFilterRadius = React.useRef(null); + + const handleDrag: OnDragHandler = (center, bounds) => { + setMapCenter({ + latitude: center.latitude, + longitude: center.longitude, + }); + setMapRadius(center.distanceTo(bounds.getNorthEast())); + setShowSearchAreaButton(true); + }; + + const [isOpenNowSelected, setIsOpenNowSelected] = React.useState(false); + const openNowFilter: SelectableStaticFilter = React.useMemo( + () => ({ + filter: { + kind: "fieldValue", + fieldId: HOURS_FIELD, + matcher: Matcher.OpenAt, + value: "now", + }, + selected: isOpenNowSelected, + displayName: t("openNow", "Open Now"), + }), + [isOpenNowSelected] + ); + + const searchActions = useSearchActions(); + + const handleSearchAreaClick = () => { + if (mapCenter && mapRadius) { + searchActions.setOffset(0); + const locationFilter: SelectableStaticFilter = { + selected: true, + displayName: "", + filter: { + kind: "fieldValue", + fieldId: LOCATION_FIELD, + value: { + lat: mapCenter.latitude, + lng: mapCenter.longitude, + radius: mapRadius, + name: t("customSearchArea", "Custom Search Area"), + }, + matcher: Matcher.Near, + }, + }; + searchActions.setStaticFilters([locationFilter, openNowFilter]); + searchActions.executeVerticalQuery(); + setSearchState("loading"); + setShowSearchAreaButton(false); + } + }; + + const selectedFacets: string[] = React.useMemo( + () => + facetFields?.selections + ?.filter((selection) => selection.value !== undefined) + ?.map((selection) => selection.value as string) ?? [], + [facetFields] + ); + React.useEffect(() => { + searchActions.setFacetAllowList(selectedFacets); + }, [searchActions, selectedFacets]); + + const filterDisplayName = useSearchState( + (state) => + state.filters?.static?.find( + (filter) => + filter.filter.kind === "fieldValue" && + (filter.filter.fieldId === LOCATION_FIELD || + filter.filter.fieldId === COUNTRY_CODE_FIELD) + )?.displayName + ); + + const handleFilterSelect = (params: OnSelectParams) => { + const newDisplayName = params.newDisplayName; + const filter = params.newFilter; + + let locationFilter: SelectableStaticFilter; + let nearFilterValue: NearFilterValue | undefined; + switch (filter.matcher) { + case Matcher.Near: { + nearFilterValue = filter.value as NearFilterValue; + apiFilterRadius.current = nearFilterValue.radius; + // only overwrite radius from filter if display options are enabled + const radius = + showDistanceOptions && selectedDistanceMeters + ? selectedDistanceMeters + : nearFilterValue.radius; + locationFilter = buildNearLocationFilterFromPrevious( + nearFilterValue, + newDisplayName, + radius + ); + break; + } + case Matcher.Equals: { + apiFilterRadius.current = null; + locationFilter = buildEqualsLocationFilter(filter, newDisplayName); + break; + } + default: { + throw new Error(`Unsupported matcher type: ${filter.matcher}`); + } + } + + searchActions.setOffset(0); + searchActions.setStaticFilters([locationFilter, openNowFilter]); + searchActions.executeVerticalQuery(); + setSearchState("loading"); + if ( + nearFilterValue?.lat && + nearFilterValue?.lng && + areValidCoordinates(nearFilterValue.lat, nearFilterValue.lng) + ) { + setMapCenter({ + latitude: nearFilterValue.lat, + longitude: nearFilterValue.lng, + }); + setMapRadius(nearFilterValue.radius); + } + }; + + const searchLoading = useSearchState((state) => state.searchStatus.isLoading); + const [searchState, setSearchState] = + React.useState("not started"); + + React.useEffect(() => { + if (!searchLoading && searchState === "loading") { + setSearchState("complete"); + } + }, [searchLoading, searchState]); + + React.useEffect(() => { + if (selectedDistanceOption === null) { + setSelectedDistanceMeters(null); + return; + } + + setSelectedDistanceMeters(toMeters(selectedDistanceOption, preferredUnit)); + }, [preferredUnit, selectedDistanceOption]); + + const resultsRef = React.useRef>([]); + const resultsContainer = React.useRef(null); + const [mobileResults, setMobileResults] = React.useState[]>( + [] + ); + // Tracks the selected pin index to highlight the corresponding result card. + const [selectedResultIndex, setSelectedResultIndex] = React.useState< + number | null + >(null); + + const setResultsRef = React.useCallback((index: number) => { + if (!resultsRef?.current) return null; + return (result: HTMLDivElement) => (resultsRef.current[index] = result); + }, []); + + const scrollToResult = React.useCallback( + (result: Result | undefined) => { + if (result) { + if (typeof result.index === "number") { + setSelectedResultIndex(result.index); + } + let scrollPos = 0; + // the search results that are listed above this result + const previousResultsRef = resultsRef.current.filter( + (r, index) => r && result.index && index < result.index + ); + + // sum up the height of all search results that are listed above this result + if (previousResultsRef.length > 0) { + scrollPos = previousResultsRef + .map((elem) => elem?.scrollHeight ?? 0) + .reduce((total, height) => total + height); + } + + resultsContainer.current?.scroll({ + top: scrollPos, + behavior: "smooth", + }); + } else { + setSelectedResultIndex(null); + } + }, + [] + ); + + const markerOptionsOverride = React.useCallback( + (selected: boolean): MapMarkerOptions => { + return { + offset: (selected ? [0, -21] : [0, -14]) as [number, number], + }; + }, + [] + ); + + const getResultCardProps = React.useCallback( + (entityType?: LocatorEntityType) => { + const existingConfig = (resultCardConfigs ?? []).find( + (item) => item.props.entityType === entityType + ); + if (existingConfig) { + return existingConfig.props; + } + + return DEFAULT_LOCATOR_RESULT_CARD_PROPS; + }, + [resultCardConfigs] + ); + + const CardComponent = React.useCallback( + (result: CardProps) => { + let resultCardProps = DEFAULT_LOCATOR_RESULT_CARD_PROPS; + const resultEntityType = result.result.entityType; + if (resultEntityType && isLocatorEntityType(resultEntityType)) { + resultCardProps = getResultCardProps(resultEntityType); + } else { + console.warn( + "Unexpected entityType from search result: ", + resultEntityType + ); + } + return ( + + ); + }, + [distanceDisplay, getResultCardProps, selectedResultIndex] + ); + + const [userLocationRetrieved, setUserLocationRetrieved] = + React.useState(false); + + const locationStylesConfig = React.useMemo(() => { + const config: LocationStyleConfig = {}; + (locationStyles ?? []).forEach((locationStyle) => { + const entityType = locationStyle.entityType; + if (!entityType) return; + const iconValue = + locationStyle.pinIcon?.type === "icon" + ? locationStyle.pinIcon.iconName + : undefined; + const customImageValue = + locationStyle.pinIcon?.type === "customImage" + ? resolveLocalizedAssetImage( + locationStyle.pinIcon.image, + i18n.language + ) + : undefined; + const customImageUrl = customImageValue?.url?.trim(); + config[entityType] = { + color: locationStyle.pinColor, + icon: + typeof iconValue === "string" ? makiIconMap[iconValue] : undefined, + customImage: customImageUrl + ? { + url: customImageUrl, + width: locationStyle.pinIcon?.width, + aspectRatio: locationStyle.pinIcon?.aspectRatio, + } + : undefined, + }; + }); + return config; + }, [i18n.language, locationStyles]); + + const initialMapCenter = React.useMemo( + () => getConfiguredMapCenterOrDefault(mapStartingLocation), + [mapStartingLocation] + ); + const [centerCoords, setCenterCoords] = + React.useState(initialMapCenter); + const [isInitialMapLocationResolved, setIsInitialMapLocationResolved] = + React.useState(false); + const canShowMoreMobileResults = + isMobile && mobileResults.length < resultCount; + + const mapProps = React.useMemo( + () => ({ + mapStyle, + centerCoords, + onDragHandler: handleDrag, + scrollToResult, + markerOptionsOverride, + locationStyleConfig: locationStylesConfig, + }), + [ + centerCoords, + handleDrag, + mapStyle, + markerOptionsOverride, + scrollToResult, + locationStylesConfig, + ] + ); + + React.useEffect(() => { + let isCancelled = false; + + const resolveLocationAndSearch = async () => { + setIsInitialMapLocationResolved(false); + setCenterCoords(initialMapCenter); + setMapCenter(initialMapCenter); + + const radius = + showDistanceOptions && selectedDistanceMeters + ? selectedDistanceMeters + : toMeters(DEFAULT_RADIUS, preferredUnit); + // default location filter to configured starting location or NYC + let initialLocationFilter = buildNearLocationFilterFromCoords( + initialMapCenter.latitude, + initialMapCenter.longitude, + radius + ); + const doSearch = () => { + searchActions.setVerticalLimit(RESULTS_LIMIT); + searchActions.setOffset(0); + searchActions.setStaticFilters([initialLocationFilter]); + searchActions.executeVerticalQuery(); + setSearchState("loading"); + if ( + initialLocationFilter.filter.kind === "fieldValue" && + initialLocationFilter.filter.matcher === Matcher.Near + ) { + const filterValue = initialLocationFilter.filter + .value as NearFilterValue; + const nextCenterCoords: Coordinate = { + longitude: filterValue.lng, + latitude: filterValue.lat, + }; + if ( + !isCancelled && + areValidCoordinates( + nextCenterCoords.latitude, + nextCenterCoords.longitude + ) + ) { + setCenterCoords(nextCenterCoords); + setMapCenter(nextCenterCoords); + setMapRadius(filterValue.radius); + } + } + if (!isCancelled) { + setIsInitialMapLocationResolved(true); + } + }; + + const foundStartingLocationFromQueryParam = async ( + queryParam: string + ): Promise => { + return searchActions + .executeFilterSearch(queryParam, false, [ + { + fieldApiName: LOCATION_FIELD, + entityType: entityTypes[0] ?? DEFAULT_ENTITY_TYPE, + fetchEntities: false, + }, + ]) + .then((response: FilterSearchResponse | undefined) => { + const firstResult = response?.sections[0]?.results[0]; + const resultFilter = firstResult?.filter; + if (!firstResult || !resultFilter) { + return false; + } + + switch (resultFilter.matcher) { + case Matcher.Near: { + const filterFromResult = resultFilter.value as NearFilterValue; + initialLocationFilter = buildNearLocationFilterFromPrevious( + filterFromResult, + firstResult.value + ); + apiFilterRadius.current = filterFromResult.radius; + return true; + } + case Matcher.Equals: { + initialLocationFilter = buildEqualsLocationFilter( + resultFilter, + firstResult.value + ); + apiFilterRadius.current = null; + return true; + } + default: { + return false; + } + } + }) + .catch((e) => { + console.warn("Filter search for initial location failed:", e); + return false; + }); + }; + + // 1. Check if a location could be determined from the initialLocation query parameter + if ( + initialLocationParam && + (await foundStartingLocationFromQueryParam(initialLocationParam)) + ) { + doSearch(); + return; + } + + try { + // 2. Try to get user location via Geolocation API + const location = await getUserLocation(); + const lat = location.coords.latitude; + const lng = location.coords.longitude; + setUserLocationRetrieved(true); + + // Try to reverse-geocode the coordinates to a human-readable place name using Mapbox + let displayName: string | undefined; + try { + if (mapboxApiKey) { + const lang = + (streamDocument.locale as string) || + (typeof navigator !== "undefined" + ? navigator.language + : undefined) || + "en"; + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxApiKey}&types=place,region,country&limit=1&language=${encodeURIComponent( + lang + )}`; + + const res = await fetch(url); + if (res.ok) { + const data = await res.json(); + const feature = data.features && data.features[0]; + displayName = feature?.place_name || undefined; + } + } + } catch (e) { + console.warn("Reverse geocoding failed:", e); + } finally { + initialLocationFilter = buildNearLocationFilterFromCoords( + lat, + lng, + radius, + displayName + ); + } + } catch { + // Fall back to configured starting location or default center. + } + + doSearch(); + }; + + resolveLocationAndSearch().catch((e) => { + if (!isCancelled) { + setIsInitialMapLocationResolved(true); + } + console.error("Failed perform search:", e); + }); + return () => { + isCancelled = true; + }; + }, [initialLocationParam, initialMapCenter, searchActions]); + + const handleOpenNowClick = (selected: boolean) => { + if (selected === isOpenNowSelected) { + // Prevents us from trying to set Open Now filter to false when it's not set + return; + } + searchActions.setFilterOption({ + filter: { + kind: "fieldValue", + fieldId: HOURS_FIELD, + matcher: Matcher.OpenAt, + value: "now", + }, + selected, + displayName: t("openNow", "Open Now"), + }); + setIsOpenNowSelected(selected); + searchActions.setOffset(0); + executeSearch(searchActions); + }; + + const searchFilters = useSearchState((state) => state.filters); + const currentOffset = useSearchState((state) => state.vertical.offset); + const previousOffset = React.useRef(undefined); + const prevIsMobile = React.useRef(isMobile); + + // Scroll to top when pagination changes + React.useEffect(() => { + if ( + !isMobile && + currentOffset !== previousOffset.current && + previousOffset.current !== undefined + ) { + resultsContainer.current?.scroll({ + top: 0, + behavior: "smooth", + }); + } + previousOffset.current = currentOffset; + }, [currentOffset, isMobile]); + + React.useEffect(() => { + const switchedToMobile = isMobile && !prevIsMobile.current; + + prevIsMobile.current = isMobile; + + if (!switchedToMobile || searchLoading || (currentOffset ?? 0) === 0) { + return; + } + + // Always reload from offset 0 if switching to mobile + setMobileResults([]); + searchActions.setOffset(0); + executeSearch(searchActions); + setSearchState("loading"); + }, [currentOffset, isMobile, searchActions, searchLoading]); + + React.useEffect(() => { + if (!isMobile || searchLoading) { + return; + } + + setMobileResults((previousResults) => { + // Mobile keeps a single growing list: later offsets append, while + // offset 0 replaces the list after a fresh search. + return (currentOffset ?? 0) > 0 && searchResults.length > 0 + ? [...previousResults, ...searchResults] + : searchResults; + }); + }, [currentOffset, isMobile, searchLoading, searchResults]); + + const handleDistanceClick = ( + distance: number, + distanceUnit: "mile" | "kilometer" + ) => { + const existingFilters = searchFilters.static || []; + let updatedFilters: SelectableStaticFilter[]; + const distanceInMeters = toMeters(distance, distanceUnit); + if (selectedDistanceOption === distance) { + setSelectedDistanceMeters(null); + setSelectedDistanceOption(null); + // revert to API radius (or default if none was found) if user clicks the same distance again + updatedFilters = updateRadiusInNearFiltersOnLocationField( + existingFilters, + apiFilterRadius.current ?? toMeters(DEFAULT_RADIUS, preferredUnit) + ); + } else { + setSelectedDistanceOption(distance); + updatedFilters = updateRadiusInNearFiltersOnLocationField( + existingFilters, + distanceInMeters + ); + } + searchActions.setStaticFilters(updatedFilters); + searchActions.setOffset(0); + executeSearch(searchActions); + }; + + const handleClearFiltersClick = () => { + const existingFilters = searchFilters.static || []; + // revert to API radius (or default if none was found) + const partiallyUpdatedFilters = updateRadiusInNearFiltersOnLocationField( + existingFilters, + apiFilterRadius.current ?? toMeters(DEFAULT_RADIUS, preferredUnit) + ); + const updatedFilters = deselectOpenNowFilters(partiallyUpdatedFilters); + + // Both open now and distance filters must be updated in the same setStaticFilters call to + // avoid problems due to the asynchronous nature of state updates. + searchActions.setStaticFilters(updatedFilters); + searchActions.resetFacets(); + // Execute search to update AppliedFilters components + searchActions.setOffset(0); + executeSearch(searchActions); + setSelectedDistanceMeters(null); + setSelectedDistanceOption(null); + }; + + // If something else causes the filters to update, check if the hours filter is still present + // and toggle off the Open Now toggle if not. + React.useEffect(() => { + setIsOpenNowSelected( + searchFilters.static + ? !!searchFilters.static.find((staticFilter) => { + return ( + staticFilter.filter.kind === "fieldValue" && + staticFilter.filter.fieldId === HOURS_FIELD && + staticFilter.selected === true + ); + }) + : false + ); + }, [searchFilters]); + + const hasFacetOptions = + ( + useSearchState((state) => + state.filters.facets?.filter((f) => f.options.length) + ) ?? [] + ).length > 0; + const hasFilterModalToggle = + openNowButton || showDistanceOptions || hasFacetOptions; + const filterAccentColorCssVariable = + getThemeColorCssValue(accentColor?.selectedColor) ?? + "var(--colors-palette-primary-dark)"; + const [showFilterModal, setShowFilterModal] = React.useState(false); + const filterToggleButtonRef = React.useRef(null); + const filterModalCloseButtonRef = React.useRef(null); + const hasOpenedFilterModalRef = React.useRef(false); + const resolvedHeading = + (pageHeading?.title && + resolveComponentData(pageHeading.title, i18n.language, streamDocument)) || + t("findALocation", "Find a Location"); + + const requireMapOptIn: boolean = streamDocument.__?.visualEditorConfig + ? JSON.parse(streamDocument.__?.visualEditorConfig)?.requireMapOptIn + : false; + // If no opt-in is required, the map is already enabled. + const [mapEnabled, setMapEnabled] = React.useState(!requireMapOptIn); + // Adds unified [enable|disable]Map functions to the window. + useEffect(() => { + (window as any).enableMap = () => setMapEnabled(true); + (window as any).disableMap = () => setMapEnabled(false); + + return () => { + delete (window as any).enableMap; + delete (window as any).disableMap; + }; + }, []); + + useEffect(() => { + if (showFilterModal) { + hasOpenedFilterModalRef.current = true; + filterModalCloseButtonRef.current?.focus(); + return; + } + + if (hasOpenedFilterModalRef.current) { + filterToggleButtonRef.current?.focus(); + } + }, [showFilterModal]); + + return ( +
+ {/* Left Section: FilterSearch + Results. Full width for small screens */} +
+
+ + {resolvedHeading} + + +
+
+
+
+ + {hasFilterModalToggle && ( + + )} +
+
+ +
+
+ {resultCount > 0 && ( +
+ {isMobile ? ( + { + if (searchLoading || mobileResults.length >= resultCount) { + return; + } + + searchActions.setOffset(mobileResults.length); + executeSearch(searchActions); + setSearchState("loading"); + }} + /> + ) : ( + + )} +
+ )} + {!isMobile && resultCount > RESULTS_LIMIT && ( +
+ +
+ )} + setShowFilterModal(false)} + handleClearFiltersClick={handleClearFiltersClick} + accentColorCssValue={filterAccentColorCssVariable} + closeButtonRef={filterModalCloseButtonRef} + /> +
+
+ + {/* Right Section: Map. Hidden for small screens */} +
+ {mapEnabled && isInitialMapLocationResolved && ( + + )} + {mapEnabled && !isInitialMapLocationResolved && ( + + )} + {!mapEnabled && ( +
+
+ + {t( + "mapRequiresOptIn", + "This map can only be displayed if cookies are enabled" + )} + +
+ +
+
+
+ )} + {showSearchAreaButton && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/visual-editor/src/components/locator/Map.tsx b/packages/visual-editor/src/components/locator/Map.tsx new file mode 100644 index 000000000..4ad58de08 --- /dev/null +++ b/packages/visual-editor/src/components/locator/Map.tsx @@ -0,0 +1,304 @@ +import { + Coordinate, + MapboxMap, + MapMarkerOptions, + OnDragHandler, + PinComponentProps, +} from "@yext/search-ui-react"; +import { Result } from "@yext/search-headless-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ImageField } from "../../fields/ImageField.tsx"; +import { useDocument } from "../../hooks/useDocument.tsx"; +import { msg } from "../../utils/i18n/platform.ts"; +import { + backgroundColors, + ThemeColor, +} from "../../utils/themeConfigOptions.ts"; +import { StreamDocument } from "../../utils/types/StreamDocument.ts"; +import { Body } from "../atoms/body.tsx"; +import { MapPinIcon } from "../MapPinIcon.tsx"; +import { isVisualEditorTestEnv } from "../testing/utils.ts"; +import { Location } from "./LocatorResultCard.tsx"; + +export const DEFAULT_MAP_CENTER: Coordinate = { + latitude: 40.741611, + longitude: -74.005371, +}; // New York City +export const DEFAULT_RADIUS = 25; +export const DEFAULT_PIN_ICON_WIDTH = 14; +export const DEFAULT_LOCATION_STYLE = { + pinIcon: { type: "none" }, + pinColor: backgroundColors.background6.value, +}; +export const MAX_PIN_ICON_WIDTH = 27; +const PIN_ICON_MAX_FILE_SIZE_BYTES = 128 * 1024; + +export type LocationStyleConfig = Record< + string, + { + color?: ThemeColor; + icon?: string; + customImage?: { + url: string; + width?: number; + aspectRatio?: number; + }; + } +>; + +export const LOCATOR_PIN_ICON_FIELD: ImageField = { + type: "image", + label: msg("fields.icon", "Icon"), + hideAltTextField: true, + maxFileSizeBytes: PIN_ICON_MAX_FILE_SIZE_BYTES, +}; + +export const getConfiguredMapCenterOrDefault = (mapStartingLocation?: { + latitude: string; + longitude: string; +}): Coordinate => { + if (mapStartingLocation?.latitude && mapStartingLocation.longitude) { + try { + return parseMapStartingLocation(mapStartingLocation); + } catch (e) { + console.error(e); + } + } + + return DEFAULT_MAP_CENTER; +}; + +const makiIconModules = import.meta.glob( + "../../../node_modules/@mapbox/maki/icons/*.svg", + { + eager: true, + import: "default", + } +) as Record; + +const makiIconEntries = Object.entries(makiIconModules).map(([path, icon]) => { + const name = path.split("/").pop()?.replace(".svg", "") || path; + return [name, icon] as const; +}); + +export const makiIconMap: Record = + Object.fromEntries(makiIconEntries); + +const formatMakiIconLabel = (name: string) => + name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + +export const makiIconOptions = makiIconEntries.map(([name, icon]) => ({ + label: formatMakiIconLabel(name), + value: name, + icon, +})); + +export const DEFAULT_MAKI_ICON_NAME = makiIconOptions[0]?.value; + +export const LoadingMapPlaceholder = () => { + const { t } = useTranslation(); + + return ( +
+
+ + {t("loadingMap", "Loading Map...")} + +
+
+ ); +}; + +const LocatorTestMap = () => { + return ( +
+ + ); +}; + +interface MapProps { + mapStyle?: string; + centerCoords?: Coordinate; + onDragHandler?: OnDragHandler; + scrollToResult?: (result: Result | undefined) => void; + markerOptionsOverride?: (selected: boolean) => MapMarkerOptions; + locationStyleConfig?: LocationStyleConfig; +} + +export const LocatorMap: React.FC = ({ + mapStyle, + centerCoords, + onDragHandler, + scrollToResult, + markerOptionsOverride, + locationStyleConfig, +}) => { + const entityDocument: StreamDocument = useDocument(); + + const documentIsUndefined = typeof document === "undefined"; + const iframe = documentIsUndefined + ? undefined + : (document.getElementById("preview-frame") as HTMLIFrameElement); + + const locatorMapDiv = documentIsUndefined + ? null + : ((iframe?.contentDocument || document)?.getElementById( + "locatorMapDiv" + ) as HTMLDivElement | null); + + const mapPadding = React.useMemo( + () => getMapboxMapPadding(locatorMapDiv), + [locatorMapDiv] + ); + const mapboxOptions = React.useMemo( + () => ({ + center: centerCoords, + fitBoundsOptions: { padding: mapPadding }, + ...(mapStyle ? { style: mapStyle } : {}), + }), + [centerCoords, mapPadding, mapStyle] + ); + const PinComponent = React.useMemo( + () => + function PinComponent(pinProps: PinComponentProps) { + return ( + + ); + }, + [locationStyleConfig] + ); + + if (isVisualEditorTestEnv()) { + return ; + } + + // During page generation we don't exist in a browser context + //@ts-expect-error MapboxGL is not loaded in iframe content window + if (iframe?.contentDocument && !iframe.contentWindow?.mapboxgl) { + // We are in an iframe, and mapboxgl is not loaded in yet + return ; + } + + let mapboxApiKey = entityDocument._env?.YEXT_MAPBOX_API_KEY; + if ( + iframe?.contentDocument && + entityDocument._env?.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY + ) { + // If we are in the layout editor, use the non-URL-restricted Mapbox API key + mapboxApiKey = entityDocument._env.YEXT_EDIT_LAYOUT_MODE_MAPBOX_API_KEY; + } + + return ( + + ); +}; + +type LocatorMapPinProps = PinComponentProps & { + locationStyleConfig?: LocationStyleConfig; +}; + +const LocatorMapPin = ({ + result, + selected, + locationStyleConfig, +}: LocatorMapPinProps) => { + const entityType = result.entityType; + const entityLocationStyle = entityType + ? locationStyleConfig?.[entityType] + : undefined; + + return ( + + ); +}; + +export const getMapboxMapPadding = (divElement: HTMLDivElement | null) => { + if (!divElement) { + return 50; + } + + const { width, height } = divElement.getBoundingClientRect(); + const mapVerticalPadding = Math.max(50, height * 0.2); + const mapHorizontalPadding = Math.max(50, width * 0.2); + return { + top: mapVerticalPadding, + bottom: mapVerticalPadding, + left: mapHorizontalPadding, + right: mapHorizontalPadding, + }; +}; + +export const parseMapStartingLocation = (mapStartingLocation: { + latitude: string; + longitude: string; +}): Coordinate => { + const lat = parseFloat(mapStartingLocation.latitude); + const lng = parseFloat(mapStartingLocation.longitude); + + const err: string[] = []; + if (isNaN(lat) || lat < -90 || lat > 90) { + err.push("Latitude must be a number between -90 and 90."); + } + if (isNaN(lng) || lng < -180 || lng > 180) { + err.push("Longitude must be a number between -180 and 180."); + } + if (err.length) { + throw new Error(err.join("\n")); + } + + return { + latitude: lat, + longitude: lng, + }; +}; + +/** Checks whether a given lat and lng are valid coordinates */ +export function areValidCoordinates(lat: number, lng: number): boolean { + return ( + !isNaN(lat) && + !isNaN(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); +} diff --git a/packages/visual-editor/src/components/locator/Results.tsx b/packages/visual-editor/src/components/locator/Results.tsx new file mode 100644 index 000000000..9eba823fc --- /dev/null +++ b/packages/visual-editor/src/components/locator/Results.tsx @@ -0,0 +1,267 @@ +import { setDeep } from "@puckeditor/core"; +import { CardProps } from "@yext/search-ui-react"; +import { Result } from "@yext/search-headless-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { YextAutoField } from "../../fields/YextAutoField.tsx"; +import { useDocument } from "../../hooks/useDocument.tsx"; +import { useTemplateMetadata } from "../../internal/hooks/useMessageReceivers.ts"; +import { getPreferredDistanceUnit } from "../../utils/i18n/distance.ts"; +import { getLocatorEntityTypeSourceMap } from "../../utils/locatorEntityTypes.ts"; +import { LocatorConfig } from "../../utils/types/StreamDocument.ts"; +import { Body } from "../atoms/body.tsx"; +import { Button } from "../atoms/button.tsx"; +import { + DEFAULT_LOCATOR_RESULT_CARD_PROPS, + Location, + LocatorResultCardFields, + LocatorResultCardProps, +} from "./LocatorResultCard.tsx"; + +export const RESULTS_LIMIT = 20; +export type SearchState = "not started" | "loading" | "complete"; + +const BOOLEAN_SUPPORTED_FIELDS = [ + "primaryHeading", + "secondaryHeading", + "tertiaryHeading", +] as const; + +const getLocatorConfigFromPageSet = (pageSet?: string): LocatorConfig => { + if (!pageSet) { + return {}; + } + + try { + return JSON.parse(pageSet)?.typeConfig?.locatorConfig ?? {}; + } catch { + console.error("Failed to parse locator config from page set"); + return {}; + } +}; + +export const translateDistanceUnit = ( + t: (key: string, options?: Record) => string, + unit: "mile" | "kilometer", + count: number +) => { + if (unit === "mile") { + return t("mile", { count, defaultValue: "mile" }); + } + + return t("kilometer", { count, defaultValue: "kilometer" }); +}; + +interface MobileLocatorResultsSectionProps { + CardComponent: React.ComponentType>; + results: Result[]; + hasMoreResults: boolean; + handleShowMoreResults: () => void; +} + +export const ResultCardPropsField = ({ + value, + onChange, +}: { + value?: LocatorResultCardProps; + onChange: (value: LocatorResultCardProps) => void; +}) => { + const streamDocument = useDocument(); + const templateMetadata = useTemplateMetadata(); + const entityTypeSourceMap = getLocatorEntityTypeSourceMap(); + const entityTypeScopes = React.useMemo(() => { + const locatorConfig = getLocatorConfigFromPageSet(streamDocument?._pageset); + return locatorConfig.entityTypeScope ?? []; + }, [streamDocument]); + + /** + * Builds the field schema for the result card editor, including: + * - Conditionally removing the primary CTA section when entity scope is not attached to a page set. + * - Toggling constant value vs. field selector visibility per section. + */ + const resultCardFields = React.useMemo(() => { + if (!value?.entityType) { + return LocatorResultCardFields; + } + let fields = LocatorResultCardFields; + const entityTypeHasSourcePageSet = !!entityTypeSourceMap[value.entityType]; + const scopeExistsForEntityType = + entityTypeScopes.find( + (scope) => scope.entityType === value.entityType + ) !== undefined; + + fields = setDeep( + fields, + `objectFields.primaryCTA.objectFields.link.visible`, + !entityTypeHasSourcePageSet && scopeExistsForEntityType + ); + + // For each section, show either the field selector or the constant value editor. + BOOLEAN_SUPPORTED_FIELDS.forEach((key) => { + const headingConfig = value[key]; + const constantValueEnabled = headingConfig?.constantValueEnabled ?? false; + const field = headingConfig?.field; + const fieldTypeId = field + ? templateMetadata?.locatorDisplayFields?.[field]?.field_type_id + : undefined; + const booleanFieldSelected = + !constantValueEnabled && fieldTypeId === "type.boolean"; + + fields = setDeep( + fields, + `objectFields.${key}.objectFields.field.visible`, + !constantValueEnabled + ); + fields = setDeep( + fields, + `objectFields.${key}.objectFields.constantValue.visible`, + constantValueEnabled + ); + fields = setDeep( + fields, + `objectFields.${key}.objectFields.trueDisplayText.visible`, + !constantValueEnabled && booleanFieldSelected + ); + fields = setDeep( + fields, + `objectFields.${key}.objectFields.falseDisplayText.visible`, + booleanFieldSelected + ); + }); + + const imageConstantValueEnabled = + value.image?.constantValueEnabled ?? false; + fields = setDeep( + fields, + "objectFields.image.objectFields.field.visible", + !imageConstantValueEnabled + ); + fields = setDeep( + fields, + "objectFields.image.objectFields.constantValue.visible", + imageConstantValueEnabled + ); + + return fields; + }, [entityTypeSourceMap, entityTypeScopes, templateMetadata, value]); + + return ( + + ); +}; + +export const MobileLocatorResultsSection = ({ + CardComponent, + results, + hasMoreResults, + handleShowMoreResults, +}: MobileLocatorResultsSectionProps) => { + const { t } = useTranslation(); + + return ( + <> + {results.length > 0 && ( +
+ {results.map((result, position) => ( +
+ +
+ ))} +
+ )} + {hasMoreResults && ( + // Mobile replaces numbered pagination with incremental loading. +
+ +
+ )} + + ); +}; + +interface ResultsCountSummaryProps { + searchState: SearchState; + resultCount: number; + selectedDistanceOption: number | null; + filterDisplayName?: string; +} + +export const ResultsCountSummary = ({ + searchState, + resultCount, + selectedDistanceOption, + filterDisplayName, +}: ResultsCountSummaryProps) => { + const { t, i18n } = useTranslation(); + + if (resultCount === 0) { + if (searchState === "not started") { + return ( + + {t( + "useOurLocatorToFindALocationNearYou", + "Use our locator to find a location near you" + )} + + ); + } + + if (searchState === "complete") { + return ( + + {t("noResultsFoundForThisArea", "No results found for this area")} + + ); + } + + return
; + } + + if (filterDisplayName) { + if (selectedDistanceOption) { + const unit = getPreferredDistanceUnit(i18n.language); + return ( + + {t("locationsWithinDistanceOf", { + count: resultCount, + distance: selectedDistanceOption, + unit: translateDistanceUnit(t, unit, selectedDistanceOption), + name: filterDisplayName, + })} + + ); + } + + return ( + + {t("locationsNear", { + count: resultCount, + name: filterDisplayName, + })} + + ); + } + + return ( + + {t("locationWithCount", { + count: resultCount, + })} + + ); +}; diff --git a/packages/visual-editor/src/components/testing/screenshots/PhotoGallerySection/[desktop] version 59 with showSectionHeading false.png b/packages/visual-editor/src/components/testing/screenshots/PhotoGallerySection/[desktop] version 59 with showSectionHeading false.png index 78479fbdf..b9bf47805 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/PhotoGallerySection/[desktop] version 59 with showSectionHeading false.png and b/packages/visual-editor/src/components/testing/screenshots/PhotoGallerySection/[desktop] version 59 with showSectionHeading false.png differ