@constructor-io/constructorio-ui-asa 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/lib/cjs/components/Chat/Chat.js +10 -1
  2. package/lib/cjs/components/Chat/ChatMessageList.js +1 -1
  3. package/lib/cjs/components/Chat/WelcomeScreen.js +3 -2
  4. package/lib/cjs/components/CioAsaProvider/CioAsaProvider.js +4 -2
  5. package/lib/cjs/components/ResultsBlock/ResultsBlock.js +13 -38
  6. package/lib/cjs/components/ResultsBlock/ResultsGroup.js +109 -0
  7. package/lib/cjs/hooks/asaStreamHandlers.js +3 -1
  8. package/lib/cjs/hooks/useAsaResults.js +78 -14
  9. package/lib/cjs/hooks/useAsaTracking.js +66 -0
  10. package/lib/cjs/hooks/useViewportTracking.js +39 -0
  11. package/lib/cjs/index.js +3 -1
  12. package/lib/cjs/utils/productNormalizer.js +2 -11
  13. package/lib/cjs/version.js +1 -1
  14. package/lib/mjs/components/Chat/Chat.js +10 -1
  15. package/lib/mjs/components/Chat/ChatMessageList.js +1 -1
  16. package/lib/mjs/components/Chat/WelcomeScreen.js +3 -2
  17. package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +4 -2
  18. package/lib/mjs/components/ResultsBlock/ResultsBlock.js +12 -45
  19. package/lib/mjs/components/ResultsBlock/ResultsGroup.js +117 -0
  20. package/lib/mjs/hooks/asaStreamHandlers.js +8 -1
  21. package/lib/mjs/hooks/useAsaResults.js +72 -10
  22. package/lib/mjs/hooks/useAsaTracking.js +91 -0
  23. package/lib/mjs/hooks/useViewportTracking.js +36 -0
  24. package/lib/mjs/index.js +1 -0
  25. package/lib/mjs/utils/productNormalizer.js +1 -0
  26. package/lib/mjs/version.js +1 -1
  27. package/lib/types/components/Chat/WelcomeScreen.d.ts +2 -2
  28. package/lib/types/components/ResultsBlock/ResultsBlock.d.ts +4 -1
  29. package/lib/types/components/ResultsBlock/ResultsGroup.d.ts +36 -0
  30. package/lib/types/hooks/useAsaTracking.d.ts +54 -0
  31. package/lib/types/hooks/useViewportTracking.d.ts +15 -0
  32. package/lib/types/index.d.ts +3 -1
  33. package/lib/types/types.d.ts +63 -1
  34. package/lib/types/utils/productNormalizer.d.ts +1 -0
  35. package/lib/types/version.d.ts +1 -1
  36. package/package.json +5 -5
@@ -16,9 +16,10 @@ export default function WelcomeScreen({ suggestions = DEFAULT_SUGGESTIONS, onSen
16
16
  placeholder: translate('CioAsa.welcome.placeholder', translations),
17
17
  isDisabled: disabled,
18
18
  };
19
+ const handleSuggestionClick = (suggestion) => onSend(suggestion, 'suggestion');
19
20
  const suggestionsRenderProps = {
20
21
  suggestions,
21
- onSuggestionClick: onSend,
22
+ onSuggestionClick: handleSuggestionClick,
22
23
  };
23
24
  return (React.createElement("div", { className: 'cio-asa-welcome-screen' },
24
25
  onClose && (React.createElement("button", { type: 'button', className: 'cio-asa-welcome-screen__close', onClick: onClose, "aria-label": translate('CioAsa.header.close', translations) }, "\u2715")),
@@ -32,7 +33,7 @@ export default function WelcomeScreen({ suggestions = DEFAULT_SUGGESTIONS, onSen
32
33
  translate('CioAsa.welcome.sendButton', translations),
33
34
  React.createElement(SendPlaneIcon, null)))),
34
35
  suggestions.length > 0 && (React.createElement(RenderPropsWrapper, { override: componentOverrides?.suggestedQuestions?.reactNode, props: suggestionsRenderProps },
35
- React.createElement("nav", { className: 'cio-asa-welcome-screen__suggestions', "aria-label": translate('CioAsa.welcome.suggestionsAriaLabel', translations) }, suggestions.map((suggestion) => (React.createElement("button", { key: suggestion, type: 'button', className: 'cio-asa-welcome-screen__suggestion-chip', disabled: disabled, onClick: () => onSend(suggestion) }, suggestion))))))),
36
+ React.createElement("nav", { className: 'cio-asa-welcome-screen__suggestions', "aria-label": translate('CioAsa.welcome.suggestionsAriaLabel', translations) }, suggestions.map((suggestion) => (React.createElement("button", { key: suggestion, type: 'button', className: 'cio-asa-welcome-screen__suggestion-chip', disabled: disabled, onClick: () => handleSuggestionClick(suggestion) }, suggestion))))))),
36
37
  termsText && React.createElement("div", { className: 'cio-asa-welcome-screen__terms' }, termsText)));
