diff --git a/package-lock.json b/package-lock.json index 96f159d0661..98fccf8913d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59636,7 +59636,7 @@ "packages/samples/headless-commerce-ssr-remix": { "name": "@coveo/headless-commerce-ssr-remix-samples", "dependencies": { - "@coveo/headless-react": "2.2.0", + "@coveo/headless-react": "2.4.0", "@remix-run/node": "2.15.2", "@remix-run/react": "2.15.2", "@remix-run/serve": "2.15.2", @@ -59683,28 +59683,6 @@ "pino-pretty": "^6.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0" } }, - "packages/samples/headless-commerce-ssr-remix/node_modules/@coveo/headless-react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@coveo/headless-react/-/headless-react-2.2.0.tgz", - "integrity": "sha512-d3I3zKoIQKtsjSU35LZzxeCI9Z8vZpNoshHuXM1MsNkgglSYcz/eN4b42AAX9OW+jL6Y7ty6/TWbn+Sjz61DfA==", - "license": "Apache-2.0", - "dependencies": { - "@coveo/headless": "3.11.0" - }, - "engines": { - "node": "^20.9.0 || ^22.11.0" - }, - "optionalDependencies": { - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0" - }, - "peerDependencies": { - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", - "react": "^18", - "react-dom": "^18" - } - }, "packages/samples/headless-commerce-ssr-remix/node_modules/@coveo/headless/node_modules/@coveo/relay-event-types": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@coveo/relay-event-types/-/relay-event-types-12.0.1.tgz", diff --git a/packages/headless/src/app/commerce-ssr-engine/types/kind.ts b/packages/headless/src/app/commerce-ssr-engine/types/kind.ts index 74c4ba9eb2e..6b70dd684e4 100644 --- a/packages/headless/src/app/commerce-ssr-engine/types/kind.ts +++ b/packages/headless/src/app/commerce-ssr-engine/types/kind.ts @@ -4,3 +4,19 @@ export enum Kind { ParameterManager = 'PARAMETER_MANAGER', Recommendations = 'RECOMMENDATIONS', } + +export function createControllerWithKind( + controller: TController, + kind: TKind +): TController & {_kind: TKind} { + const copy = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(controller) + ); + + Object.defineProperty(copy, '_kind', { + value: kind, + }); + + return copy as TController & {_kind: TKind}; +} diff --git a/packages/headless/src/controllers/commerce/context/cart/headless-cart.ssr.ts b/packages/headless/src/controllers/commerce/context/cart/headless-cart.ssr.ts index 8e17fb57e13..e65140c7739 100644 --- a/packages/headless/src/controllers/commerce/context/cart/headless-cart.ssr.ts +++ b/packages/headless/src/controllers/commerce/context/cart/headless-cart.ssr.ts @@ -1,5 +1,8 @@ import {UniversalControllerDefinitionWithProps} from '../../../../app/commerce-ssr-engine/types/common.js'; -import {Kind} from '../../../../app/commerce-ssr-engine/types/kind.js'; +import { + createControllerWithKind, + Kind, +} from '../../../../app/commerce-ssr-engine/types/kind.js'; import {Cart, buildCart, CartInitialState} from './headless-cart.js'; export type {CartState, CartItem, CartProps} from './headless-cart.js'; @@ -27,10 +30,8 @@ export function defineCart(): CartDefinition { standalone: true, recommendation: true, buildWithProps: (engine, props) => { - return { - ...buildCart(engine, {initialState: props.initialState}), - _kind: Kind.Cart, - }; + const controller = buildCart(engine, {initialState: props.initialState}); + return createControllerWithKind(controller, Kind.Cart); }, }; } diff --git a/packages/headless/src/controllers/commerce/context/headless-context.ssr.ts b/packages/headless/src/controllers/commerce/context/headless-context.ssr.ts index 269167b28e3..589b9b61750 100644 --- a/packages/headless/src/controllers/commerce/context/headless-context.ssr.ts +++ b/packages/headless/src/controllers/commerce/context/headless-context.ssr.ts @@ -1,5 +1,8 @@ import {UniversalControllerDefinitionWithProps} from '../../../app/commerce-ssr-engine/types/common.js'; -import {Kind} from '../../../app/commerce-ssr-engine/types/kind.js'; +import { + createControllerWithKind, + Kind, +} from '../../../app/commerce-ssr-engine/types/kind.js'; import { Context, buildContext, @@ -29,10 +32,8 @@ export function defineContext(): ContextDefinition { standalone: true, recommendation: true, buildWithProps: (engine, props) => { - return { - ...buildContext(engine, {options: props}), - _kind: Kind.Context, - }; + const controller = buildContext(engine, {options: props}); + return createControllerWithKind(controller, Kind.Context); }, }; } diff --git a/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts b/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts index 4d757539fe1..42efaceb758 100644 --- a/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/parameter-manager/headless-core-parameter-manager.ssr.ts @@ -4,6 +4,10 @@ import { SolutionType, SubControllerDefinitionWithProps, } from '../../../../app/commerce-ssr-engine/types/common.js'; +import { + createControllerWithKind, + Kind, +} from '../../../../app/commerce-ssr-engine/types/kind.js'; import {CoreEngineNext} from '../../../../app/engine.js'; import {commerceFacetSetReducer as commerceFacetSet} from '../../../../features/commerce/facets/facet-set/facet-set-slice.js'; import {manualNumericFacetReducer as manualNumericFacetSet} from '../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-slice.js'; @@ -55,18 +59,23 @@ export function defineParameterManager< if (!loadCommerceProductListingParameterReducers(engine)) { throw loadReducerError; } - return buildProductListing(engine).parameterManager({ + const controller = buildProductListing(engine).parameterManager({ ...props, excludeDefaultParameters: true, }); + + return createControllerWithKind(controller, Kind.ParameterManager); } else { if (!loadCommerceSearchParameterReducers(engine)) { throw loadReducerError; } - return buildSearch(engine).parameterManager({ + + const controller = buildSearch(engine).parameterManager({ ...props, excludeDefaultParameters: true, }); + + return createControllerWithKind(controller, Kind.ParameterManager); } }, } as SubControllerDefinitionWithProps< diff --git a/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts b/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts index 7f7dcb9472e..b19f83d16f5 100644 --- a/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts +++ b/packages/headless/src/controllers/commerce/recommendations/headless-recommendations.ssr.ts @@ -2,7 +2,10 @@ import { recommendationInternalOptionKey, RecommendationOnlyControllerDefinitionWithProps, } from '../../../app/commerce-ssr-engine/types/common.js'; -import {Kind} from '../../../app/commerce-ssr-engine/types/kind.js'; +import { + createControllerWithKind, + Kind, +} from '../../../app/commerce-ssr-engine/types/kind.js'; import { RecommendationsOptions, RecommendationsState, @@ -52,16 +55,7 @@ export function defineRecommendations( const controller = buildRecommendations(engine, { options: {...staticOptions, ...options}, }); - const copy = Object.defineProperties( - {}, - Object.getOwnPropertyDescriptors(controller) - ); - - Object.defineProperty(copy, '_kind', { - value: Kind.Recommendations, - }); - - return copy as typeof controller & {_kind: Kind.Recommendations}; + return createControllerWithKind(controller, Kind.Recommendations); }, }; } diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/breadcrumb-manager.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/breadcrumb-manager.tsx new file mode 100644 index 00000000000..5cdc64bd831 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/breadcrumb-manager.tsx @@ -0,0 +1,71 @@ +import {useBreadcrumbManager} from '@/lib/commerce-engine'; +import { + NumericFacetValue, + DateFacetValue, + CategoryFacetValue, + RegularFacetValue, + LocationFacetValue, +} from '@coveo/headless-react/ssr-commerce'; + +export default function BreadcrumbManager() { + const {state, methods} = useBreadcrumbManager(); + + const renderBreadcrumbValue = ( + value: + | CategoryFacetValue + | RegularFacetValue + | NumericFacetValue + | DateFacetValue + | LocationFacetValue, + type: string + ) => { + switch (type) { + case 'hierarchical': + return (value as CategoryFacetValue).path.join(' > '); + case 'regular': + return (value as RegularFacetValue).value; + case 'numericalRange': + return ( + (value as NumericFacetValue).start + + ' - ' + + (value as NumericFacetValue).end + ); + case 'dateRange': + return ( + (value as DateFacetValue).start + + ' - ' + + (value as DateFacetValue).end + ); + default: + // TODO COMHUB-291 support location breadcrumb + return null; + } + }; + + return ( +
+
+ +
+ +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/context-dropdown.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/context-dropdown.tsx new file mode 100644 index 00000000000..b81375cf06b --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/context-dropdown.tsx @@ -0,0 +1,76 @@ +import externalContextService from '@/external-services/external-context-service'; +import {useContext, useEngine} from '@/lib/commerce-engine'; +import { + CommerceEngine, + ContextOptions, + loadProductListingActions, + loadSearchActions, +} from '@coveo/headless-react/ssr-commerce'; +import {LoaderFunctionArgs} from '@remix-run/node'; +import {Form, useFetcher, useLoaderData} from '@remix-run/react'; +import {useState} from 'react'; + +export const loader = async ({}: LoaderFunctionArgs) => { + const contextInfo = await externalContextService.getContextInformation(); + return contextInfo; +}; + +export default function ContextDropdown({ + useCase, +}: { + useCase?: 'listing' | 'search'; +}) { + const {state, methods} = useContext(); + const engine = useEngine(); + const fetcher = useFetcher(); + const serverContext = useLoaderData(); + const [, setContext] = useState<{ + language: string; + country: string; + currency: string; + }>(serverContext); + + const handleChange = async (e: React.ChangeEvent) => { + const [language, country, currency] = e.target.value.split('-'); + const newContext = {language, country, currency}; + setContext(newContext); + methods?.setLanguage(language); + methods?.setCountry(country); + methods?.setCurrency(currency as ContextOptions['currency']); + + fetcher.submit( + {language, country, currency}, + {method: 'post', action: '/context/update'} + ); + + if (useCase === 'search') { + engine?.dispatch( + loadSearchActions(engine as CommerceEngine).executeSearch() + ); + } else if (useCase === 'listing') { + engine?.dispatch( + loadProductListingActions( + engine as CommerceEngine + ).fetchProductListing() + ); + } + }; + + return ( +
+ Context dropdown: +
+ +
+
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/did-you-mean.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/did-you-mean.tsx new file mode 100644 index 00000000000..5c8eae17cb3 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/did-you-mean.tsx @@ -0,0 +1,34 @@ +import {useDidYouMean} from '@/lib/commerce-engine'; + +export default function DidYouMean() { + const {state, methods} = useDidYouMean(); + + if (!state.hasQueryCorrection) { + return null; + } + + if (state.wasAutomaticallyCorrected) { + return ( +
+

+ No results for {state.originalQuery} +

+

+ Query was automatically corrected to {state.wasCorrectedTo} +

+
+ ); + } + + return ( +
+

+ Search for + methods?.applyCorrection()}> + {state.queryCorrection.correctedQuery} + + instead? +

+
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/facets/category-facet.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/facets/category-facet.tsx new file mode 100644 index 00000000000..01e6e32aede --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/facets/category-facet.tsx @@ -0,0 +1,299 @@ +import { + CategoryFacetSearchResult, + CategoryFacetState, + CategoryFacetValue, + CategoryFacet as HeadlessCategoryFacet, +} from '@coveo/headless-react/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +export default function CategoryFacet({ + controller, + staticState, +}: { + controller?: HeadlessCategoryFacet; + staticState: CategoryFacetState; +}) { + const [state, setState] = useState(staticState); + const [showFacetSearchResults, setShowFacetSearchResults] = useState(false); + + const facetSearchInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const focusFacetSearchInput = (): void => { + facetSearchInputRef.current?.focus(); + }; + + const onChangeFacetSearchInput = ( + e: React.ChangeEvent + ): void => { + if (e.target.value === '') { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + return; + } + + controller?.facetSearch.updateText(e.target.value); + controller?.facetSearch.search(); + setShowFacetSearchResults(true); + }; + + const onClickClearFacetSearch = (): void => { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + focusFacetSearchInput(); + }; + + const highlightFacetSearchResult = (displayValue: string): string => { + const query = state.facetSearch.query; + const regex = new RegExp(query, 'gi'); + return displayValue.replace(regex, (match) => `${match}`); + }; + + const onClickFacetSearchResult = (value: CategoryFacetSearchResult): void => { + controller?.facetSearch.select(value); + controller?.facetSearch.clear(); + setShowFacetSearchResults(false); + focusFacetSearchInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusFacetSearchInput(); + }; + + const toggleSelectFacetValue = (value: CategoryFacetValue) => { + if (controller?.isValueSelected(value)) { + controller.deselectAll(); + } + controller?.toggleSelect(value); + }; + + const renderFacetSearchControls = () => { + return ( + + + + + {state.facetSearch.isLoading && ( + + {' '} + Facet search is loading... + + )} + + ); + }; + + const renderFacetSearchResults = () => { + return state.facetSearch.values.length === 0 ? ( + + No results for {state.facetSearch.query} + + ) : ( +
    + {state.facetSearch.values.map((value) => ( +
  • onClickFacetSearchResult(value)} + style={{width: 'fit-content'}} + > + + + + {' '} + ({value.count}) + +
  • + ))} +
+ ); + }; + + const renderActiveFacetValueTree = () => { + if (!state.hasActiveValues) { + return null; + } + + const ancestry = state.selectedValueAncestry!; + const activeValueChildren = ancestry[ancestry.length - 1]?.children ?? []; + + return ( +
    + {ancestry.map((ancestryValue) => { + const checkboxId = `ancestryFacetValueCheckbox-${ancestryValue.value}`; + return ( +
  • + toggleSelectFacetValue(ancestryValue)} + type="checkbox" + > + +
  • + ); + })} + {activeValueChildren.length > 0 && ( +
      + {activeValueChildren.map((child) => { + const checkboxId = `facetValueChildCheckbox-${child.value}`; + return ( +
    • + toggleSelectFacetValue(child)} + > + +
    • + ); + })} +
    + )} +
