diff --git a/packages/visual-editor/src/components/Locator.test.tsx b/packages/visual-editor/src/components/Locator.test.tsx index a3ecd7659..385c3e72a 100644 --- a/packages/visual-editor/src/components/Locator.test.tsx +++ b/packages/visual-editor/src/components/Locator.test.tsx @@ -127,7 +127,7 @@ const tests: ComponentTest[] = [ "accounts/4174974/sites/163770/pagesets/restaurants", "accounts/4174974/sites/163770/pagesets/atms", ], - entityTypeScopes: [ + entityTypeScope: [ { entityType: "financialProfessional", savedFilter: "1491722104", diff --git a/packages/visual-editor/src/components/Locator.tsx b/packages/visual-editor/src/components/Locator.tsx index e982f0493..3b076338a 100644 --- a/packages/visual-editor/src/components/Locator.tsx +++ b/packages/visual-editor/src/components/Locator.tsx @@ -71,7 +71,10 @@ import { BackgroundStyle, backgroundColors, } from "../utils/themeConfigOptions.ts"; -import { StreamDocument } from "../utils/types/StreamDocument.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"; @@ -87,8 +90,8 @@ import { MapPinIcon } from "./MapPinIcon.js"; import { useAnalytics } from "@yext/pages-components"; import { DEFAULT_ENTITY_TYPE, - EntityType, - isEntityType, + LocatorEntityType, + isLocatorEntityType, getLocatorEntityTypeSourceMap, getEntityTypeLabel, } from "../utils/locatorEntityTypes.ts"; @@ -107,6 +110,19 @@ const DEFAULT_LOCATION_STYLE = { pinColor: backgroundColors.background6.value, }; +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", @@ -152,7 +168,13 @@ const ResultCardPropsField = ({ value?: LocatorResultCardProps; onChange: (value: LocatorResultCardProps) => void; }) => { + const streamDocument = useDocument(); 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. @@ -163,9 +185,17 @@ const ResultCardPropsField = ({ return LocatorResultCardFields; } let fields = LocatorResultCardFields; - const showPrimaryCta = !!entityTypeSourceMap[value.entityType]; - - fields = setDeep(fields, `objectFields.primaryCTA.visible`, showPrimaryCta); + 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. const constantValueFieldConfigs = [ @@ -201,7 +231,7 @@ const ResultCardPropsField = ({ }); return fields; - }, [entityTypeSourceMap, value]); + }, [entityTypeSourceMap, entityTypeScopes, value]); return ( [] { const facetFields: DynamicOption[] = []; const addedValues: Set = new Set(); @@ -229,7 +259,7 @@ function getFacetFieldOptions( } function getFacetFieldOptionsForEntityType( - entityType: EntityType + entityType: LocatorEntityType ): DynamicOption[] { let filterOptions: DynamicOption[] = []; switch (entityType) { @@ -565,7 +595,7 @@ export interface LocatorProps { */ locationStyles: Array<{ /** The entity type this style applies to. */ - entityType: EntityType; + entityType: LocatorEntityType; /** Whether to render an icon in the pin. */ pinIcon?: { type: "none" | "icon"; @@ -768,7 +798,7 @@ const locatorFields: Fields = { getOptions: () => { const entityTypeSourceMap = getLocatorEntityTypeSourceMap(); const entityTypes = - Object.keys(entityTypeSourceMap).filter(isEntityType); + Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); return getFacetFieldOptions(entityTypes); }, placeholderOptionLabel: msg( @@ -1038,7 +1068,8 @@ const LocatorInternal = ({ const preferredUnit = getPreferredDistanceUnit(i18n.language); const streamDocument = useDocument(); const entityTypeSourceMap = getLocatorEntityTypeSourceMap(streamDocument); - const entityTypes = Object.keys(entityTypeSourceMap).filter(isEntityType); + const entityTypes = + Object.keys(entityTypeSourceMap).filter(isLocatorEntityType); const resultCount = useSearchState( (state) => state.vertical.resultsCount || 0 ); @@ -1258,7 +1289,7 @@ const LocatorInternal = ({ }, []); const getResultCardProps = React.useCallback( - (entityType?: EntityType) => { + (entityType?: LocatorEntityType) => { const existingConfig = (resultCardConfigs ?? []).find( (item) => item.props.entityType === entityType ); @@ -1273,15 +1304,9 @@ const LocatorInternal = ({ const CardComponent = React.useCallback( (result: CardProps) => { let resultCardProps = DEFAULT_LOCATOR_RESULT_CARD_PROPS; - let showPrimaryCta; const resultEntityType = result.result.entityType; - if (resultEntityType && isEntityType(resultEntityType)) { + if (resultEntityType && isLocatorEntityType(resultEntityType)) { resultCardProps = getResultCardProps(resultEntityType); - // Show primary CTA when the liveVisibility is true and entity scope is backed by an entity page set. - showPrimaryCta = - resultCardProps.primaryCTA.liveVisibility && - !!resultEntityType && - !!entityTypeSourceMap[resultEntityType]; } else { console.warn( "Unexpected entityType from search result: ", @@ -1293,7 +1318,6 @@ const LocatorInternal = ({ {...result} resultCardProps={resultCardProps} isSelected={result.result.index === selectedResultIndex} - showPrimaryCta={showPrimaryCta} distanceDisplay={distanceDisplay} /> ); diff --git a/packages/visual-editor/src/components/LocatorResultCard.tsx b/packages/visual-editor/src/components/LocatorResultCard.tsx index a4d85fa2e..83039c741 100644 --- a/packages/visual-editor/src/components/LocatorResultCard.tsx +++ b/packages/visual-editor/src/components/LocatorResultCard.tsx @@ -65,13 +65,13 @@ import { } from "../utils/i18n/distance.ts"; import { DEFAULT_ENTITY_TYPE, - EntityType, + LocatorEntityType, } from "../utils/locatorEntityTypes.ts"; import { resolveLocatorResultUrl } from "../utils/urls/resolveLocatorResultUrl.ts"; export interface LocatorResultCardProps { /** The entity type this result card applies to. */ - entityType: EntityType; + entityType: LocatorEntityType; /** Settings for the main heading of the card */ primaryHeading: { @@ -189,6 +189,8 @@ export interface LocatorResultCardProps { variant: CTAVariant; /** Whether the primary CTA is visible in live mode */ liveVisibility: boolean; + /** Static URL to use for primary CTA when an entity page URL is not found */ + link?: TranslatableString; }; /** Settings for the secondary CTA */ @@ -645,6 +647,13 @@ export const LocatorResultCardFields: Field = { ], } ), + link: TranslatableStringField( + msg("fields.link", "Link"), + undefined, + false, + true, + () => getDisplayFieldOptions("type.string") + ), }, }, secondaryCTA: { @@ -744,16 +753,12 @@ export const LocatorResultCard = React.memo( resultCardProps: props, distanceDisplay = "distanceFromUser", isSelected, - showPrimaryCta, }: { result: CardProps["result"]; resultCardProps: LocatorResultCardProps; distanceDisplay?: DistanceDisplayOption; isSelected?: boolean; - showPrimaryCta?: boolean; }): React.JSX.Element => { - const { document: streamDocument, relativePrefixToRoot } = - useTemplateProps(); const { t, i18n } = useTranslation(); const location = result.rawData; @@ -775,10 +780,6 @@ export const LocatorResultCard = React.memo( result, "DRIVING_DIRECTIONS" ); - const handleVisitPageClick = useCardAnalyticsCallback( - result, - "VIEW_WEBSITE" - ); const handleSecondaryCTAClick = useCardAnalyticsCallback( result, "CTA_CLICK" @@ -788,12 +789,6 @@ export const LocatorResultCard = React.memo( "TAP_TO_CALL" ); - const resolvedUrl = resolveLocatorResultUrl( - location, - streamDocument, - relativePrefixToRoot - ); - const getDirectionsLink: string | undefined = (() => { const listings = location.ref_listings ?? []; const listingsLink = getDirections( @@ -920,22 +915,9 @@ export const LocatorResultCard = React.memo( {displayDistance} )} + {/** CTA section */}
- {showPrimaryCta && resolvedUrl && ( - - )} + {props.secondaryCTA.liveVisibility && ( ["result"]; +}) => { + const { primaryCTA, result } = props; + const location = result.rawData; + const { document: streamDocument, relativePrefixToRoot } = useTemplateProps(); + const { t, i18n } = useTranslation(); + + // Always uses the entity page link if one exists. If not, tries to resolve URL from the static + // template in the Link prop, and if that also fails, doesn't render at all. + let resolvedUrl = resolveLocatorResultUrl( + location, + streamDocument, + relativePrefixToRoot + ); + if (resolvedUrl === undefined && primaryCTA?.link) { + resolvedUrl = resolveComponentData( + primaryCTA.link, + i18n.language, + location + ); + } + + const showPrimaryCta = primaryCTA.liveVisibility && resolvedUrl; + + const handlePrimaryCtaClick = useCardAnalyticsCallback( + result, + "VIEW_WEBSITE" + ); + + return ( + showPrimaryCta && ( + + ) + ); +}; + const CardIcon: React.FC<{ children: React.ReactNode }> = ({ children }) => { const colorClasses = `${backgroundColors.background2.value.bgColor} ${backgroundColors.background2.value.textColor}`; return ( diff --git a/packages/visual-editor/src/docs/components.md b/packages/visual-editor/src/docs/components.md index 5e525f63a..dcf33cf9e 100644 --- a/packages/visual-editor/src/docs/components.md +++ b/packages/visual-editor/src/docs/components.md @@ -543,15 +543,15 @@ Available on Locator templates. Controls which distance value to display on each locator result card. -| Prop | Type | Description | Default | -| :-------------------- | :------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------- | -| `distanceDisplay` | `DistanceDisplayOption` | Controls which distance value to display on each locator result card. | | -| `filters` | `{ openNowButton: boolean; showDistanceOptions: boolean; facetFields?: DynamicOptionsSelectorType; }` | Configuration for the filters available in the locator search experience. | | -| `locationStyles` | `Array<{ entityType: EntityType; pinIcon?: { type: "none" \| "icon"; iconName?: string; }; pinColor?: BackgroundStyle; }>` | 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. | | -| `mapStartingLocation` | `{ latitude: string; longitude: string; }` | The starting location for the map. | | -| `mapStyle` | `string` | The visual theme for the map tiles, chosen from a predefined list of Mapbox styles. | `'mapbox://styles/mapbox/streets-v12'` | -| `pageHeading` | `{ title: TranslatableString; color?: BackgroundStyle; }` | Configuration for the locator page heading. Allows customizing the title text and its color. | | -| `resultCard` | `Array<{ props: LocatorResultCardProps; }>` | 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. | | +| Prop | Type | Description | Default | +| :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------- | +| `distanceDisplay` | `DistanceDisplayOption` | Controls which distance value to display on each locator result card. | | +| `filters` | `{ openNowButton: boolean; showDistanceOptions: boolean; facetFields?: DynamicOptionsSelectorType; }` | Configuration for the filters available in the locator search experience. | | +| `locationStyles` | `Array<{ entityType: LocatorEntityType; pinIcon?: { type: "none" \| "icon"; iconName?: string; }; pinColor?: BackgroundStyle; }>` | 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. | | +| `mapStartingLocation` | `{ latitude: string; longitude: string; }` | The starting location for the map. | | +| `mapStyle` | `string` | The visual theme for the map tiles, chosen from a predefined list of Mapbox styles. | `'mapbox://styles/mapbox/streets-v12'` | +| `pageHeading` | `{ title: TranslatableString; color?: BackgroundStyle; }` | Configuration for the locator page heading. Allows customizing the title text and its color. | | +| `resultCard` | `Array<{ props: LocatorResultCardProps; }>` | 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. | | --- diff --git a/packages/visual-editor/src/utils/locatorEntityTypes.ts b/packages/visual-editor/src/utils/locatorEntityTypes.ts index a5709fc00..d2de2f729 100644 --- a/packages/visual-editor/src/utils/locatorEntityTypes.ts +++ b/packages/visual-editor/src/utils/locatorEntityTypes.ts @@ -1,43 +1,45 @@ -import { StreamDocument } from "./types/StreamDocument.ts"; +import { + LocatorConfig, + LocatorSourcePageSetInfo, + StreamDocument, +} from "./types/StreamDocument.ts"; import { useDocument } from "../hooks/useDocument.tsx"; import { pt } from "../utils/i18n/platform.ts"; -export const DEFAULT_ENTITY_TYPE = "location"; -export type EntityType = - | "location" - | "healthcareProfessional" - | "healthcareFacility" - | "restaurant" - | "hotel" - | "financialProfessional"; +const LOCATOR_ENTITY_TYPES = [ + "location", + "healthcareProfessional", + "healthcareFacility", + "restaurant", + "hotel", + "financialProfessional", +] as const; -export function isEntityType(value: string): value is EntityType { - return ( - value === "location" || - value === "healthcareProfessional" || - value === "healthcareFacility" || - value === "restaurant" || - value === "hotel" || - value === "financialProfessional" - ); +export type LocatorEntityType = (typeof LOCATOR_ENTITY_TYPES)[number]; +export const DEFAULT_ENTITY_TYPE: LocatorEntityType = "location"; + +const VALID_ENTITY_TYPES = new Set(LOCATOR_ENTITY_TYPES); + +export function isLocatorEntityType(value: string): value is LocatorEntityType { + return VALID_ENTITY_TYPES.has(value); } export const getLocatorEntityTypeSourceMap = ( streamDocument?: StreamDocument -): Partial> => { +): Partial> => { const entityDocument: StreamDocument = streamDocument ?? useDocument(); - const entityTypeSourceMap: Partial> = - {}; + const entityTypeSourceMap: Partial< + Record + > = {}; const locatorSourcePageSets = entityDocument.__?.locatorSourcePageSets; if (locatorSourcePageSets) { try { - const pageSetMap = JSON.parse(locatorSourcePageSets) as Record< - string, - { entityType?: EntityType } - >; + const pageSetMap: Record = JSON.parse( + locatorSourcePageSets + ); for (const [source, entry] of Object.entries(pageSetMap)) { - if (entry.entityType) { + if (entry?.entityType && isLocatorEntityType(entry.entityType)) { entityTypeSourceMap[entry.entityType] = source; } } @@ -52,20 +54,21 @@ export const getLocatorEntityTypeSourceMap = ( const pageset = entityDocument._pageset; if (pageset) { try { - const locatorConfig = JSON.parse(pageset)?.typeConfig?.locatorConfig as - | { - source?: string; - entityType?: EntityType; - entityTypeScope?: Array<{ entityType?: EntityType }>; - } - | undefined; + const locatorConfig: LocatorConfig = + JSON.parse(pageset)?.typeConfig?.locatorConfig; - if (locatorConfig?.entityType) { + if ( + locatorConfig?.entityType && + isLocatorEntityType(locatorConfig.entityType) + ) { entityTypeSourceMap[locatorConfig.entityType] = locatorConfig.source; } for (const entityTypeScope of locatorConfig?.entityTypeScope ?? []) { - if (entityTypeScope.entityType) { + if ( + entityTypeScope?.entityType && + isLocatorEntityType(entityTypeScope.entityType) + ) { entityTypeSourceMap[entityTypeScope.entityType] = undefined; } } @@ -81,7 +84,7 @@ export const getLocatorEntityTypeSourceMap = ( return entityTypeSourceMap; }; -export const getEntityTypeLabel = (entityType: EntityType) => { +export const getEntityTypeLabel = (entityType: LocatorEntityType) => { switch (entityType) { case "healthcareProfessional": return pt("healthcareProfessionals", "Healthcare Professionals"); diff --git a/packages/visual-editor/src/utils/types/StreamDocument.ts b/packages/visual-editor/src/utils/types/StreamDocument.ts index 135cfe0db..4e63a1cae 100644 --- a/packages/visual-editor/src/utils/types/StreamDocument.ts +++ b/packages/visual-editor/src/utils/types/StreamDocument.ts @@ -36,7 +36,7 @@ export type LocatorConfig = { experienceKey?: string; entityType?: string; // deprecated savedFilter?: string; // deprecated - entityTypeScopes?: EntityTypeScope[]; + entityTypeScope?: EntityTypeScope[]; [key: string]: any; // allow any other fields }; diff --git a/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts b/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts index e15ee3a04..a443d3b29 100644 --- a/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts +++ b/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts @@ -4851,7 +4851,7 @@ const directoryDefaultLayout = { const locatorDefaultLayout = { root: { props: { - version: 63, + version: 66, title: { field: "", constantValue: { defaultValue: "Find Locations" }, @@ -4874,6 +4874,7 @@ const locatorDefaultLayout = { id: "Locator-2ae506f4-a3ee-46ea-b5f9-e4c3236243a7", mapStyle: "mapbox://styles/mapbox/streets-v12", filters: { openNowButton: false, showDistanceOptions: false }, + distanceDisplay: "distanceFromUser", resultCard: { primaryHeading: { field: { selection: { value: "name" } },