@blotoutio/providers-evo-search-sdk 1.71.1 → 1.73.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 +7 -2
- package/core.js +7 -2
- package/core.mjs +7 -2
- package/hooks.cjs.js +455 -3
- package/hooks.d.ts +9 -2
- package/hooks.mjs +455 -3
- package/index.cjs.js +2405 -1534
- package/index.js +2405 -1534
- package/index.mjs +2405 -1534
- package/package.json +1 -1
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.
|
|
2538
|
+
sdkVersion: "1.73.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.
|
|
2547
|
+
sdkVersion: "1.73.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.
|
|
2539
|
+
sdkVersion: "1.73.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.
|
|
2548
|
+
sdkVersion: "1.73.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.
|
|
2536
|
+
sdkVersion: "1.73.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.
|
|
2545
|
+
sdkVersion: "1.73.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)}§ion_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
|
-
|
|
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(
|
|
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
|
@@ -132,7 +132,7 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
|
|
|
132
132
|
description: string | null
|
|
133
133
|
url: string | null
|
|
134
134
|
img: string | null
|
|
135
|
-
productCount: number
|
|
135
|
+
productCount: number | null
|
|
136
136
|
score: number
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -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[]
|
|
@@ -579,7 +584,7 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
|
|
|
579
584
|
export type AskEvidenceCollection = {
|
|
580
585
|
id: string
|
|
581
586
|
title: string
|
|
582
|
-
productCount?: number
|
|
587
|
+
productCount?: number | null
|
|
583
588
|
}
|
|
584
589
|
|
|
585
590
|
export type AskEvidence = {
|
|
@@ -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
|