+ ); + }; + + const renderRootValues = () => { + if (state.hasActiveValues) { + return null; + } + + return ( +
    + {state.values.map((root) => { + return ( +
  • + toggleSelectFacetValue(root)} + > + + + {' '} + ({root.numberOfResults}) + +
  • + ); + })} +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && ( + Facet is loading... + )} + {renderRootValues()} + {renderActiveFacetValueTree()} + + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderFacetSearchControls()} + {showFacetSearchResults + ? renderFacetSearchResults() + : renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/facets/date-facet.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/facets/date-facet.tsx new file mode 100644 index 00000000000..f430bbe04b4 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/facets/date-facet.tsx @@ -0,0 +1,83 @@ +import { + DateFacetState, + DateFacet as HeadlessDateFacet, +} from '@coveo/headless-react/ssr-commerce'; +import {useEffect, useState} from 'react'; + +export default function DateFacet({ + controller, + staticState, +}: { + controller?: HeadlessDateFacet; + staticState: DateFacetState; +}) { + const [state, setState] = useState(staticState); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const renderFacetValues = () => { + return ( +
    + {state.values.map((value) => { + const id = `${value.start}-${value.end}-${value.endInclusive}`; + return ( +
  • + controller?.toggleSelect(value)} + type="checkbox" + > + +
  • + ); + })} +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + + {state.isLoading && ( + Facet is loading... + )} + {renderFacetValues()} + + +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/facets/facet-generator.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/facets/facet-generator.tsx new file mode 100644 index 00000000000..963583669d1 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/facets/facet-generator.tsx @@ -0,0 +1,62 @@ +import {useFacetGenerator} from '@/lib/commerce-engine'; +import CategoryFacet from './category-facet'; +import DateFacet from './date-facet'; +import NumericFacet from './numeric-facet'; +import RegularFacet from './regular-facet'; + +export default function FacetGenerator() { + const {state, methods} = useFacetGenerator(); + + return ( + + ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/facets/numeric-facet.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/facets/numeric-facet.tsx new file mode 100644 index 00000000000..d95f251eadd --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/facets/numeric-facet.tsx @@ -0,0 +1,208 @@ +import { + NumericFacet as HeadlessNumericFacet, + NumericFacetState, +} from '@coveo/headless-react/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +export default function NumericFacet({ + controller, + staticState, +}: { + controller?: HeadlessNumericFacet; + staticState: NumericFacetState; +}) { + const [state, setState] = useState(staticState); + const [currentManualRange, setCurrentManualRange] = useState({ + start: + controller?.state.manualRange?.start ?? + controller?.state.domain?.min ?? + controller?.state.values[0]?.start ?? + 0, + end: + controller?.state.manualRange?.end ?? + controller?.state.domain?.max ?? + controller?.state.values[0]?.end ?? + 0, + }); + + const manualRangeStartInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => { + setState(controller.state), + setCurrentManualRange({ + start: + controller.state.manualRange?.start ?? + controller.state.domain?.min ?? + controller.state.values[0]?.start ?? + 0, + end: + controller.state.manualRange?.end ?? + controller.state.domain?.max ?? + controller.state.values[0]?.end ?? + 0, + }); + }); + }, [controller]); + + const focusManualRangeStartInput = (): void => { + manualRangeStartInputRef.current?.focus(); + }; + + const invalidRange = + currentManualRange.start >= currentManualRange.end || + isNaN(currentManualRange.start) || + isNaN(currentManualRange.end); + + const onChangeManualRangeStart = (e: React.ChangeEvent) => { + setCurrentManualRange({ + start: Number.parseInt(e.target.value), + end: currentManualRange.end, + }); + }; + + const onChangeManualRangeEnd = (e: React.ChangeEvent) => { + setCurrentManualRange({ + start: currentManualRange.start, + end: Number.parseInt(e.target.value), + }); + }; + + const onClickManualRangeSelect = () => { + const start = + state.domain && currentManualRange.start < state.domain.min + ? state.domain.min + : currentManualRange.start; + const end = + state.domain && currentManualRange.end > state.domain.max + ? state.domain.max + : currentManualRange.end; + controller?.setRanges([ + { + start, + end, + endInclusive: true, + state: 'selected', + }, + ]); + focusManualRangeStartInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusManualRangeStartInput(); + }; + + const renderManualRangeControls = () => { + return ( +
+ + + + + +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && Facet is loading...} +
    + {state.values.map((value, index) => { + const checkboxId = `${value.start}-${value.end}-${value.endInclusive}`; + return ( +
  • + controller?.toggleSelect(value)} + type="checkbox" + > + +
  • + ); + })} +
+ + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderManualRangeControls()} + {renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/facets/regular-facet.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/facets/regular-facet.tsx new file mode 100644 index 00000000000..1f5c8fde57c --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/facets/regular-facet.tsx @@ -0,0 +1,217 @@ +import { + BaseFacetSearchResult, + RegularFacet as HeadlessRegularFacet, + RegularFacetState, + RegularFacetValue, +} from '@coveo/headless-react/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +export default function RegularFacet({ + controller, + staticState, +}: { + controller?: HeadlessRegularFacet; + staticState: RegularFacetState; +}) { + const [state, setState] = useState(staticState); + const [showFacetSearchResults, setShowFacetSearchResults] = useState(false); + + const facetSearchInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const focusFacetSearchInput = (): void => { + facetSearchInputRef.current?.focus(); + }; + + const onChangeFacetSearchInput = ( + e: React.ChangeEvent + ): void => { + if (e.target.value === '') { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + return; + } + + controller?.facetSearch.updateText(e.target.value); + controller?.facetSearch.search(); + setShowFacetSearchResults(true); + }; + + const highlightFacetSearchResult = (displayValue: string): string => { + const query = state.facetSearch.query; + const regex = new RegExp(query, 'gi'); + return displayValue.replace(regex, (match) => `${match}`); + }; + + const onClickFacetSearchResult = (value: BaseFacetSearchResult): void => { + controller?.facetSearch.select(value); + controller?.facetSearch.clear(); + setShowFacetSearchResults(false); + focusFacetSearchInput(); + }; + + const onClickFacetSearchClear = (): void => { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + focusFacetSearchInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusFacetSearchInput(); + }; + + const onChangeFacetValue = (facetValue: RegularFacetValue): void => { + controller?.toggleSelect(facetValue); + focusFacetSearchInput(); + }; + + const renderFacetSearchControls = () => { + return ( + + + + + {state.facetSearch.isLoading && ( + + {' '} + Facet search is loading... + + )} + + ); + }; + + const renderFacetSearchResults = () => { + return state.facetSearch.values.length === 0 ? ( + + No results for {state.facetSearch.query} + + ) : ( +
    + {state.facetSearch.values.map((value) => ( +
  • onClickFacetSearchResult(value)} + style={{width: 'fit-content'}} + > + + + + {' '} + ({value.count}) + +
  • + ))} +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && ( + Facet is loading... + )} +
    + {state.values.map((value) => ( +
  • + onChangeFacetValue(value)} + type="checkbox" + > + +
  • + ))} +
+ + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderFacetSearchControls()} + {showFacetSearchResults + ? renderFacetSearchResults() + : renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/instant-product.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/instant-product.tsx new file mode 100644 index 00000000000..d3af0f776a3 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/instant-product.tsx @@ -0,0 +1,39 @@ +import {useInstantProducts} from '@/lib/commerce-engine'; +import {Product} from '@coveo/headless-react/ssr-commerce'; +import {useNavigate} from '@remix-run/react'; +import AddToCartButton from './add-to-cart-button'; + +export default function InstantProducts() { + const navigate = useNavigate(); + + const {state, methods} = useInstantProducts(); + + const clickProduct = (product: Product) => { + methods?.interactiveProduct({options: {product}}).select(); + navigate( + `/products/${product.ec_product_id}?name=${product.ec_name}&price=${product.ec_price}` + ); + }; + + return ( +
    + Instant Products : + {state.products.map((product, index) => ( +
  • + + {product.ec_product_id && + product.ec_price !== null && + product.ec_name && ( + + )} +
  • + ))} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/pagination.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/pagination.tsx new file mode 100644 index 00000000000..14bf0413fe4 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/pagination.tsx @@ -0,0 +1,46 @@ +import {usePagination} from '@/lib/commerce-engine'; + +export default function Pagination() { + const {state, methods} = usePagination(); + + const renderPageRadioButtons = () => { + return Array.from({length: state.totalPages}, (_, i) => { + const page = i + 1; + return ( + + ); + }); + }; + + return ( +
+
+ Page {state.page + 1} of {state.totalPages} +
+ + {renderPageRadioButtons()} + +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/parameter-manager.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/parameter-manager.tsx new file mode 100644 index 00000000000..97dc04533c4 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/parameter-manager.tsx @@ -0,0 +1,56 @@ +import {useParameterManager} from '@/lib/commerce-engine'; +import {buildParameterSerializer} from '@coveo/headless-react/ssr-commerce'; +import {useSearchParams} from '@remix-run/react'; +import {useEffect, useMemo, useRef} from 'react'; + +export default function ParameterManager({url}: {url: string | null}) { + const {state, methods} = useParameterManager(); + + const {serialize, deserialize} = buildParameterSerializer(); + + const initialUrl = useMemo(() => new URL(url ?? ''), [url]); + const previousUrl = useRef(initialUrl.href); + const [searchParams] = useSearchParams(); + + /** + * When the URL fragment changes, this effect deserializes it and synchronizes it into the + * ParameterManager controller's state. + */ + useEffect(() => { + if (methods === undefined) { + return; + } + + const newCommerceParams = deserialize(searchParams); + + const newUrl = serialize(newCommerceParams, new URL(previousUrl.current)); + + if (newUrl === previousUrl.current || newUrl === initialUrl.href) { + return; + } + + previousUrl.current = newUrl; + methods.synchronize(newCommerceParams); + }, [searchParams]); + + /** + * When the ParameterManager controller's state changes, this effect serializes it into the URL fragment and pushes the new URL + * to the browser history. + * */ + useEffect(() => { + if (methods === undefined) { + return; + } + + const newUrl = serialize(state.parameters, new URL(previousUrl.current)); + + if (previousUrl.current === newUrl || newUrl === initialUrl.href) { + return; + } + + previousUrl.current = newUrl; + history.pushState(null, '', newUrl); + }, [state.parameters]); + + return null; +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/product-view.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/product-view.tsx index b01284f39a4..3d6057841e9 100644 --- a/packages/samples/headless-commerce-ssr-remix/app/components/product-view.tsx +++ b/packages/samples/headless-commerce-ssr-remix/app/components/product-view.tsx @@ -2,7 +2,7 @@ import {ExternalCartItem} from '@/external-services/external-cart-service'; import {ExternalCatalogItem} from '@/external-services/external-catalog-service'; import {useProductView} from '@/lib/commerce-engine'; import {formatCurrency} from '@/utils/format-currency'; -import {useEffect, useRef} from 'react'; +import {useEffect} from 'react'; import AddToCartButton from './add-to-cart-button'; import RemoveFromCartButton from './remove-from-cart-button'; @@ -25,12 +25,12 @@ export default function ProductView({ uniqueId: productId, } = catalogItem; - const viewed = useRef(false); + let viewed = false; useEffect(() => { - if (methods && !viewed.current) { - viewed.current = true; + if (methods && !viewed) { methods.view({name, price, productId}); + viewed = true; } }, []); diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/providers/providers.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/providers/providers.tsx index c7e7b248c68..506f5de703d 100644 --- a/packages/samples/headless-commerce-ssr-remix/app/components/providers/providers.tsx +++ b/packages/samples/headless-commerce-ssr-remix/app/components/providers/providers.tsx @@ -1,6 +1,6 @@ import { listingEngineDefinition, - //recommendationEngineDefinition, + recommendationEngineDefinition, searchEngineDefinition, standaloneEngineDefinition, } from '@/lib/commerce-engine'; @@ -17,9 +17,9 @@ export const SearchProvider = buildProviderWithDefinition( ); // Wraps recommendations, whether in a standalone, search, or listing page -// export const RecommendationProvider = buildProviderWithDefinition( -// recommendationEngineDefinition -// ); +export const RecommendationProvider = buildProviderWithDefinition( + recommendationEngineDefinition +); // Used for components that don’t require triggering a search or product fetch (e.g., cart pages, standalone search box) export const StandaloneProvider = buildProviderWithDefinition( diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/recent-queries.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/recent-queries.tsx new file mode 100644 index 00000000000..8a5e9585cdc --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/recent-queries.tsx @@ -0,0 +1,24 @@ +import {useInstantProducts, useRecentQueriesList} from '@/lib/commerce-engine'; + +export default function RecentQueries() { + const {state, methods} = useRecentQueriesList(); + const {methods: instantProductsController} = useInstantProducts(); + + return ( +
+
    + Recent queries: + {state.queries.map((query, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/recommendations/popular-recommendations.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/recommendations/popular-recommendations.tsx new file mode 100644 index 00000000000..66ef06f1446 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/recommendations/popular-recommendations.tsx @@ -0,0 +1,48 @@ +import { + usePopularBoughtRecs, + usePopularViewedRecs, +} from '@/lib/commerce-engine'; +import {Product} from '@coveo/headless-react/ssr-commerce'; +import {useNavigate} from '@remix-run/react'; + +type RecommendationType = 'bought' | 'viewed'; + +interface PopularRecommendationsProps { + type: RecommendationType; +} + +export default function PopularRecommendations({ + type, +}: PopularRecommendationsProps) { + const {state, methods} = + type === 'bought' ? usePopularBoughtRecs() : usePopularViewedRecs(); + const navigate = useNavigate(); + + const onProductClick = (product: Product) => { + methods?.interactiveProduct({options: {product}}).select(); + navigate( + `/products/${product.ec_product_id}?name=${product.ec_name}&price=${product.ec_price}` + ); + }; + + return ( + <> +
    +

    {state.headline}

    + {state.products.map((product: Product) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/search-box.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/search-box.tsx new file mode 100644 index 00000000000..826b229d90a --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/search-box.tsx @@ -0,0 +1,95 @@ +import { + useInstantProducts, + useRecentQueriesList, + useSearchBox, +} from '@/lib/commerce-engine'; +import {useState} from 'react'; +import InstantProducts from './instant-product'; +import RecentQueries from './recent-queries'; + +export default function SearchBox() { + const {state, methods} = useSearchBox(); + const {state: recentQueriesState} = useRecentQueriesList(); + const {state: instantProductsState, methods: instantProductsController} = + useInstantProducts(); + + const [isInputFocused, setIsInputFocused] = useState(false); + const [isSelectingSuggestion, setIsSelectingSuggestion] = useState(false); + + const onSearchBoxInputChange = (e: React.ChangeEvent) => { + setIsSelectingSuggestion(true); + methods?.updateText(e.target.value); + instantProductsController?.updateQuery(e.target.value); + }; + + const handleFocus = () => { + setIsInputFocused(true); + }; + + const handleBlur = () => { + if (!isSelectingSuggestion) { + setIsInputFocused(false); + } + }; + + return ( +
+ { + if (e.key === 'Enter') { + methods?.submit(); + } + }} + onChange={(e) => onSearchBoxInputChange(e)} + onFocus={handleFocus} + onBlur={handleBlur} + > + {state.value !== '' && ( + + + + )} + + + {isInputFocused && ( + <> + {recentQueriesState.queries.length > 0 && } + {state.suggestions.length > 0 && ( +
    + Suggestions : + {state.suggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+ )} + {instantProductsState.products.length > 0 && } + + )} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/show-more.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/show-more.tsx new file mode 100644 index 00000000000..8cdc9e93035 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/show-more.tsx @@ -0,0 +1,33 @@ +import {usePagination, useSummary} from '@/lib/commerce-engine'; + +export default function ShowMore() { + const {state, methods} = usePagination(); + const {state: summaryState} = useSummary(); + + const handleFetchMore = () => { + methods?.fetchMoreProducts(); + }; + + const isDisabled = () => { + return ( + !methods || + summaryState?.lastProduct === summaryState?.totalNumberOfProducts + ); + }; + + return ( + <> +
+ Displaying {summaryState?.lastProduct ?? state.pageSize} out of{' '} + {state.totalEntries} products +
+ + + ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/sort.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/sort.tsx new file mode 100644 index 00000000000..f0f7b7581c9 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/sort.tsx @@ -0,0 +1,48 @@ +import {useSort} from '@/lib/commerce-engine'; +import {SortBy, SortCriterion} from '@coveo/headless-react/ssr-commerce'; + +export default function Sort() { + const {state, methods} = useSort(); + + if (state.availableSorts.length === 0) { + return null; + } + + const formatSortFieldLabel = (field: { + name: string; + direction?: string; + displayName?: string; + }) => field?.displayName ?? `${field.name} ${field.direction ?? ''}`.trim(); + + const getSortLabel = (criterion: SortCriterion) => { + switch (criterion.by) { + case SortBy.Relevance: + return 'Relevance'; + case SortBy.Fields: + return criterion.fields.map(formatSortFieldLabel); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/standalone-search-box.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/standalone-search-box.tsx new file mode 100644 index 00000000000..9ea559c240b --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/standalone-search-box.tsx @@ -0,0 +1,94 @@ +import { + useInstantProducts, + useRecentQueriesList, + useStandaloneSearchBox, +} from '@/lib/commerce-engine'; +import {useNavigate} from '@remix-run/react'; +import {useEffect, useState} from 'react'; +import InstantProducts from './instant-product'; +import RecentQueries from './recent-queries'; + +export default function StandaloneSearchBox() { + const {state, methods} = useStandaloneSearchBox(); + const {state: recentQueriesState} = useRecentQueriesList(); + const {state: instantProductsState, methods: instantProductsController} = + useInstantProducts(); + + const [isInputFocused, setIsInputFocused] = useState(false); + const [isSelectingSuggestion, setIsSelectingSuggestion] = useState(false); + + const navigate = useNavigate(); + + useEffect(() => { + if (state.redirectTo === '/search') { + const url = `${state.redirectTo}?q=${encodeURIComponent(state.value)}`; + navigate(url, {preventScrollReset: true}); + methods?.afterRedirection(); + } + }, [state.redirectTo, state.value, navigate, methods]); + + const onSearchBoxInputChange = (e: React.ChangeEvent) => { + setIsSelectingSuggestion(true); + methods?.updateText(e.target.value); + instantProductsController?.updateQuery(e.target.value); + }; + + const handleFocus = () => { + setIsInputFocused(true); + }; + + const handleBlur = () => { + if (!isSelectingSuggestion) { + setIsInputFocused(false); + } + }; + + return ( +
+ onSearchBoxInputChange(e)} + onFocus={handleFocus} + onBlur={handleBlur} + > + {state.value !== '' && ( + + + + )} + + + {isInputFocused && ( + <> + {recentQueriesState.queries.length > 0 && } + {state.suggestions.length > 0 && ( +
    + Suggestions : + {state.suggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+ )} + {instantProductsState.products.length > 0 && } + + )} +
+ ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/summary.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/summary.tsx new file mode 100644 index 00000000000..fe5fd1c2401 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/summary.tsx @@ -0,0 +1,39 @@ +import {useSummary} from '@/lib/commerce-engine'; + +export default function Summary() { + const {state} = useSummary(); + + const renderBaseSummary = () => { + const {firstProduct, lastProduct, totalNumberOfProducts} = state; + return ( + + Showing results {firstProduct} - {lastProduct} of{' '} + {totalNumberOfProducts} + + ); + }; + + const renderQuerySummary = () => { + if (!('query' in state) || state.query.trim() === '') { + return null; + } + + return ( + + {' '} + for {state.query} + + ); + }; + + const renderSummary = () => { + return ( +

+ {renderBaseSummary()} + {renderQuerySummary()} +

+ ); + }; + + return
{renderSummary()}
; +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/triggers/notify-trigger.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/notify-trigger.tsx new file mode 100644 index 00000000000..24b8a2e66d2 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/notify-trigger.tsx @@ -0,0 +1,19 @@ +import {useNotifyTrigger} from '@/lib/commerce-engine'; +import {useCallback, useEffect} from 'react'; + +// The notify trigger query example in the searchuisamples org is 'notify me'. +export default function NotifyTrigger() { + const {state} = useNotifyTrigger(); + + const notify = useCallback(() => { + state.notifications.forEach((notification) => { + alert(`Notification: ${notification}`); + }); + }, [state.notifications]); + + useEffect(() => { + notify(); + }, [notify]); + + return null; +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/triggers/query-trigger.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/query-trigger.tsx new file mode 100644 index 00000000000..f4926b66768 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/query-trigger.tsx @@ -0,0 +1,16 @@ +import {useQueryTrigger} from '@/lib/commerce-engine'; + +// The query trigger query example in the searchuisamples org is 'query me'. +export default function QueryTrigger() { + const {state} = useQueryTrigger(); + + if (state.wasQueryModified) { + return ( +
+ The query changed from {state.originalQuery} to{' '} + {state.newQuery} +
+ ); + } + return null; +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/triggers/redirection-trigger.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/redirection-trigger.tsx new file mode 100644 index 00000000000..69f9b45e3a1 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/redirection-trigger.tsx @@ -0,0 +1,19 @@ +import {useRedirectionTrigger} from '@/lib/commerce-engine'; +import {useCallback, useEffect} from 'react'; + +// The redirection trigger query example in the searchuisamples org is 'redirect me'. +export default function RedirectionTrigger() { + const {state} = useRedirectionTrigger(); + + const redirect = useCallback(() => { + if (state.redirectTo) { + window.location.replace(state.redirectTo); + } + }, [state.redirectTo]); + + useEffect(() => { + redirect(); + }, [redirect]); + + return null; +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/components/triggers/triggers.tsx b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/triggers.tsx new file mode 100644 index 00000000000..6ac0b54befa --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/components/triggers/triggers.tsx @@ -0,0 +1,13 @@ +import NotifyTrigger from './notify-trigger'; +import QueryTrigger from './query-trigger'; +import RedirectionTrigger from './redirection-trigger'; + +export default function Triggers() { + return ( + <> + + + + + ); +} diff --git a/packages/samples/headless-commerce-ssr-remix/app/routes/cart.tsx b/packages/samples/headless-commerce-ssr-remix/app/routes/cart.tsx index 2259ef2f228..8c92a5534ff 100644 --- a/packages/samples/headless-commerce-ssr-remix/app/routes/cart.tsx +++ b/packages/samples/headless-commerce-ssr-remix/app/routes/cart.tsx @@ -1,8 +1,18 @@ +import Cart from '@/app/components/cart'; +import ContextDropdown from '@/app/components/context-dropdown'; +import { + RecommendationProvider, + StandaloneProvider, +} from '@/app/components/providers/providers'; +import PopularRecommendations from '@/app/components/recommendations/popular-recommendations'; +import StandaloneSearchBox from '@/app/components/standalone-search-box'; import externalCartService, { ExternalCartItem, } from '@/external-services/external-cart-service'; import externalContextService from '@/external-services/external-context-service'; import { + recommendationEngineDefinition, + RecommendationStaticState, standaloneEngineDefinition, StandaloneStaticState, } from '@/lib/commerce-engine'; @@ -14,8 +24,6 @@ import { import {NavigatorContext} from '@coveo/headless-react/ssr-commerce'; import {LoaderFunctionArgs} from '@remix-run/node'; import {useLoaderData} from '@remix-run/react'; -import Cart from '../components/cart'; -import {StandaloneProvider} from '../components/providers/providers'; export const loader = async ({request}: LoaderFunctionArgs) => { const navigatorContext = await getNavigatorContext(request); @@ -47,31 +55,71 @@ export const loader = async ({request}: LoaderFunctionArgs) => { }, }); - return {staticState, items, totalPrice, language, currency}; + const recsStaticState = await recommendationEngineDefinition.fetchStaticState( + { + controllers: { + popularViewedRecs: {enabled: true}, + popularBoughtRecs: {enabled: true}, + cart: { + initialState: { + items: toCoveoCartItems(items), + }, + }, + context: { + language, + country, + currency: toCoveoCurrency(currency), + view: { + url: 'https://sports.barca.group/cart', + }, + }, + }, + } + ); + + return {staticState, items, totalPrice, language, currency, recsStaticState}; }; export default function CartRoute() { - const {staticState, navigatorContext, items, totalPrice, language, currency} = - useLoaderData<{ - staticState: StandaloneStaticState; - navigatorContext: NavigatorContext; - items: ExternalCartItem[]; - totalPrice: number; - language: string; - currency: string; - }>(); + const { + staticState, + navigatorContext, + items, + totalPrice, + language, + currency, + recsStaticState, + } = useLoaderData<{ + staticState: StandaloneStaticState; + navigatorContext: NavigatorContext; + items: ExternalCartItem[]; + totalPrice: number; + language: string; + currency: string; + recsStaticState: RecommendationStaticState; + }>(); return (

Cart

- +
+ + + + + + +
); } diff --git a/packages/samples/headless-commerce-ssr-remix/app/routes/context.update.tsx b/packages/samples/headless-commerce-ssr-remix/app/routes/context.update.tsx new file mode 100644 index 00000000000..2888b80b723 --- /dev/null +++ b/packages/samples/headless-commerce-ssr-remix/app/routes/context.update.tsx @@ -0,0 +1,15 @@ +import externalContextService from '@/external-services/external-context-service'; +import {ActionFunctionArgs} from '@remix-run/node'; + +export const action = async ({request}: ActionFunctionArgs) => { + const formData = await request.formData(); + const newContext = { + language: formData.get('language')!.toString(), + country: formData.get('country')!.toString(), + currency: formData.get('currency')!.toString(), + }; + + await externalContextService.setContextInformation(newContext); + + return newContext; +}; diff --git a/packages/samples/headless-commerce-ssr-remix/app/routes/listings.$listingId.tsx b/packages/samples/headless-commerce-ssr-remix/app/routes/listings.$listingId.tsx index 2a0b65b14aa..3926548a706 100644 --- a/packages/samples/headless-commerce-ssr-remix/app/routes/listings.$listingId.tsx +++ b/packages/samples/headless-commerce-ssr-remix/app/routes/listings.$listingId.tsx @@ -1,20 +1,37 @@ +import BreadcrumbManager from '@/app/components/breadcrumb-manager'; +import ContextDropdown from '@/app/components/context-dropdown'; +import FacetGenerator from '@/app/components/facets/facet-generator'; +import Pagination from '@/app/components/pagination'; +import ProductList from '@/app/components/product-list'; +import { + ListingProvider, + RecommendationProvider, +} from '@/app/components/providers/providers'; +import PopularRecommendations from '@/app/components/recommendations/popular-recommendations'; +import Sort from '@/app/components/sort'; +import StandaloneSearchBox from '@/app/components/standalone-search-box'; +import Summary from '@/app/components/summary'; import externalCartService from '@/external-services/external-cart-service'; import externalContextService from '@/external-services/external-context-service'; import { listingEngineDefinition, ListingStaticState, + recommendationEngineDefinition, + RecommendationStaticState, } from '@/lib/commerce-engine'; import {getNavigatorContext} from '@/lib/navigator-context'; import { toCoveoCartItems, toCoveoCurrency, } from '@/utils/external-api-conversions'; -import {NavigatorContext} from '@coveo/headless-react/ssr-commerce'; +import { + buildParameterSerializer, + NavigatorContext, +} from '@coveo/headless-react/ssr-commerce'; import {LoaderFunctionArgs} from '@remix-run/node'; import {useLoaderData, useParams} from '@remix-run/react'; import invariant from 'tiny-invariant'; -import ProductList from '../components/product-list'; -import {ListingProvider} from '../components/providers/providers'; +import ParameterManager from '../components/parameter-manager'; import {coveo_visitorId} from '../cookies.server'; export const loader = async ({params, request}: LoaderFunctionArgs) => { @@ -22,16 +39,32 @@ export const loader = async ({params, request}: LoaderFunctionArgs) => { const navigatorContext = await getNavigatorContext(request); + const url = new URL(request.url); + + const {deserialize} = buildParameterSerializer(); + const parameters = deserialize(url.searchParams); + listingEngineDefinition.setNavigatorContextProvider(() => navigatorContext); + recommendationEngineDefinition.setNavigatorContextProvider( + () => navigatorContext + ); + const {country, currency, language} = await externalContextService.getContextInformation(); + const items = await externalCartService.getItems(); + const staticState = await listingEngineDefinition.fetchStaticState({ controllers: { cart: { initialState: { - items: toCoveoCartItems(await externalCartService.getItems()), + items: toCoveoCartItems(items), + }, + }, + parameterManager: { + initialState: { + parameters: parameters, }, }, context: { @@ -45,9 +78,32 @@ export const loader = async ({params, request}: LoaderFunctionArgs) => { }, }); + const recsStaticState = await recommendationEngineDefinition.fetchStaticState( + { + controllers: { + popularViewedRecs: {enabled: true}, + popularBoughtRecs: {enabled: true}, + cart: { + initialState: { + items: toCoveoCartItems(items), + }, + }, + context: { + language, + country, + currency: toCoveoCurrency(currency), + view: { + url: 'https://sports.barca.group/cart', + }, + }, + }, + } + ); + return { staticState, navigatorContext, + recsStaticState, headers: { 'Set-Cookie': await coveo_visitorId.serialize(navigatorContext.clientId), }, @@ -56,9 +112,10 @@ export const loader = async ({params, request}: LoaderFunctionArgs) => { export default function ListingRoute() { const params = useParams(); - const {staticState, navigatorContext} = useLoaderData<{ + const {staticState, navigatorContext, recsStaticState} = useLoaderData<{ staticState: ListingStaticState; navigatorContext: NavigatorContext; + recsStaticState: RecommendationStaticState; }>(); const getTitle = () => { @@ -75,8 +132,39 @@ export default function ListingRoute() { staticState={staticState} navigatorContext={navigatorContext} > +

{getTitle()}

- + +
+
+ +
+ +
+ + + + + + {/* The ShowMore and Pagination components showcase two frequent ways to implement pagination. */} + + {/* */} +
+ +
+ + + + +
+
); } diff --git a/packages/samples/headless-commerce-ssr-remix/app/routes/products.$productId.tsx b/packages/samples/headless-commerce-ssr-remix/app/routes/products.$productId.tsx index b0c20fcd631..e630ebb5cdc 100644 --- a/packages/samples/headless-commerce-ssr-remix/app/routes/products.$productId.tsx +++ b/packages/samples/headless-commerce-ssr-remix/app/routes/products.$productId.tsx @@ -1,7 +1,11 @@ +import ContextDropdown from '@/app/components/context-dropdown'; +import ProductView from '@/app/components/product-view'; +import {StandaloneProvider} from '@/app/components/providers/providers'; +import StandaloneSearchBox from '@/app/components/standalone-search-box'; import externalCartService, { ExternalCartItem, } from '@/external-services/external-cart-service'; -import externalCatalogService, { +import externalCatalogAPI, { ExternalCatalogItem, } from '@/external-services/external-catalog-service'; import externalContextService from '@/external-services/external-context-service'; @@ -18,15 +22,13 @@ import {NavigatorContext} from '@coveo/headless-react/ssr-commerce'; import {LoaderFunctionArgs} from '@remix-run/node'; import {useLoaderData} from '@remix-run/react'; import invariant from 'tiny-invariant'; -import ProductView from '../components/product-view'; -import {StandaloneProvider} from '../components/providers/providers'; export const loader = async ({params, request}: LoaderFunctionArgs) => { const productId = params.productId; invariant(productId, 'Missing productId parameter'); - const catalogItem = await externalCatalogService.getItem(request.url); + const catalogItem = await externalCatalogAPI.getItem(request.url); const {country, currency, language} = await externalContextService.getContextInformation(); @@ -90,6 +92,8 @@ export default function ProductRoute() { staticState={staticState} navigatorContext={navigatorContext} > + + { const navigatorContext = await getNavigatorContext(request); + const url = new URL(request.url); + + const {deserialize} = buildParameterSerializer(); + const parameters = deserialize(url.searchParams); + searchEngineDefinition.setNavigatorContextProvider(() => navigatorContext); const {country, currency, language} = @@ -27,6 +45,11 @@ export const loader = async ({request}: LoaderFunctionArgs) => { items: toCoveoCartItems(await externalCartService.getItems()), }, }, + parameterManager: { + initialState: { + parameters: parameters, + }, + }, context: { language, country, @@ -46,13 +69,36 @@ export default function SearchRoute() { staticState: SearchStaticState; navigatorContext: NavigatorContext; }>(); + return ( +

Search

- + +
+
+ +
+
+ + + + + + + + + {/* The ShowMore and Pagination components showcase two frequent ways to implement pagination. */} + {/* */} + +
+
); } diff --git a/packages/samples/headless-commerce-ssr-remix/external-services/external-context-service.ts b/packages/samples/headless-commerce-ssr-remix/external-services/external-context-service.ts index 5212a750e99..5012461e487 100644 --- a/packages/samples/headless-commerce-ssr-remix/external-services/external-context-service.ts +++ b/packages/samples/headless-commerce-ssr-remix/external-services/external-context-service.ts @@ -11,6 +11,29 @@ export type ExternalContextInformation = { language: string; }; +const contextOptions = [ + { + country: 'US', + currency: 'USD', + language: 'en', + }, + { + country: 'CA', + currency: 'CAD', + language: 'en', + }, + { + country: 'CA', + currency: 'CAD', + language: 'fr', + }, + { + country: 'GB', + currency: 'GBP', + language: 'en', + }, +]; + class ExternalContextService { private contextDB: {country: string; currency: string; language: string} = { country: 'US', @@ -37,6 +60,12 @@ class ExternalContextService { ): Promise { this.contextDB = localeInformation; } + + public getContextOptions(): string[] { + return contextOptions.map( + (option) => `${option.language}-${option.country}-${option.currency}` + ); + } } declare global { diff --git a/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine-config.ts b/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine-config.ts index 6f36e0e29d7..458334ce77f 100644 --- a/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine-config.ts +++ b/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine-config.ts @@ -1,6 +1,4 @@ import { - Controller, - ControllerDefinitionsMap, CommerceEngineDefinitionOptions, defineProductList, defineCart, @@ -20,13 +18,10 @@ import { defineProductView, getSampleCommerceEngineConfiguration, defineDidYouMean, - defineRecommendations, //defineParameterManager, + defineRecommendations, + defineParameterManager, } from '@coveo/headless-react/ssr-commerce'; -type CommerceEngineConfig = CommerceEngineDefinitionOptions< - ControllerDefinitionsMap ->; - export default { /** * By default, the logger level is set to 'warn'. This level may not provide enough information for some server-side @@ -64,8 +59,8 @@ export default { sort: defineSort(), productView: defineProductView(), didYouMean: defineDidYouMean(), - //parameterManager: defineParameterManager(), + parameterManager: defineParameterManager(), facetGenerator: defineFacetGenerator(), breadcrumbManager: defineBreadcrumbManager(), }, -} satisfies CommerceEngineConfig; +} satisfies CommerceEngineDefinitionOptions; diff --git a/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine.ts b/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine.ts index f4ed310ec98..67bca6b36c8 100644 --- a/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine.ts +++ b/packages/samples/headless-commerce-ssr-remix/lib/commerce-engine.ts @@ -10,6 +10,7 @@ export const engineDefinition = defineCommerceEngine(engineConfig); export const { listingEngineDefinition, searchEngineDefinition, + recommendationEngineDefinition, standaloneEngineDefinition, useEngine, } = engineDefinition; @@ -34,7 +35,7 @@ export const { useSummary, useFacetGenerator, useBreadcrumbManager, - //useParameterManager, + useParameterManager, } = engineDefinition.controllers; export type ListingStaticState = InferStaticState< @@ -55,3 +56,11 @@ export type StandaloneStaticState = InferStaticState< export type StandaloneHydratedState = InferHydratedState< typeof standaloneEngineDefinition >; + +export type RecommendationStaticState = InferStaticState< + typeof recommendationEngineDefinition +>; + +export type RecommendationHydratedState = InferHydratedState< + typeof recommendationEngineDefinition +>; diff --git a/packages/samples/headless-commerce-ssr-remix/package.json b/packages/samples/headless-commerce-ssr-remix/package.json index 9b2cb275485..d9ef9379e16 100644 --- a/packages/samples/headless-commerce-ssr-remix/package.json +++ b/packages/samples/headless-commerce-ssr-remix/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc" }, "dependencies": { - "@coveo/headless-react": "2.2.0", + "@coveo/headless-react": "2.4.0", "@remix-run/node": "2.15.2", "@remix-run/react": "2.15.2", "@remix-run/serve": "2.15.2", diff --git a/packages/samples/headless-ssr-commerce/components/facets/facet-generator.tsx b/packages/samples/headless-ssr-commerce/components/facets/facet-generator.tsx index 8f9ad286870..7dc334749d0 100644 --- a/packages/samples/headless-ssr-commerce/components/facets/facet-generator.tsx +++ b/packages/samples/headless-ssr-commerce/components/facets/facet-generator.tsx @@ -54,6 +54,7 @@ export default function FacetGenerator() { staticState={facetState} /> ); + //TODO: add location facet support https://coveord.atlassian.net/browse/KIT-3808 default: return null; } diff --git a/packages/samples/headless-ssr-commerce/components/standalone-search-box.tsx b/packages/samples/headless-ssr-commerce/components/standalone-search-box.tsx index 0c6fd973de2..b7446793b0e 100644 --- a/packages/samples/headless-ssr-commerce/components/standalone-search-box.tsx +++ b/packages/samples/headless-ssr-commerce/components/standalone-search-box.tsx @@ -23,7 +23,7 @@ export default function StandaloneSearchBox() { useEffect(() => { if (state.redirectTo === '/search') { - const url = `${state.redirectTo}#q=${encodeURIComponent(state.value)}`; + const url = `${state.redirectTo}?q=${encodeURIComponent(state.value)}`; router.push(url, {scroll: false}); methods?.afterRedirection(); } diff --git a/packages/samples/headless-ssr-commerce/e2e/listing/listing.spec.ts b/packages/samples/headless-ssr-commerce/e2e/listing/listing.spec.ts index 64bed901937..9a5607ccf72 100644 --- a/packages/samples/headless-ssr-commerce/e2e/listing/listing.spec.ts +++ b/packages/samples/headless-ssr-commerce/e2e/listing/listing.spec.ts @@ -33,7 +33,7 @@ test.describe('default', () => { }); test('should go to search page', async ({page}) => { - await page.waitForURL('**/search#q=*'); + await page.waitForURL('**/search?q=*'); const currentUrl = page.url(); @@ -47,7 +47,7 @@ test.describe('default', () => { }); test('should go to search page', async ({page}) => { - await page.waitForURL('**/search#q=*'); + await page.waitForURL('**/search?q=*'); const currentUrl = page.url(); expect(currentUrl).toContain('shoes'); });