37
38
  }
38
39
  WelcomeScreen.displayName = 'WelcomeScreen';
@@ -4,7 +4,7 @@ import { AsaContext } from '../../hooks/useCioAsaContext';
4
4
  import * as defaultFormatters from '../../utils/formatters';
5
5
  import * as defaultUrlHelpers from '../../utils/urlHelpers';
6
6
  export default function CioAsaProvider(props) {
7
- const { apiKey, formatters, urlHelpers, staticRequestConfigs = { domain: 'chatbot' }, cioClient: customCioClient, children, } = props;
7
+ const { apiKey, formatters, urlHelpers, staticRequestConfigs = { domain: 'chatbot' }, cioClient: customCioClient, callbacks, section = 'Products', children, } = props;
8
8
  const [cioClientOptions, setCioClientOptions] = useState({});
9
9
  const cioClient = useCioClient({ apiKey, cioClient: customCioClient, cioClientOptions });
10
10
  const contextValue = useMemo(() => ({
@@ -14,6 +14,8 @@ export default function CioAsaProvider(props) {
14
14
  staticRequestConfigs,
15
15
  formatters: { ...defaultFormatters, ...formatters },
16
16
  urlHelpers: { ...defaultUrlHelpers, ...urlHelpers },
17
- }), [cioClient, cioClientOptions, formatters, urlHelpers, staticRequestConfigs]);
17
+ callbacks,
18
+ section,
19
+ }), [cioClient, cioClientOptions, formatters, urlHelpers, staticRequestConfigs, callbacks, section]);
18
20
  return (React.createElement(AsaContext.Provider, { value: contextValue }, typeof children === 'function' ? children(contextValue) : children));
19
21
  }
@@ -1,19 +1,20 @@
1
1
  import React, { useRef, useState, useEffect } from 'react';
