@blotoutio/providers-evo-search-sdk 1.71.1 → 1.72.0

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.
package/core.cjs.js CHANGED
@@ -1864,6 +1864,10 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1864
1864
  logger.info('EvoSearch API: Fetching trending searches');
1865
1865
  return getJSON('/trending', 'Trending', undefined, signal);
1866
1866
  };
1867
+ const collections = (signal) => {
1868
+ logger.info('EvoSearch API: Fetching featured collections');
1869
+ return getJSON('/collections', 'Collections', undefined, signal);
1870
+ };
1867
1871
  // Held in the gate rather than read off `uiPreferences` at each send site:
1868
1872
  // that object is captured by value when this API instance is built, and the
1869
1873
  // widget replaces its own copy after fetching config, so an overlay there
@@ -2306,6 +2310,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
2306
2310
  };
2307
2311
  return {
2308
2312
  trending,
2313
+ collections,
2309
2314
  config,
2310
2315
  setMarket,
2311
2316
  searchPageDesign,
@@ -2530,7 +2535,7 @@ const init = (params) => {
2530
2535
  const tag = ({ data }) => {
2531
2536
  try {
2532
2537
  return {
2533
- sdkVersion: "1.71.1" || '',
2538
+ sdkVersion: "1.72.0" || '',
2534
2539
  searchQuery: data['searchQuery'],
2535
2540
  searchResults: data['searchResults'],
2536
2541
  timestamp: Date.now(),
@@ -2539,7 +2544,7 @@ const tag = ({ data }) => {
2539
2544
  catch (err) {
2540
2545
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
2541
2546
  return {
2542
- sdkVersion: "1.71.1" ,
2547
+ sdkVersion: "1.72.0" ,
2543
2548
  timestamp: Date.now(),
2544
2549
  };
2545
2550
  }
package/core.js CHANGED
@@ -1865,6 +1865,10 @@ var ProvidersEvoSearchSdk = (function () {
1865
1865
  logger.info('EvoSearch API: Fetching trending searches');
1866
1866
  return getJSON('/trending', 'Trending', undefined, signal);
1867
1867
  };
1868
+ const collections = (signal) => {
1869
+ logger.info('EvoSearch API: Fetching featured collections');
1870
+ return getJSON('/collections', 'Collections', undefined, signal);
1871
+ };
1868
1872
  // Held in the gate rather than read off `uiPreferences` at each send site:
1869
1873
  // that object is captured by value when this API instance is built, and the
1870
1874
  // widget replaces its own copy after fetching config, so an overlay there
@@ -2307,6 +2311,7 @@ var ProvidersEvoSearchSdk = (function () {
2307
2311
  };
2308
2312
  return {
2309
2313
  trending,
2314
+ collections,
2310
2315
  config,
2311
2316
  setMarket,
2312
2317
  searchPageDesign,
@@ -2531,7 +2536,7 @@ var ProvidersEvoSearchSdk = (function () {
2531
2536
  const tag = ({ data }) => {
2532
2537
  try {
2533
2538
  return {
2534
- sdkVersion: "1.71.1" || '',
2539
+ sdkVersion: "1.72.0" || '',
2535
2540
  searchQuery: data['searchQuery'],
2536
2541
  searchResults: data['searchResults'],
2537
2542
  timestamp: Date.now(),
@@ -2540,7 +2545,7 @@ var ProvidersEvoSearchSdk = (function () {
2540
2545
  catch (err) {
2541
2546
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
2542
2547
  return {
2543
- sdkVersion: "1.71.1" ,
2548
+ sdkVersion: "1.72.0" ,
2544
2549
  timestamp: Date.now(),
2545
2550
  };
2546
2551
  }
package/core.mjs CHANGED
@@ -1862,6 +1862,10 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1862
1862
  logger.info('EvoSearch API: Fetching trending searches');
1863
1863
  return getJSON('/trending', 'Trending', undefined, signal);
1864
1864
  };
1865
+ const collections = (signal) => {
1866
+ logger.info('EvoSearch API: Fetching featured collections');
1867
+ return getJSON('/collections', 'Collections', undefined, signal);
1868
+ };
1865
1869
  // Held in the gate rather than read off `uiPreferences` at each send site:
1866
1870
  // that object is captured by value when this API instance is built, and the
1867
1871
  // widget replaces its own copy after fetching config, so an overlay there
@@ -2304,6 +2308,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
2304
2308
  };
2305
2309
  return {
2306
2310
  trending,
2311
+ collections,
2307
2312
  config,
2308
2313
  setMarket,
2309
2314
  searchPageDesign,
@@ -2528,7 +2533,7 @@ const init = (params) => {
2528
2533
  const tag = ({ data }) => {
2529
2534
  try {
2530
2535
  return {
2531
- sdkVersion: "1.71.1" || '',
2536
+ sdkVersion: "1.72.0" || '',
2532
2537
  searchQuery: data['searchQuery'],
2533
2538
  searchResults: data['searchResults'],
2534
2539
  timestamp: Date.now(),
@@ -2537,7 +2542,7 @@ const tag = ({ data }) => {
2537
2542
  catch (err) {
2538
2543
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
2539
2544
  return {
2540
- sdkVersion: "1.71.1" ,
2545
+ sdkVersion: "1.72.0" ,
2541
2546
  timestamp: Date.now(),
2542
2547
  };
2543
2548
  }
package/hooks.cjs.js CHANGED
@@ -3435,6 +3435,443 @@ const useEvoSearchAPI = () => {
3435
3435
  return { ready: ctx.ready, api: ctx.api };
3436
3436
  };
3437
3437
 
3438
+ /**
3439
+ * Storefront-local helpers, deliberately separate from the worker-scoped API
3440
+ * client: `api.ts` injects identity params and headers that must never reach
3441
+ * the merchant's own origin, and its `credentials: omit` policy is the
3442
+ * opposite of what storefront requests (which ride the cart cookie) need.
3443
+ */
3444
+ /**
3445
+ * The storefront's root path. Shopify publishes it (locale prefix included,
3446
+ * e.g. `/en-ca/`) on `window.Shopify.routes.root`; default to `/`.
3447
+ */
3448
+ const storefrontRoot = () => {
3449
+ var _a, _b;
3450
+ if (typeof window === 'undefined') {
3451
+ return '/';
3452
+ }
3453
+ const root = (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.routes) === null || _b === void 0 ? void 0 : _b.root;
3454
+ return typeof root === 'string' && root.startsWith('/') && root.endsWith('/')
3455
+ ? root
3456
+ : '/';
3457
+ };
3458
+ /* ------------------------------------------------------------------ *
3459
+ * Theme quick-add handoff
3460
+ *
3461
+ * Whatever a merchant's quick-add opens — a modal, a side drawer, a plain
3462
+ * navigation — their own product card already knows how to open it. So the
3463
+ * tile borrows that card and presses its button rather than emulating any
3464
+ * one theme's surface. Nothing here is written against a specific theme:
3465
+ * the results section and the trigger are both read off the storefront at
3466
+ * runtime, and a store with no quick-add at all resolves to `false` so the
3467
+ * caller keeps its product link.
3468
+ * ------------------------------------------------------------------ */
3469
+ /**
3470
+ * Our own markup, which must never be mistaken for the theme's card. Every
3471
+ * surface we render is `evs-` prefixed — the custom elements by tag, the
3472
+ * managed page by class — so the prefix is the test rather than a list that
3473
+ * would fall behind the next component.
3474
+ */
3475
+ const isEvoSearchMarkup = (node) => node.tagName.startsWith('EVS-') ||
3476
+ node.hasAttribute('data-evo-search-quick-add') ||
3477
+ Array.from(node.classList).some((name) => name.startsWith('evs-'));
3478
+ const QUICK_ADD_PATTERN = /quick[-_]?(add|shop|view|buy)|choose[-_]?option/i;
3479
+ /** The same words as a selector, so presence is one query rather than a walk. */
3480
+ const QUICK_ADD_HINT = '[class*="quick-add" i],[class*="quickadd" i],[class*="quick-shop" i],' +
3481
+ '[class*="quickshop" i],[class*="quick-view" i],[class*="quickview" i],' +
3482
+ '[class*="quick-buy" i],[class*="quickbuy" i],quick-add,quick-add-component,' +
3483
+ 'quick-add-modal,quick-shop,quick-view';
3484
+ const productHandleOf = (link) => { var _a, _b, _c; return (_c = (_b = /\/products\/([^/?#]+)/.exec((_a = link.getAttribute('href')) !== null && _a !== void 0 ? _a : '')) === null || _b === void 0 ? void 0 : _b[1]) !== null && _c !== void 0 ? _c : null; };
3485
+ const isThemeOwned = (element) => {
3486
+ for (let node = element; node; node = node.parentElement) {
3487
+ if (isEvoSearchMarkup(node))
3488
+ return false;
3489
+ }
3490
+ return true;
3491
+ };
3492
+ const attributeSignal = (element) => {
3493
+ var _a, _b;
3494
+ return `${element.tagName} ${(_a = element.getAttribute('class')) !== null && _a !== void 0 ? _a : ''} ${(_b = element.getAttribute('data-quick-add-button')) !== null && _b !== void 0 ? _b : ''}`;
3495
+ };
3496
+ const holdsQuickAdd = (element) => element.querySelector(QUICK_ADD_HINT) !== null;
3497
+ /**
3498
+ * Whether this storefront does quick-add at all, read off the theme's own
3499
+ * cards. A tile asks before it names its own call to action: promising an add
3500
+ * on a storefront that can only offer the product page is a worse answer than
3501
+ * naming the detour. Page scope, so a probe reads it once and every tile in
3502
+ * the pass shares the answer; never remembered across passes, because a "no"
3503
+ * from an early render would outlive the markup that made it true.
3504
+ */
3505
+ const pageHasThemeQuickAdd = () => {
3506
+ if (typeof document === 'undefined')
3507
+ return false;
3508
+ try {
3509
+ return Array.from(document.querySelectorAll(QUICK_ADD_HINT)).some(isThemeOwned);
3510
+ }
3511
+ catch {
3512
+ return false;
3513
+ }
3514
+ };
3515
+ /** True until the climb reaches the grid, where a sibling product appears. */
3516
+ const holdsOnlyProduct = (element, handle) => {
3517
+ const links = element.querySelectorAll('a[href*="/products/"]');
3518
+ return (links.length > 0 &&
3519
+ Array.from(links).every((link) => productHandleOf(link) === handle));
3520
+ };
3521
+ /**
3522
+ * The theme's card for one product. Deliberately the OUTERMOST wrapper that
3523
+ * still belongs to this product alone: a theme's quick-add resolves the
3524
+ * product through its own card element (Horizon reads
3525
+ * `closest('product-card')`), so handing it an inner fragment that merely
3526
+ * contains the button leaves it without a product to open.
3527
+ */
3528
+ const themeCardFor = (root, handle) => {
3529
+ const link = Array.from(root.querySelectorAll('a[href*="/products/"]')).find((candidate) => productHandleOf(candidate) === handle && isThemeOwned(candidate));
3530
+ if (!link)
3531
+ return null;
3532
+ let card = null;
3533
+ let node = link.parentElement;
3534
+ for (let depth = 0; node && depth < 10; depth += 1) {
3535
+ if (node.id.startsWith('shopify-section-') ||
3536
+ !holdsOnlyProduct(node, handle)) {
3537
+ break;
3538
+ }
3539
+ if (holdsQuickAdd(node))
3540
+ card = node;
3541
+ node = node.parentElement;
3542
+ }
3543
+ return card;
3544
+ };
3545
+ /**
3546
+ * The section that renders the theme's own product grid, read off the page
3547
+ * rather than configured: Shopify stamps every section as
3548
+ * `shopify-section-<id>`, and the one holding the most product links is the
3549
+ * grid. A storefront with no sections at all (headless) yields null, which
3550
+ * is what keeps the handoff from firing there.
3551
+ */
3552
+ const themeGridSectionId = () => {
3553
+ var _a;
3554
+ let best = null;
3555
+ for (const section of Array.from(document.querySelectorAll('[id^="shopify-section-"]'))) {
3556
+ const links = Array.from(section.querySelectorAll('a[href*="/products/"]')).filter(isThemeOwned).length;
3557
+ if (links > 0 && (!best || links > best.links)) {
3558
+ best = { id: section.id.slice('shopify-section-'.length), links };
3559
+ }
3560
+ }
3561
+ return (_a = best === null || best === void 0 ? void 0 : best.id) !== null && _a !== void 0 ? _a : null;
3562
+ };
3563
+ const themeCardCache = new Map();
3564
+ /**
3565
+ * Handles the theme's own grid section came back without a card for. Worth
3566
+ * remembering, because it is a fact about the theme's results rather than a
3567
+ * transient failure — a refused or failed fetch records nothing — and a card
3568
+ * that mounts on the page later is found before this set is consulted.
3569
+ */
3570
+ const themeCardMisses = new Set();
3571
+ /** Ask the storefront to render its own grid for this product alone. */
3572
+ const fetchThemeCard = async (request, handle) => {
3573
+ const cached = themeCardCache.get(handle);
3574
+ if (cached)
3575
+ return cached;
3576
+ if (themeCardMisses.has(handle))
3577
+ return null;
3578
+ const sectionId = themeGridSectionId();
3579
+ if (!sectionId)
3580
+ return null;
3581
+ const response = await request(`${storefrontRoot()}search?q=${encodeURIComponent(handle)}&section_id=${encodeURIComponent(sectionId)}`, { credentials: 'same-origin' });
3582
+ if (!response.ok)
3583
+ return null;
3584
+ const document_ = new DOMParser().parseFromString(await response.text(), 'text/html');
3585
+ const card = themeCardFor(document_, handle);
3586
+ if (!card) {
3587
+ themeCardMisses.add(handle);
3588
+ return null;
3589
+ }
3590
+ const imported = document.importNode(card, true);
3591
+ themeCardCache.set(handle, imported);
3592
+ return imported;
3593
+ };
3594
+ /**
3595
+ * A borrowed card has to be genuinely laid out: `display:none`,
3596
+ * `visibility:hidden` and `inert` all leave the trigger unclickable, and a
3597
+ * theme that renders its quick-add surface inside the card would render it
3598
+ * hidden too. Off-screen it is, with no `transform` — that would make this
3599
+ * host the containing block for the theme's own fixed-position dialog.
3600
+ */
3601
+ const quickAddHost = () => {
3602
+ const existing = document.querySelector('[data-evo-search-quick-add]');
3603
+ if (existing instanceof HTMLElement)
3604
+ return existing;
3605
+ const host = document.createElement('div');
3606
+ host.setAttribute('data-evo-search-quick-add', '');
3607
+ host.style.cssText =
3608
+ 'position:fixed;top:0;left:-10000px;width:360px;height:1px;overflow:visible';
3609
+ document.body.appendChild(host);
3610
+ return host;
3611
+ };
3612
+ /**
3613
+ * A card commonly carries more than one trigger and hides all but the live
3614
+ * one — Horizon ships a direct-add submit beside a "choose options" button
3615
+ * and drops one out of the layout in CSS. Computed style is the discriminator
3616
+ * (rather than geometry, which an off-screen host would answer for), read up
3617
+ * to the card so a hidden wrapper counts.
3618
+ */
3619
+ const isRendered = (element, root) => {
3620
+ const view = element.ownerDocument.defaultView;
3621
+ if (!view)
3622
+ return true;
3623
+ for (let node = element; node; node = node.parentElement) {
3624
+ const style = view.getComputedStyle(node);
3625
+ if (style.display === 'none' || style.visibility === 'hidden')
3626
+ return false;
3627
+ if (node === root)
3628
+ break;
3629
+ }
3630
+ return true;
3631
+ };
3632
+ const ancestorsWithin = (element, root) => {
3633
+ const chain = [];
3634
+ for (let node = element.parentElement; node && node !== root.parentElement; node = node.parentElement) {
3635
+ chain.push(node);
3636
+ }
3637
+ return chain;
3638
+ };
3639
+ /**
3640
+ * Every control that can submit a form: a button or an `<input>` whose type is
3641
+ * `submit` or `image` — `<button>` submits by default, `<input>` does not.
3642
+ */
3643
+ const isSubmitter = (candidate) => (candidate instanceof HTMLButtonElement && candidate.type !== 'button') ||
3644
+ (candidate instanceof HTMLInputElement &&
3645
+ (candidate.type === 'submit' || candidate.type === 'image'));
3646
+ /**
3647
+ * Whether clicking this element posts the native cart form. `formaction` is
3648
+ * read off the attribute rather than the property, which resolves against the
3649
+ * document URL and so is never empty.
3650
+ */
3651
+ const submitsCartForm = (candidate) => {
3652
+ if (!isSubmitter(candidate))
3653
+ return false;
3654
+ const override = candidate.getAttribute('formaction');
3655
+ if (override)
3656
+ return override.includes('/cart/add');
3657
+ return candidate.closest('form[action*="/cart/add"]') !== null;
3658
+ };
3659
+ /**
3660
+ * Whether clicking this element does anything of its own. `click()` on a
3661
+ * `<div>` dispatches a real bubbling event, so a theme listening higher up
3662
+ * would still open — but a bare div carries no activation behaviour, and the
3663
+ * two are indistinguishable from here. Declining trades that delegated
3664
+ * listener for a product link the shopper can always complete, which is the
3665
+ * better half of the trade: a detour beats a click that does nothing.
3666
+ *
3667
+ * A theme's own custom element counts, because that is where a theme usually
3668
+ * puts its quick-add. Only the configured path is narrowed by this — the
3669
+ * heuristic already queries nothing but `button` and `a[role="button"]`.
3670
+ */
3671
+ const isActionable = (candidate) => ['BUTTON', 'INPUT', 'SELECT', 'SUMMARY', 'LABEL'].includes(candidate.tagName) ||
3672
+ (candidate instanceof HTMLAnchorElement && candidate.hasAttribute('href')) ||
3673
+ candidate.hasAttribute('role') ||
3674
+ candidate.hasAttribute('tabindex') ||
3675
+ candidate.tagName.includes('-');
3676
+ /**
3677
+ * Whether clicking this element can open a quick-add at all. Four ways it
3678
+ * cannot: nothing happens on a click at all; it is not laid out, so the click
3679
+ * lands nowhere; it is disabled, so the click is swallowed and the caller
3680
+ * would read the silent no-op as a successful handoff and suppress the product
3681
+ * link; or it submits the native cart form, which would add a variant the
3682
+ * shopper never chose.
3683
+ *
3684
+ * A merchant's configured selector is held to the same four, because it
3685
+ * names markup the merchant cannot see the state of — a selector matching
3686
+ * Horizon's card picks up the hidden direct-add submit as readily as the
3687
+ * "choose options" button beside it.
3688
+ */
3689
+ const isUsableTrigger = (candidate, card) => {
3690
+ if (!isActionable(candidate))
3691
+ return false;
3692
+ if (!isRendered(candidate, card))
3693
+ return false;
3694
+ if (candidate.disabled === true ||
3695
+ candidate.getAttribute('aria-disabled') === 'true') {
3696
+ return false;
3697
+ }
3698
+ return !submitsCartForm(candidate);
3699
+ };
3700
+ /**
3701
+ * The rendered trigger. A card commonly carries several — Horizon ships both
3702
+ * a direct-add submit and a "choose options" button and hides one of them in
3703
+ * CSS — so visibility is the discriminator, and a submit inside the native
3704
+ * cart form is refused outright: it would silently add a variant the shopper
3705
+ * never chose.
3706
+ */
3707
+ const quickAddTrigger = (card, configured) => {
3708
+ var _a;
3709
+ if (configured) {
3710
+ // Every match, not the first: a broad selector over a card that ships a
3711
+ // hidden direct-add ahead of the live button would otherwise resolve to
3712
+ // the hidden one and decline a handoff that was there to make.
3713
+ return ((_a = Array.from(card.querySelectorAll(configured)).find((candidate) => candidate instanceof HTMLElement && isUsableTrigger(candidate, card))) !== null && _a !== void 0 ? _a : null);
3714
+ }
3715
+ for (const candidate of Array.from(card.querySelectorAll('button,a[role="button"]'))) {
3716
+ if (!(candidate instanceof HTMLElement))
3717
+ continue;
3718
+ if (!isUsableTrigger(candidate, card))
3719
+ continue;
3720
+ const signals = [candidate, ...ancestorsWithin(candidate, card)];
3721
+ if (signals.some((node) => QUICK_ADD_PATTERN.test(attributeSignal(node)))) {
3722
+ return candidate;
3723
+ }
3724
+ }
3725
+ return null;
3726
+ };
3727
+ /**
3728
+ * Whether this storefront has a quick-add to hand off to, answered
3729
+ * synchronously so a caller can keep its product link's native behaviour
3730
+ * when the answer is no. Three things have to hold: the theme does quick-add
3731
+ * somewhere on this page, and either its card for this product is already
3732
+ * here or there is a grid section to render one from that has not already come
3733
+ * back without it. A headless storefront fails the first test and never
3734
+ * reaches the network.
3735
+ */
3736
+ const themeQuickAddAvailable = (handle) => themeQuickAddProbe().available(handle);
3737
+ /**
3738
+ * One render pass's worth of those answers. The two page-level scans — does
3739
+ * the theme do quick-add, and which section is its grid — come back the same
3740
+ * for every tile, and the per-handle lookup walks the document, so a grid of
3741
+ * tiles asking one at a time rescans the page once per tile. A probe reads the
3742
+ * page once and remembers each handle it is asked about; a renderer builds one
3743
+ * per pass and lets it fall out of scope, so nothing is remembered past the
3744
+ * markup it was read from.
3745
+ */
3746
+ const themeQuickAddProbe = () => {
3747
+ if (typeof document === 'undefined')
3748
+ return { available: () => false };
3749
+ let pageAnswer;
3750
+ let sectionAnswer;
3751
+ const perHandle = new Map();
3752
+ const resolve = (handle) => {
3753
+ pageAnswer !== null && pageAnswer !== void 0 ? pageAnswer : (pageAnswer = pageHasThemeQuickAdd());
3754
+ if (!pageAnswer)
3755
+ return false;
3756
+ if (themeCardFor(document, handle) !== null)
3757
+ return true;
3758
+ if (themeCardMisses.has(handle))
3759
+ return false;
3760
+ if (sectionAnswer === undefined)
3761
+ sectionAnswer = themeGridSectionId();
3762
+ return sectionAnswer !== null;
3763
+ };
3764
+ return {
3765
+ available: (handle) => {
3766
+ if (!handle)
3767
+ return false;
3768
+ const memo = perHandle.get(handle);
3769
+ if (memo !== undefined)
3770
+ return memo;
3771
+ let answer = false;
3772
+ try {
3773
+ answer = resolve(handle);
3774
+ }
3775
+ catch {
3776
+ answer = false;
3777
+ }
3778
+ perHandle.set(handle, answer);
3779
+ return answer;
3780
+ },
3781
+ };
3782
+ };
3783
+ /**
3784
+ * Open the merchant's own quick-add surface for a product, resolving false
3785
+ * when the storefront has none — headless, or a theme whose cards simply
3786
+ * link to the product page. False is not a degraded state: the caller's
3787
+ * product link IS what that storefront does.
3788
+ *
3789
+ * The theme's own card on the page is pressed where it stands. A clone has no
3790
+ * `shopify-section-` ancestor and no grid controller above it, so a theme that
3791
+ * resolves its product by climbing out of the card — or keys a body-appended
3792
+ * surface off the section id — would find nothing to open. Only a card we had
3793
+ * to fetch is hosted, because that one has no place on the page to be pressed
3794
+ * in; it keeps the section ancestor it never had.
3795
+ *
3796
+ * A hosted card stays in the host after a successful open, because a theme
3797
+ * that builds its surface inside the card would have it torn out from under
3798
+ * the shopper; the next handoff replaces it.
3799
+ */
3800
+ const openThemeQuickAdd = async ({ handle, triggerSelector, fetchImpl, }) => {
3801
+ // Gated on the same predicate the caller uses, so the handoff can never
3802
+ // spend a section render on a storefront that has nothing to hand off to.
3803
+ if (!themeQuickAddAvailable(handle))
3804
+ return false;
3805
+ try {
3806
+ const live = themeCardFor(document, handle);
3807
+ if (live) {
3808
+ // Already upgraded and wired, so no frame to wait for. `isRendered`
3809
+ // stops at the card rather than the document, which lets a trigger in
3810
+ // the theme's hidden duplicate grid through — its listeners are wired
3811
+ // the same, and the surface it opens is not drawn from the card.
3812
+ const trigger = quickAddTrigger(live, triggerSelector);
3813
+ if (!trigger)
3814
+ return false;
3815
+ trigger.click();
3816
+ return true;
3817
+ }
3818
+ const fetched = await fetchThemeCard(fetchImpl !== null && fetchImpl !== void 0 ? fetchImpl : window.fetch.bind(window), handle);
3819
+ if (!fetched)
3820
+ return false;
3821
+ const host = quickAddHost();
3822
+ const card = fetched.cloneNode(true);
3823
+ host.replaceChildren(card);
3824
+ // One frame so the theme's custom elements upgrade and wire their refs.
3825
+ await new Promise((resolve) => {
3826
+ requestAnimationFrame(() => resolve());
3827
+ });
3828
+ const trigger = quickAddTrigger(card, triggerSelector);
3829
+ if (!trigger) {
3830
+ host.replaceChildren();
3831
+ return false;
3832
+ }
3833
+ trigger.click();
3834
+ return true;
3835
+ }
3836
+ catch {
3837
+ // A theme throwing on its own markup is a theme without a usable
3838
+ // quick-add: the caller's product link stands.
3839
+ return false;
3840
+ }
3841
+ };
3842
+ /**
3843
+ * A tile that cannot resolve a variant, clicked. Borrow the storefront's own
3844
+ * quick-add when there is one; otherwise leave the link untouched — on a
3845
+ * headless storefront it belongs to the host router, and hijacking it would
3846
+ * turn a client transition into a full page load.
3847
+ */
3848
+ const handleChooseOptionsClick = (event, { probe, handle, href, triggerSelector, }) => {
3849
+ if (event.defaultPrevented ||
3850
+ event.metaKey ||
3851
+ event.ctrlKey ||
3852
+ event.shiftKey ||
3853
+ event.altKey ||
3854
+ !probe.available(handle)) {
3855
+ return;
3856
+ }
3857
+ event.preventDefault();
3858
+ void openThemeQuickAdd({
3859
+ handle,
3860
+ ...(triggerSelector ? { triggerSelector } : {}),
3861
+ }).then((opened) => {
3862
+ // The theme looked like it had one and then did not: complete the journey
3863
+ // the shopper actually asked for rather than swallowing it.
3864
+ if (!opened)
3865
+ window.location.href = href;
3866
+ });
3867
+ };
3868
+ /**
3869
+ * That tile's call to action: an add wherever the storefront can actually open
3870
+ * one, and the detour named where it cannot. The same predicate the click
3871
+ * gates on, so the promise and the outcome agree.
3872
+ */
3873
+ const chooseOptionsLabel = (probe, handle, labels) => (probe.available(handle) ? labels.add : labels.detour);
3874
+
3438
3875
  const CSS = `
3439
3876
  .evs-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
3440
3877
  .evs-search-page{box-sizing:border-box;max-width:var(--evs-max-width,1200px);margin:auto;padding:var(--evs-content-padding-top,24px) var(--evs-content-padding-inline,24px) var(--evs-content-padding-bottom,24px);color:var(--evs-ink);background:var(--evs-background);font-family:var(--evs-font-family)}
@@ -3695,7 +4132,14 @@ const VariantRow = ({ product, design, data, selection, onSelect, onRequestMatri
3695
4132
  ? `Selected ${selectionText}`
3696
4133
  : 'No product options selected' })] }));
3697
4134
  };
3698
- const ProductCard = ({ product, design, onClick, data, selection, onSelect, onRequestMatrix, onAddToCart, quickAddState, variantBusy, hrefFor, }) => {
4135
+ /** The link a tile that cannot resolve a variant carries, and its handoff. */
4136
+ const ChooseOptions = ({ className, href, handle, probe, triggerSelector, children, }) => (jsxRuntime.jsx("a", { className: className, href: href, onClick: (event) => handleChooseOptionsClick(event, {
4137
+ probe,
4138
+ handle,
4139
+ href,
4140
+ ...(triggerSelector ? { triggerSelector } : {}),
4141
+ }), children: children }));
4142
+ const ProductCard = ({ product, design, onClick, data, selection, onSelect, onRequestMatrix, onAddToCart, quickAddState, variantBusy, hrefFor, quickAddProbe, }) => {
3699
4143
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13;
3700
4144
  const lead = product.variants[0];
3701
4145
  const selectedSummary = exactSelectedVariant(data, selection);
@@ -3808,7 +4252,12 @@ const ProductCard = ({ product, design, onClick, data, selection, onSelect, onRe
3808
4252
  ? design.labels.soldOut
3809
4253
  : (quickAddState === null || quickAddState === void 0 ? void 0 : quickAddState.checking)
3810
4254
  ? (_12 = design.labels.checkingAvailability) !== null && _12 !== void 0 ? _12 : 'Checking availability…'
3811
- : atc.label })) : (jsxRuntime.jsx("a", { className: `evs-atc evs-atc-${atc.style} evs-atc-choose-options`, href: hrefFor(product, cartTarget === null || cartTarget === void 0 ? void 0 : cartTarget.id), children: (_13 = design.labels.chooseOptions) !== null && _13 !== void 0 ? _13 : 'Choose options' })))] }));
4255
+ : atc.label })) : (jsxRuntime.jsx(ChooseOptions, { className: `evs-atc evs-atc-${atc.style} evs-atc-choose-options`, href: hrefFor(product, cartTarget === null || cartTarget === void 0 ? void 0 : cartTarget.id), handle: product.handle, probe: quickAddProbe, ...(atc.quickAddTriggerSelector
4256
+ ? { triggerSelector: atc.quickAddTriggerSelector }
4257
+ : {}), children: chooseOptionsLabel(quickAddProbe, product.handle, {
4258
+ add: atc.label,
4259
+ detour: (_13 = design.labels.chooseOptions) !== null && _13 !== void 0 ? _13 : 'Choose options',
4260
+ }) })))] }));
3812
4261
  };
3813
4262
  const PromoCard = ({ card }) => {
3814
4263
  var _a, _b, _c, _d;
@@ -4137,11 +4586,14 @@ const EvoSearchSearchPage = ({ initialSearch, onUrlChange, fallback = null, base
4137
4586
  }));
4138
4587
  }
4139
4588
  };
4589
+ // One probe for the whole pass: its two page-level reads and its per-handle
4590
+ // card lookups walk the document, and every tile asks the same questions.
4591
+ const quickAddProbe = themeQuickAddProbe();
4140
4592
  // Product tiles plus merchant promo cards spliced into their grid slots
4141
4593
  // (first page only — a promo card is a landing moment, not a repeat).
4142
4594
  const gridCells = search.results.map((product) => {
4143
4595
  var _a;
4144
- return (jsxRuntime.jsx(ProductCard, { product: product, design: design, data: variantDataFor(product), selection: (_a = selectedOptions[product.id]) !== null && _a !== void 0 ? _a : {}, onSelect: (axis, value) => void selectProductOption(product, axis, value), onRequestMatrix: () => void requestVariantMatrix(product), onAddToCart: onAddToCart ? safelyAddToCart : undefined, quickAddState: quickAddStates[product.id], variantBusy: variantMatrixBusy.has(product.id), hrefFor: hrefFor, onClick: (selected, productUrl) => {
4596
+ return (jsxRuntime.jsx(ProductCard, { product: product, design: design, data: variantDataFor(product), selection: (_a = selectedOptions[product.id]) !== null && _a !== void 0 ? _a : {}, onSelect: (axis, value) => void selectProductOption(product, axis, value), onRequestMatrix: () => void requestVariantMatrix(product), onAddToCart: onAddToCart ? safelyAddToCart : undefined, quickAddState: quickAddStates[product.id], variantBusy: variantMatrixBusy.has(product.id), hrefFor: hrefFor, quickAddProbe: quickAddProbe, onClick: (selected, productUrl) => {
4145
4597
  var _a, _b;
4146
4598
  return search.trackClick(product.id, {
4147
4599
  productTitle: product.title,
package/hooks.d.ts CHANGED
@@ -261,6 +261,7 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
261
261
  uppercase: boolean
262
262
  afterAdd?: 'auto' | 'stay' | 'navigate'
263
263
  cartToggleSelector?: string
264
+ quickAddTriggerSelector?: string
264
265
  }
265
266
  export type SearchPageTextAlignment = 'start' | 'center' | 'end'
266
267
  export type SearchPageImageFit = 'cover' | 'contain'
@@ -516,6 +517,10 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
516
517
  recents: string[]
517
518
  }
518
519
 
520
+ export type CollectionsAPIResponse = {
521
+ collections: AutocompleteAPICollection[]
522
+ }
523
+
519
524
  export type ImageSearchAPIResponse = {
520
525
  mode: 'image' | 'image+text'
521
526
  products: AutocompleteAPIProduct[]
@@ -639,6 +644,8 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
639
644
  trending?: { term: string; img?: string }[]
640
645
  }
641
646
  }>
647
+ /** Idle-phase featured collections. Merchant hide rules already applied. */
648
+ collections: (signal?: AbortSignal) => Promise<CollectionsAPIResponse>
642
649
  config: (signal?: AbortSignal) => Promise<AskConfigResponse>
643
650
  /**
644
651
  * The shopper's market, for a headless host that routes it itself instead