2
- import { Carousel, RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
2
  import { normalizeItemToProduct } from '../../utils/productNormalizer';
4
- import { ArrowRightIcon } from '../icons';
3
+ import { useCioAsaContext } from '../../hooks/useCioAsaContext';
4
+ import useAsaTracking from '../../hooks/useAsaTracking';
5
5
  import { PEEK_FRACTION, aspectRatioMap } from './constants';
6
- import createProductCardRenderer from './renderProductCard';
6
+ import ResultsGroup from './ResultsGroup';
7
7
  import './ResultsBlock.css';
8
- function PreviousButton() {
9
- return null;
10
- }
11
- function NextButton() {
12
- return null;
13
- }
14
- function ResultsBlock({ groups, aspectRatio = '3:4', minCardWidth = 150, gap = 12, showTitle = true, viewMoreText = 'View more products', addToCartText = 'Add to cart', saleBadgeText = 'Sale', currency, normalizeItem = normalizeItemToProduct, onProductClick, onAddToCart, onViewMore, componentOverrides, }) {
8
+ function ResultsBlock({ groups, aspectRatio = '3:4', minCardWidth = 150, gap = 12, showTitle = true, viewMoreText = 'View more products', addToCartText = 'Add to cart', saleBadgeText = 'Sale', currency, intent, intentResultId, threadId, normalizeItem = normalizeItemToProduct, onProductClick, onAddToCart, onViewMore, componentOverrides, }) {
15
9
  const containerRef = useRef(null);
16
10
  const [slidesToShow, setSlidesToShow] = useState(3 + PEEK_FRACTION);
11
+ const context = useCioAsaContext();
12
+ const tracking = useAsaTracking({
13
+ tracker: context?.cioClient?.tracker ?? undefined,
14
+ section: context?.section,
15
+ threadId,
16
+ });
17
+ const callbacks = context?.callbacks;
17
18
  useEffect(() => {
18
19
  const el = containerRef.current;
19
20
  if (!el)
@@ -32,44 +33,10 @@ function ResultsBlock({ groups, aspectRatio = '3:4', minCardWidth = 150, gap = 1
32
33
  }, [minCardWidth, gap]);
33
34
  if (!groups || groups.length === 0)
34
35
  return null;
35
- const renderProductCard = createProductCardRenderer({
36
- currency,
37
- addToCartText,
38
- onProductClick,
39
- onAddToCart,
40
- componentOverrides: componentOverrides?.carousel?.item?.productCard,
41
- });
42
36
  return (React.createElement("div", { ref: containerRef, className: 'cio-asa-results-block', style: { '--cio-asa-image-ratio': aspectRatioMap[aspectRatio] } }, groups.map((groupData, index) => {
43
37
  const label = groupData.group?.display_name || groupData.group?.value || '';
44
38
  const groupKey = `${groupData.group?.value || label}-${index}`;
45
- const products = groupData.searchResults.map((item) => normalizeItem(item, { saleBadgeText }));
46
- const titleRenderProps = { label };
47
- const viewMoreRenderProps = {
48
- group: groupData.group,
49
- onClick: () => onViewMore?.(groupData.group),
50
- };
51
- const userCarousel = componentOverrides?.carousel;
52
- const carouselOverrides = {
53
- previous: { reactNode: PreviousButton },
54
- next: { reactNode: NextButton },
55
- ...userCarousel,
56
- item: {
57
- ...userCarousel?.item,
58
- productCard: {
59
- reactNode: renderProductCard,
60
- },
61
- },
62
- };
63
- return (React.createElement("div", { className: 'cio-asa-results-group', key: groupKey },
64
- showTitle && label && (React.createElement(RenderPropsWrapper, { override: componentOverrides?.groupTitle?.reactNode, props: titleRenderProps },
65
- React.createElement("h3", { className: 'cio-asa-results-group__label' }, label))),
66
- React.createElement(Carousel, { items: products, loop: false, responsive: {
67
- 0: { gap, slidesToShow },
68
- }, componentOverrides: carouselOverrides }),
69
- onViewMore && (React.createElement(RenderPropsWrapper, { override: componentOverrides?.viewMore?.reactNode, props: viewMoreRenderProps },
70
- React.createElement("button", { type: 'button', className: 'cio-asa-results-group__view-more', onClick: () => onViewMore(groupData.group) },
71
- React.createElement("span", { className: 'cio-asa-results-group__view-more-text' }, viewMoreText),
72
- React.createElement(ArrowRightIcon, null))))));
39
+ return (React.createElement(ResultsGroup, { key: groupKey, groupData: groupData, label: label, slidesToShow: slidesToShow, gap: gap, showTitle: showTitle, viewMoreText: viewMoreText, addToCartText: addToCartText, saleBadgeText: saleBadgeText, currency: currency, intent: intent, intentResultId: intentResultId, normalizeItem: normalizeItem, onProductClick: onProductClick, onAddToCart: onAddToCart, onViewMore: onViewMore, componentOverrides: componentOverrides, tracking: tracking, callbacks: callbacks }));
73
40
  })));
74
41
  }
75
42
  ResultsBlock.displayName = 'ResultsBlock';
@@ -0,0 +1,117 @@
1
+ import React from 'react';
2
+ import { Carousel, RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
+ import useViewportTracking from '../../hooks/useViewportTracking';
4
+ import { ArrowRightIcon } from '../icons';
5
+ import createProductCardRenderer from './renderProductCard';
6
+ function PreviousButton() {
7
+ return null;
8
+ }
9
+ function NextButton() {
10
+ return null;
11
+ }
12
+ /**
13
+ * Renders a single search_result pod (one carousel) and fires its pod-level behavioral
14
+ * events: `assistant_search_result_view` (on scroll into view), `assistant_search_result_click`
15
+ * (product click), and `assistant_search_submit` ("view more"). Each SDK beacon is paired
16
+ * with its optional consumer callback. Events only fire when both `intent` and the pod's
17
+ * `searchResultId` are known.
18
+ */
19
+ export default function ResultsGroup({ groupData, label, slidesToShow, gap, showTitle, viewMoreText, addToCartText, saleBadgeText, currency, intent, intentResultId, normalizeItem, onProductClick, onAddToCart, onViewMore, componentOverrides, tracking, callbacks, }) {
20
+ const { searchResultId } = groupData;
21
+ const products = groupData.searchResults.map((item) => normalizeItem(item, { saleBadgeText }));
22
+ const canTrack = !!intent && !!searchResultId;
23
+ const { ref: viewRef } = useViewportTracking({
24
+ enabled: canTrack,
25
+ onView: () => {
26
+ if (!intent || !searchResultId)
27
+ return;
28
+ const items = products.map((p) => ({
29
+ itemId: p.id,
30
+ itemName: p.name,
31
+ ...(p.variationId && { variationId: p.variationId }),
32
+ }));
33
+ const payload = {
34
+ intent,
35
+ searchResultId,
36
+ intentResultId,
37
+ numResultsViewed: products.length,
38
+ items,
39
+ };
40
+ tracking.trackResultView(payload);
41
+ callbacks?.onResultView?.(payload);
42
+ },
43
+ });
44
+ const handleProductClick = (product) => {
45
+ if (intent && searchResultId) {
46
+ tracking.trackResultClick({
47
+ intent,
48
+ searchResultId,
49
+ intentResultId,
50
+ itemId: product.id,
51
+ itemName: product.name,
52
+ variationId: product.variationId,
53
+ });
54
+ callbacks?.onResultClick?.({
55
+ intent,
56
+ searchResultId,
57
+ intentResultId,
58
+ item: {
59
+ itemId: product.id,
60
+ itemName: product.name,
61
+ ...(product.variationId && { variationId: product.variationId }),
62
+ },
63
+ });
64
+ }
65
+ onProductClick?.(product);
66
+ };
67
+ const handleViewMore = () => {
68
+ if (intent && searchResultId) {
69
+ const searchTerm = groupData.group?.value || groupData.group?.display_name || '';
70
+ const payload = {
71
+ intent,
72
+ searchTerm,
73
+ userInput: searchTerm,
74
+ searchResultId,
75
+ intentResultId,
76
+ };
77
+ tracking.trackSearchSubmit(payload);
78
+ callbacks?.onSearchSubmit?.(payload);
79
+ }
80
+ onViewMore?.(groupData.group);
81
+ };
82
+ const renderProductCard = createProductCardRenderer({
83
+ currency,
84
+ addToCartText,
85
+ onProductClick: handleProductClick,
86
+ onAddToCart,
87
+ componentOverrides: componentOverrides?.carousel?.item?.productCard,
88
+ });
89
+ const titleRenderProps = { label };
90
+ const viewMoreRenderProps = {
91
+ group: groupData.group,
92
+ onClick: handleViewMore,
93
+ };
94
+ const userCarousel = componentOverrides?.carousel;
95
+ const carouselOverrides = {
96
+ previous: { reactNode: PreviousButton },
97
+ next: { reactNode: NextButton },
98
+ ...userCarousel,
99
+ item: {
100
+ ...userCarousel?.item,
101
+ productCard: {
102
+ reactNode: renderProductCard,
103
+ },
104
+ },
105
+ };
106
+ return (React.createElement("div", { className: 'cio-asa-results-group', ref: viewRef },
107
+ showTitle && label && (React.createElement(RenderPropsWrapper, { override: componentOverrides?.groupTitle?.reactNode, props: titleRenderProps },
108
+ React.createElement("h3", { className: 'cio-asa-results-group__label' }, label))),
109
+ React.createElement(Carousel, { items: products, loop: false, responsive: {
110
+ 0: { gap, slidesToShow },
111
+ }, componentOverrides: carouselOverrides }),
112
+ onViewMore && (React.createElement(RenderPropsWrapper, { override: componentOverrides?.viewMore?.reactNode, props: viewMoreRenderProps },
113
+ React.createElement("button", { type: 'button', className: 'cio-asa-results-group__view-more', onClick: handleViewMore },
114
+ React.createElement("span", { className: 'cio-asa-results-group__view-more-text' }, viewMoreText),
115
+ React.createElement(ArrowRightIcon, null))))));
116
+ }
117
+ ResultsGroup.displayName = 'ResultsGroup';
@@ -7,7 +7,14 @@ export function handleSearchResult(data, pendingGroup, assistantId, setMessages)
7
7
  value: data?.response?.search_request?.search_term ?? data?.title ?? '',
8
8
  };
9
9
  const results = data?.response?.results ?? (data?.results ? data.results : [data]);
10
- const newGroup = { group: resolvedGroup, searchResults: results };
10
+ // `result_id` is unique per search_result event; `intent_result_id` is shared across
11
+ // the stream. Both sit at the top level of the SSE event payload.
12
+ const newGroup = {
13
+ group: resolvedGroup,
14
+ searchResults: results,
15
+ ...(data?.result_id && { searchResultId: data.result_id }),
16
+ ...(data?.intent_result_id && { intentResultId: data.intent_result_id }),
17
+ };
11
18
  updateMessageById(setMessages, assistantId, (msg) => ({
12
19
  ...msg,
13
20
  status: 'streaming',
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useCioAsaContext } from './useCioAsaContext';
3
3
  import { handleSearchResult, handleMessage, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
4
+ import useAsaTracking from './useAsaTracking';
4
5
  export default function useAsaResults() {
5
6
  const [messages, setMessages] = useState([]);
6
7
  const [isStreaming, setIsStreaming] = useState(false);
@@ -13,22 +14,36 @@ export default function useAsaResults() {
13
14
  if (!contextValue) {
14
15
  throw new Error('useAsaResults must be used within a CioAsaProvider.');
15
16
  }
16
- const { cioClient, staticRequestConfigs } = contextValue;
17
+ const { cioClient, staticRequestConfigs, callbacks, section } = contextValue;
17
18
  const { domain } = staticRequestConfigs || {};
18
19
  if (!cioClient || !domain) {
19
20
  throw new Error('useAsaResults requires a configured cioClient and domain. Check your CioAsaProvider props.');
20
21
  }
22
+ const tracking = useAsaTracking({
23
+ tracker: cioClient.tracker,
24
+ section,
25
+ threadId: threadIdRef.current ?? undefined,
26
+ });
27
+ const trackingRef = useRef(tracking);
28
+ trackingRef.current = tracking;
29
+ const callbacksRef = useRef(callbacks);
30
+ callbacksRef.current = callbacks;
31
+ const staticRequestConfigsRef = useRef(staticRequestConfigs);
32
+ staticRequestConfigsRef.current = staticRequestConfigs;
21
33
  const nextMessageId = useCallback(() => {
22
34
  idCounterRef.current += 1;
23
35
  return `msg-${idCounterRef.current}-${Date.now()}`;
24
36
  }, []);
25
- const sendMessage = useCallback((text) => {
26
- if (!text.trim() || isStreamingRef.current)
37
+ const sendMessage = useCallback((text, source = 'input') => {
38
+ const intent = text.trim();
39
+ if (!intent || isStreamingRef.current)
27
40
  return;
41
+ trackingRef.current.trackSubmit(intent);
42
+ callbacksRef.current?.onAssistantSubmit?.({ intent, source });
28
43
  const userMessage = {
29
44
  id: nextMessageId(),
30
45
  role: 'user',
31
- text: text.trim(),
46
+ text: intent,
32
47
  status: 'done',
33
48
  };
34
49
  const assistantMessage = {
@@ -37,19 +52,46 @@ export default function useAsaResults() {
37
52
  text: '',
38
53
  groups: [],
39
54
  status: 'loading',
55
+ intent,
40
56
  };
41
57
  setMessages((prev) => [...prev, userMessage, assistantMessage]);
42
58
  setIsStreaming(true);
43
59
  isStreamingRef.current = true;
44
60
  killSwitchRef.current = false;
45
- const stream = cioClient.agent.getAgentResultsStream(text.trim(), {
61
+ // `intent` is passed as the first argument and `threadId` is managed per-stream
62
+ // below, so both are stripped here. Everything else configured on the provider
63
+ // (filters, guard, numResultsPerEvent, numResultEvents, qsParam,
64
+ // preFilterExpression, fmtOptions, ...) is forwarded to the agent as-is.
65
+ const agentParams = { ...staticRequestConfigsRef.current };
66
+ delete agentParams.intent;
67
+ delete agentParams.threadId;
68
+ const stream = cioClient.agent.getAgentResultsStream(intent, {
69
+ ...agentParams,
46
70
  domain,
47
71
  ...(threadIdRef.current && { threadId: threadIdRef.current }),
48
72
  });
49
73
  const reader = stream.getReader();
50
74
  readerRef.current = reader;
75
+ // Mutable per-stream state kept on an object so the closures below don't capture
76
+ // reassigned loop-locals (which eslint's no-loop-func forbids).
77
+ const streamState = {
78
+ pendingGroup: null,
79
+ intentResultId: undefined,
80
+ loadStartFired: false,
81
+ groupCount: 0,
82
+ };
83
+ const patchAssistant = (patch) => {
84
+ setMessages((prev) => prev.map((m) => (m.id === assistantMessage.id ? { ...m, ...patch } : m)));
85
+ };
86
+ const fireLoadStart = () => {
87
+ if (streamState.loadStartFired)
88
+ return;
89
+ streamState.loadStartFired = true;
90
+ const args = { intent, intentResultId: streamState.intentResultId };
91
+ trackingRef.current.trackResultLoadStarted(args);
92
+ callbacksRef.current?.onResultLoadStart?.(args);
93
+ };
51
94
  (async () => {
52
- let pendingGroup = null;
53
95
  try {
54
96
  // eslint-disable-next-line no-await-in-loop
55
97
  while (!killSwitchRef.current) {
@@ -59,22 +101,42 @@ export default function useAsaResults() {
59
101
  break;
60
102
  if (res.done) {
61
103
  handleStreamEnd(assistantMessage.id, setMessages);
104
+ const finishArgs = {
105
+ intent,
106
+ searchResultCount: streamState.groupCount,
107
+ intentResultId: streamState.intentResultId,
108
+ };
109
+ trackingRef.current.trackResultLoadFinished(finishArgs);
110
+ callbacksRef.current?.onResultLoadFinish?.(finishArgs);
62
111
  break;
63
112
  }
64
113
  const { type, data } = res.value;
65
- if (type === 'start' && data?.thread_id) {
66
- threadIdRef.current = data.thread_id;
114
+ // `intent_result_id` is shared across the stream; capture it from the first
115
+ // event that carries it so load-start/finish can attribute correctly.
116
+ if (!streamState.intentResultId && data?.intent_result_id) {
117
+ streamState.intentResultId = data.intent_result_id;
118
+ patchAssistant({ intentResultId: streamState.intentResultId });
119
+ }
120
+ if (type === 'start') {
121
+ if (data?.thread_id) {
122
+ threadIdRef.current = data.thread_id;
123
+ patchAssistant({ threadId: data.thread_id });
124
+ }
125
+ fireLoadStart();
67
126
  }
68
127
  else if (type === 'group') {
69
- pendingGroup = {
128
+ streamState.pendingGroup = {
70
129
  display_name: data?.display_name ?? data?.group ?? '',
71
130
  value: data?.value ?? data?.group ?? '',
72
131
  };
73
132
  }
74
133
  else if (type === 'search_result') {
75
- pendingGroup = handleSearchResult(data, pendingGroup, assistantMessage.id, setMessages);
134
+ fireLoadStart();
135
+ streamState.groupCount += 1;
136
+ streamState.pendingGroup = handleSearchResult(data, streamState.pendingGroup, assistantMessage.id, setMessages);
76
137
  }
77
138
  else if (type === 'message') {
139
+ fireLoadStart();
78
140
  handleMessage(data, assistantMessage.id, setMessages);
79
141
  }
80
142
  else if (type === 'server_error') {
@@ -0,0 +1,91 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ const NOOP_TRACKING = {
3
+ trackSubmit: () => { },
4
+ trackResultLoadStarted: () => { },
5
+ trackResultLoadFinished: () => { },
6
+ trackResultClick: () => { },
7
+ trackResultView: () => { },
8
+ trackSearchSubmit: () => { },
9
+ };
10
+ /**
11
+ * Fires ASA behavioral tracking events through the Constructor client. Each returned
12
+ * function maps to one `trackAssistant*` beacon and merges in `section` + `threadId`.
13
+ * Mirrors the tracking-hook pattern used by constructorio-ui-pia.
14
+ */
15
+ export default function useAsaTracking({ tracker, section, threadId, }) {
16
+ const assistant = tracker;
17
+ const base = useMemo(() => ({
18
+ ...(section && { section }),
19
+ ...(threadId && { threadId }),
20
+ }), [section, threadId]);
21
+ const trackSubmit = useCallback((intent) => {
22
+ assistant?.trackAssistantSubmit({ intent, ...base });
23
+ }, [assistant, base]);
24
+ const trackResultLoadStarted = useCallback(({ intent, intentResultId }) => {
25
+ assistant?.trackAssistantResultLoadStarted({
26
+ intent,
27
+ ...(intentResultId && { intentResultId }),
28
+ ...base,
29
+ });
30
+ }, [assistant, base]);
31
+ const trackResultLoadFinished = useCallback(({ intent, searchResultCount, intentResultId }) => {
32
+ assistant?.trackAssistantResultLoadFinished({
33
+ intent,
34
+ searchResultCount,
35
+ ...(intentResultId && { intentResultId }),
36
+ ...base,
37
+ });
38
+ }, [assistant, base]);
39
+ const trackResultClick = useCallback(({ intent, searchResultId, intentResultId, itemId, itemName, variationId, }) => {
40
+ assistant?.trackAssistantResultClick({
41
+ intent,
42
+ searchResultId,
43
+ ...(intentResultId && { intentResultId }),
44
+ ...(itemId && { itemId }),
45
+ ...(itemName && { itemName }),
46
+ ...(variationId && { variationId }),
47
+ ...base,
48
+ });
49
+ }, [assistant, base]);
50
+ const trackResultView = useCallback(({ intent, searchResultId, numResultsViewed, intentResultId, items }) => {
51
+ assistant?.trackAssistantResultView({
52
+ intent,
53
+ searchResultId,
54
+ numResultsViewed,
55
+ ...(intentResultId && { intentResultId }),
56
+ ...(items && items.length > 0 && { items }),
57
+ ...base,
58
+ });
59
+ }, [assistant, base]);
60
+ const trackSearchSubmit = useCallback(({ intent, searchTerm, userInput, searchResultId, intentResultId, groupId, }) => {
61
+ assistant?.trackAssistantSearchSubmit({
62
+ intent,
63
+ searchTerm,
64
+ userInput,
65
+ searchResultId,
66
+ ...(intentResultId && { intentResultId }),
67
+ ...(groupId && { groupId }),
68
+ ...base,
69
+ });
70
+ }, [assistant, base]);
71
+ return useMemo(() => {
72
+ if (!assistant)
73
+ return NOOP_TRACKING;
74
+ return {
75
+ trackSubmit,
76
+ trackResultLoadStarted,
77
+ trackResultLoadFinished,
78
+ trackResultClick,
79
+ trackResultView,
80
+ trackSearchSubmit,
81
+ };
82
+ }, [
83
+ assistant,
84
+ trackSubmit,
85
+ trackResultLoadStarted,
86
+ trackResultLoadFinished,
87
+ trackResultClick,
88
+ trackResultView,
89
+ trackSearchSubmit,
90
+ ]);
91
+ }
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ /**
3
+ * Fires `onView` once when the observed element scrolls into the viewport, modeling a
4
+ * per-pod `assistant_search_result_view` impression. Unlike the PIA widget-level view
5
+ * tracker, this fires a single impression per pod (not repeated enter/exit cycles).
6
+ */
7
+ export default function useViewportTracking({ onView, enabled = true, }) {
8
+ const onViewRef = useRef(onView);
9
+ onViewRef.current = onView;
10
+ const firedRef = useRef(false);
11
+ const cleanupRef = useRef(null);
12
+ const ref = useCallback((node) => {
13
+ if (cleanupRef.current) {
14
+ cleanupRef.current();
15
+ cleanupRef.current = null;
16
+ }
17
+ if (!node || !enabled || firedRef.current)
18
+ return;
19
+ if (typeof IntersectionObserver === 'undefined')
20
+ return;
21
+ const observer = new IntersectionObserver(([entry]) => {
22
+ if (entry.isIntersecting && !firedRef.current) {
23
+ firedRef.current = true;
24
+ onViewRef.current();
25
+ observer.disconnect();
26
+ }
27
+ }, { threshold: 0.5 });
28
+ observer.observe(node);
29
+ cleanupRef.current = () => observer.disconnect();
30
+ }, [enabled]);
31
+ useEffect(() => () => {
32
+ if (cleanupRef.current)
33
+ cleanupRef.current();
34
+ }, []);
35
+ return { ref };
36
+ }
package/lib/mjs/index.js CHANGED
@@ -5,6 +5,7 @@ export { default as ResultsBlock } from './components/ResultsBlock/ResultsBlock'
5
5
  export { default as Button } from './components/Button/Button';
6
6
  // Hooks
7
7
  export { default as useAsaResults } from './hooks/useAsaResults';
8
+ export { default as useAsaTracking } from './hooks/useAsaTracking';
8
9
  // Utils
9
10
  export { normalizeItemToProduct } from './utils/productNormalizer';
10
11
  // Re-exported client value from the JS client (used to construct/type a cioClient)
@@ -3,6 +3,7 @@ export function normalizeItemToProduct(item, options) {
3
3
  return {
4
4
  id: String(data.id ?? data.result_id ?? item.value ?? ''),
5
5
  name: String(item.value ?? data.item_name ?? ''),
6
+ ...(data.variation_id != null && { variationId: String(data.variation_id) }),
6
7
  url: data.url,
7
8
  imageUrl: data.image_url ?? data.imageUrl,
8
9
  price: data.price,
@@ -1 +1 @@
1
- export default '1.0.0';
1
+ export default '1.1.1';
@@ -1,8 +1,8 @@
1
1
  import React from 'react';
2
- import { Translations, WelcomeScreenOverrides } from '../../types';
2
+ import { AssistantSubmitSource, Translations, WelcomeScreenOverrides } from '../../types';
3
3
  interface WelcomeScreenProps {
4
4
  suggestions?: string[];
5
- onSend: (text: string) => void;
5
+ onSend: (text: string, source?: AssistantSubmitSource) => void;
6
6
  onClose?: () => void;
7
7
  termsText?: React.ReactNode;
8
8
  disabled?: boolean;
@@ -14,6 +14,9 @@ interface ResultsBlockProps {
14
14
  addToCartText?: string;
15
15
  saleBadgeText?: string;
16
16
  currency?: string;
17
+ intent?: string;
18
+ intentResultId?: string;
19
+ threadId?: string;
17
20
  /**
18
21
  * Map a raw search-result item to the `Product` shape rendered by the card.
19
22
  * Override this when your index metadata uses non-default field names
@@ -25,7 +28,7 @@ interface ResultsBlockProps {
25
28
  onViewMore?: (group: ResultGroupMeta) => void;
26
29
  componentOverrides?: ResultsBlockOverrides;
27
30
  }
28
- declare function ResultsBlock({ groups, aspectRatio, minCardWidth, gap, showTitle, viewMoreText, addToCartText, saleBadgeText, currency, normalizeItem, onProductClick, onAddToCart, onViewMore, componentOverrides, }: ResultsBlockProps): React.JSX.Element | null;
31
+ declare function ResultsBlock({ groups, aspectRatio, minCardWidth, gap, showTitle, viewMoreText, addToCartText, saleBadgeText, currency, intent, intentResultId, threadId, normalizeItem, onProductClick, onAddToCart, onViewMore, componentOverrides, }: ResultsBlockProps): React.JSX.Element | null;
29
32
  declare namespace ResultsBlock {
30
33
  var displayName: string;
31
34
  }
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { AsaCallbacks, ResultGroup, ResultGroupMeta, ResultsBlockOverrides } from '../../types';
3
+ import { Product, NormalizeOptions } from '../../utils/productNormalizer';
4
+ import useAsaTracking from '../../hooks/useAsaTracking';
5
+ export interface ResultsGroupProps {
6
+ groupData: ResultGroup;
7
+ label: string;
8
+ slidesToShow: number;
9
+ gap: number;
10
+ showTitle: boolean;
11
+ viewMoreText: string;
12
+ addToCartText: string;
13
+ saleBadgeText: string;
14
+ currency?: string;
15
+ intent?: string;
16
+ intentResultId?: string;
17
+ normalizeItem: (item: any, options?: NormalizeOptions) => Product;
18
+ onProductClick?: (product: Product) => void;
19
+ onAddToCart?: (product: Product) => void;
20
+ onViewMore?: (group: ResultGroupMeta) => void;
21
+ componentOverrides?: ResultsBlockOverrides;
22
+ tracking: ReturnType<typeof useAsaTracking>;
23
+ callbacks?: AsaCallbacks;
24
+ }
25
+ /**
26
+ * Renders a single search_result pod (one carousel) and fires its pod-level behavioral
27
+ * events: `assistant_search_result_view` (on scroll into view), `assistant_search_result_click`
28
+ * (product click), and `assistant_search_submit` ("view more"). Each SDK beacon is paired
29
+ * with its optional consumer callback. Events only fire when both `intent` and the pod's
30
+ * `searchResultId` are known.
31
+ */
32
+ declare function ResultsGroup({ groupData, label, slidesToShow, gap, showTitle, viewMoreText, addToCartText, saleBadgeText, currency, intent, intentResultId, normalizeItem, onProductClick, onAddToCart, onViewMore, componentOverrides, tracking, callbacks, }: ResultsGroupProps): React.JSX.Element;
33
+ declare namespace ResultsGroup {
34
+ var displayName: string;
35
+ }
36
+ export default ResultsGroup;