@tabcommerceio/buy-together-sdk 1.0.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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.js +877 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tab commerce technologies llc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Product Bundle SDK
|
|
2
|
+
|
|
3
|
+
`@tabcommerceio/buy-together-sdk` provides the Product Bundle storefront workflow:
|
|
4
|
+
Offer lookup, exact Variant re-quotes, verified **Add bundle**, and independent
|
|
5
|
+
Bundle event delivery. It is browser-targeted ESM with TypeScript declarations
|
|
6
|
+
and no runtime dependencies. It does not import or mount the Cross Sell SDK.
|
|
7
|
+
|
|
8
|
+
## Install and choose the SDK
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @tabcommerceio/buy-together-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use `@tabcommerceio/cross-sell-sdk` for individual recommendations and Cross Sell
|
|
15
|
+
tracking. Use this package for Product Bundle Offers. An Online Store can use
|
|
16
|
+
both packages, with separate clients, roots, tokens, and event observers.
|
|
17
|
+
|
|
18
|
+
`1.0.0` is the first public release of the current component-based contract.
|
|
19
|
+
It replaces the repository's earlier experimental role-based API: components
|
|
20
|
+
are identified by `component_id`, and every purchasable selection carries a
|
|
21
|
+
short `quote_expires_at`. Code using older workspace builds must migrate all
|
|
22
|
+
component tuples and implement the re-quote flow below. The package name stays
|
|
23
|
+
`buy-together-sdk`; a product display-name change does not require an npm rename.
|
|
24
|
+
|
|
25
|
+
## Before connecting
|
|
26
|
+
|
|
27
|
+
Use the installed shop's actual App Proxy path; the examples use the development
|
|
28
|
+
path `/apps/tab-cross-sell-dev`. Requests run in the shopper's Online Store
|
|
29
|
+
browser through Shopify's signed proxy. Do not put Admin API credentials into
|
|
30
|
+
browser code. Resolve and quote require the shop's `api_access` entitlement
|
|
31
|
+
(currently Optimize, or an otherwise eligible enabled plan).
|
|
32
|
+
|
|
33
|
+
An Offer is a peer Product set. Every member Product can be its PDP source.
|
|
34
|
+
The server owns eligible variants, contextual prices, totals, tokens, and the
|
|
35
|
+
Bundle instance identity. Never calculate a replacement quote in the browser.
|
|
36
|
+
The current protocol accepts two to four distinct component Products.
|
|
37
|
+
|
|
38
|
+
## Custom UI: resolve, re-quote, add
|
|
39
|
+
|
|
40
|
+
The following controller uses public exports only. Bind your option controls to
|
|
41
|
+
`chooseVariants`, refresh displayed totals from its returned Offer, and enable
|
|
42
|
+
Add only when `canAdd()` is true. Disable option controls while Add is running.
|
|
43
|
+
Each selection contains **every** component tuple, including unchanged members.
|
|
44
|
+
Replace the example country, currency, proxy path, slot, and locale root with
|
|
45
|
+
current shop facts. Use `offer_ids` instead of `slot_key` when selecting explicit
|
|
46
|
+
Offers; never send both selectors.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import {
|
|
50
|
+
init,
|
|
51
|
+
createQuoteRequestFence,
|
|
52
|
+
type BundleOffer,
|
|
53
|
+
type BundleQuoteSelection,
|
|
54
|
+
} from '@tabcommerceio/buy-together-sdk'
|
|
55
|
+
|
|
56
|
+
const client = init({ proxyBase: '/apps/tab-cross-sell-dev' })
|
|
57
|
+
const context = { country: 'US' }
|
|
58
|
+
const cart = { currency: 'USD' }
|
|
59
|
+
const routeRoot = '/' // Use the shop's Shopify.routes.root, e.g. '/fr/'.
|
|
60
|
+
const fence = createQuoteRequestFence()
|
|
61
|
+
const resolved = await client.resolve({ slot_key: 'summer-bundles', context, cart })
|
|
62
|
+
let tokenSource: BundleOffer | null = resolved?.offers[0] ?? null
|
|
63
|
+
let ready: BundleOffer | null = tokenSource
|
|
64
|
+
let adding = false
|
|
65
|
+
let needsCartRecovery = false
|
|
66
|
+
|
|
67
|
+
export function canAdd(): boolean {
|
|
68
|
+
return !adding && !needsCartRecovery && ready !== null &&
|
|
69
|
+
Date.parse(ready.quote_expires_at) > Date.now()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function chooseVariants(selections: BundleQuoteSelection[]) {
|
|
73
|
+
if (!tokenSource || adding || needsCartRecovery) return null
|
|
74
|
+
const tracking_token = tokenSource.tracking_token
|
|
75
|
+
ready = null // The previous selection cannot remain purchasable.
|
|
76
|
+
const attempt = fence.begin() // Aborts and invalidates the previous request.
|
|
77
|
+
const result = await client.quote({ tracking_token, selections, context, cart }, attempt.signal)
|
|
78
|
+
if (!fence.isCurrent(attempt)) return null
|
|
79
|
+
if (!result?.eligible || !result.offer) return null
|
|
80
|
+
tokenSource = result.offer
|
|
81
|
+
ready = result.offer
|
|
82
|
+
return ready // Render these server-returned totals and selected variants.
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function addSelectedBundle() {
|
|
86
|
+
if (!canAdd() || !ready) return null
|
|
87
|
+
adding = true
|
|
88
|
+
try {
|
|
89
|
+
const result = await client.addAll(ready, { routeRoot })
|
|
90
|
+
if (result.status === 'added') {
|
|
91
|
+
ready = null
|
|
92
|
+
tokenSource = null // Resolve again before starting another Bundle instance.
|
|
93
|
+
} else if (result.status === 'recoverable_error') {
|
|
94
|
+
needsCartRecovery = true
|
|
95
|
+
ready = null
|
|
96
|
+
window.location.assign(`${routeRoot}cart`)
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
} finally {
|
|
100
|
+
adding = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function destroyBundleUI() {
|
|
105
|
+
fence.cancel()
|
|
106
|
+
client.destroy()
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Read a component's eligible `variants` to construct its selected
|
|
111
|
+
`{ component_id, product_id, variant_id }` tuple. Pass only the latest eligible
|
|
112
|
+
server Offer to `addAll()`. Preserve its `bundle_instance_id` and tracking token;
|
|
113
|
+
never mint a replacement instance after the server has signed it. Re-resolve
|
|
114
|
+
when the live Offer is stale. Update country/cart facts and re-resolve when the
|
|
115
|
+
shopper's market or currency changes.
|
|
116
|
+
|
|
117
|
+
### Add outcomes
|
|
118
|
+
|
|
119
|
+
| Status | Meaning and next action |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `added` | Both the add response and a fresh cart read prove the exact component/property tuples. Refresh your cart UI; resolve a new instance for another add. |
|
|
122
|
+
| `failed` | A fresh read proves a clean no-op. Let the shopper retry only with a still-valid quote. |
|
|
123
|
+
| `compensated` | Partial instance lines were removed and the clean result was verified. A deliberate retry with a valid quote is safe. |
|
|
124
|
+
| `recoverable_error` | The cart outcome could not be proven. Stop retries and navigate to a fresh cart; do not mint another request/instance to retry blindly. |
|
|
125
|
+
|
|
126
|
+
The SDK sends one Shopify Ajax `/cart/add.js` multi-item request with the five
|
|
127
|
+
`_tab_bundle_*` private properties. Its compensation targets only lines from
|
|
128
|
+
that exact instance/token. It queues `add_to_cart` only after full cart proof;
|
|
129
|
+
custom consumers must not send a duplicate event. Resolve/quote return `null`
|
|
130
|
+
for transport failures; an ineligible quote must leave Add disabled.
|
|
131
|
+
|
|
132
|
+
## Rendering and tracking
|
|
133
|
+
|
|
134
|
+
`resolvePDP()` returns server-rendered HTML. `mountPDP()` inserts that HTML,
|
|
135
|
+
fences overlapping mounts, and observes impressions. Its request requires the
|
|
136
|
+
currently selected source Product/Variant GIDs and a new `crypto.randomUUID()`
|
|
137
|
+
instance **before** requesting a signed fragment. It does not wire custom UI
|
|
138
|
+
option/add handlers: those remain the host's responsibility. The official
|
|
139
|
+
Theme App Block bundles its own interaction controller and needs no npm install.
|
|
140
|
+
|
|
141
|
+
For custom cards, use `createBundleImpressionObserver` with an observe target
|
|
142
|
+
containing the actual Offer's `offer_id`, `tracking_token`, and `request_id`;
|
|
143
|
+
forward its `onImpression` payload to `client.track`. The public observer enforces
|
|
144
|
+
50% visibility for 500 ms and excludes hidden-tab time. Track clicks with the
|
|
145
|
+
same Offer identity. `client.flush()` drains queued events, and
|
|
146
|
+
`client.destroy()` releases client resources; disconnect any observer you create
|
|
147
|
+
separately. No Bundle root or event belongs to the Cross Sell client.
|
|
148
|
+
|
|
149
|
+
In browsers `init` uses the page's document/window for visibility and pagehide
|
|
150
|
+
flushes. Explicit refs are only needed for custom environments.
|
|
151
|
+
|
|
152
|
+
## Using both packages
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { init as initCrossSell } from '@tabcommerceio/cross-sell-sdk'
|
|
156
|
+
import { init as initBundles } from '@tabcommerceio/buy-together-sdk'
|
|
157
|
+
|
|
158
|
+
const proxyBase = '/apps/tab-cross-sell-dev'
|
|
159
|
+
const crossSell = initCrossSell({ proxyBase })
|
|
160
|
+
const bundles = initBundles({ proxyBase })
|
|
161
|
+
// Bind each client to its own cards and event identities.
|
|
162
|
+
// On teardown: crossSell.destroy(); bundles.destroy().
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The public ESM API supplies data/commerce primitives for your own markup. The
|
|
166
|
+
internal official Theme IIFE is built separately as
|
|
167
|
+
`tab-buy-together-storefront.js` and is excluded from this npm package. After a
|
|
168
|
+
proven official add it synchronizes the theme cart and emits
|
|
169
|
+
`tab:buy-together:cart-changed`; custom ESM consumers refresh their own cart UI.
|
|
170
|
+
|
|
171
|
+
## Maintainer release
|
|
172
|
+
|
|
173
|
+
Source: `tabcommerce/tab-cross-sell`, directory `packages/web/buy-together-sdk`.
|
|
174
|
+
The shared workflow `publish-cross-sell-sdk.yml` publishes exact manifest
|
|
175
|
+
versions for both SDKs from `main` using package-scoped npm Trusted Publishing.
|
|
176
|
+
The initial publication requires an authenticated package owner; configure the
|
|
177
|
+
Bundle package's trust binding after that first publish. Local validation uses
|
|
178
|
+
`pnpm publish:buy-together:dry`; the authorized manual release command is
|
|
179
|
+
`pnpm publish:buy-together`. See `scripts/release/README.md` in the repository
|
|
180
|
+
for digest, authentication, tagging, and post-publication checks.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/** One neutral member Product resolved to the exact Variant for this Offer. */
|
|
2
|
+
type BundleComponent = {
|
|
3
|
+
component_id: string;
|
|
4
|
+
product_id: string;
|
|
5
|
+
variant_id: string;
|
|
6
|
+
};
|
|
7
|
+
type BundleEligibleVariant = {
|
|
8
|
+
variant_id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
option_values: string[];
|
|
11
|
+
available: boolean;
|
|
12
|
+
price: BundleMoney;
|
|
13
|
+
};
|
|
14
|
+
/** Display snapshot returned only by the agency/API resolve route. */
|
|
15
|
+
type BundleResolvedComponent = BundleComponent & {
|
|
16
|
+
product_title: string;
|
|
17
|
+
image_url?: string;
|
|
18
|
+
options: string[];
|
|
19
|
+
variants: BundleEligibleVariant[];
|
|
20
|
+
};
|
|
21
|
+
type BundleMoney = {
|
|
22
|
+
amount: string;
|
|
23
|
+
currency: string;
|
|
24
|
+
};
|
|
25
|
+
/** Minimum cart identity needed to safely add one exact Bundle Offer. */
|
|
26
|
+
type BundleCartOffer = {
|
|
27
|
+
offer_id: string;
|
|
28
|
+
tracking_token: string;
|
|
29
|
+
components: BundleComponent[];
|
|
30
|
+
/**
|
|
31
|
+
* The exact instance UUID signed into tracking_token by the rendered route.
|
|
32
|
+
* Add bundle must use this value verbatim; it must never mint a replacement.
|
|
33
|
+
*/
|
|
34
|
+
bundle_instance_id: string;
|
|
35
|
+
/** Server-enforced short authorization window for this exact selection. */
|
|
36
|
+
quote_expires_at: string;
|
|
37
|
+
};
|
|
38
|
+
type BundleCartLine = {
|
|
39
|
+
product_id?: string;
|
|
40
|
+
variant_id?: string;
|
|
41
|
+
quantity?: number;
|
|
42
|
+
};
|
|
43
|
+
/** Current contextual facts accepted by the Bundle storefront routes. */
|
|
44
|
+
type BundleCartContext = {
|
|
45
|
+
currency: string;
|
|
46
|
+
lines?: BundleCartLine[];
|
|
47
|
+
};
|
|
48
|
+
type BundleRequestContext = {
|
|
49
|
+
country: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Official PDP route: the selected source Product and Variant. Every member
|
|
53
|
+
* Product can be the source; the selected Variant determines that member's
|
|
54
|
+
* cart line while the server resolves first-available Variants for the other
|
|
55
|
+
* neutral members.
|
|
56
|
+
*/
|
|
57
|
+
type BundlePDPRenderedRequest = {
|
|
58
|
+
source_product_id: string;
|
|
59
|
+
source_variant_id: string;
|
|
60
|
+
/** Client-generated UUID supplied before token issuance. */
|
|
61
|
+
bundle_instance_id: string;
|
|
62
|
+
context: BundleRequestContext;
|
|
63
|
+
cart: BundleCartContext;
|
|
64
|
+
};
|
|
65
|
+
/** Agency/API route: exactly one selector (slot_key or offer_ids). */
|
|
66
|
+
type BundleResolveRequest = {
|
|
67
|
+
slot_key: string;
|
|
68
|
+
offer_ids?: never;
|
|
69
|
+
limit?: number;
|
|
70
|
+
context: BundleRequestContext;
|
|
71
|
+
cart: BundleCartContext;
|
|
72
|
+
} | {
|
|
73
|
+
slot_key?: never;
|
|
74
|
+
offer_ids: string[];
|
|
75
|
+
limit?: number;
|
|
76
|
+
context: BundleRequestContext;
|
|
77
|
+
cart: BundleCartContext;
|
|
78
|
+
};
|
|
79
|
+
/** Fully validated agency/API resolve Offer. */
|
|
80
|
+
type BundleOffer = Omit<BundleCartOffer, 'components'> & {
|
|
81
|
+
campaign_id: string;
|
|
82
|
+
publish_version_id: string;
|
|
83
|
+
campaign_version: number;
|
|
84
|
+
revision: number;
|
|
85
|
+
position: number;
|
|
86
|
+
name: string;
|
|
87
|
+
message: string;
|
|
88
|
+
components: BundleResolvedComponent[];
|
|
89
|
+
regular_total: BundleMoney;
|
|
90
|
+
offer_total: BundleMoney;
|
|
91
|
+
savings: BundleMoney;
|
|
92
|
+
request_id: string;
|
|
93
|
+
surface: 'pdp' | 'api_slot' | 'api_offer_ids';
|
|
94
|
+
slot_key?: string;
|
|
95
|
+
};
|
|
96
|
+
type BundleQuoteSelection = BundleComponent;
|
|
97
|
+
type BundleQuoteRequest = {
|
|
98
|
+
/** Previously issued capability; Offer identity and surface come only from its verified claims. */
|
|
99
|
+
tracking_token: string;
|
|
100
|
+
selections: BundleQuoteSelection[];
|
|
101
|
+
context: BundleRequestContext;
|
|
102
|
+
cart: BundleCartContext;
|
|
103
|
+
};
|
|
104
|
+
type BundleQuoteResponse = {
|
|
105
|
+
request_id: string;
|
|
106
|
+
eligible: boolean;
|
|
107
|
+
reason?: 'stale_offer' | 'invalid_selection' | 'temporarily_unavailable';
|
|
108
|
+
offer?: BundleOffer;
|
|
109
|
+
};
|
|
110
|
+
type BundleResolveResponse = {
|
|
111
|
+
request_id: string;
|
|
112
|
+
offers: BundleOffer[];
|
|
113
|
+
};
|
|
114
|
+
type BundleRenderedResponse = {
|
|
115
|
+
html: string;
|
|
116
|
+
request_id: string;
|
|
117
|
+
};
|
|
118
|
+
type BundleEventType = 'impression' | 'click' | 'add_to_cart';
|
|
119
|
+
type BundleTrackInput = {
|
|
120
|
+
event_type: BundleEventType;
|
|
121
|
+
tracking_token: string;
|
|
122
|
+
offer_id: string;
|
|
123
|
+
request_id: string;
|
|
124
|
+
attrs?: Record<string, unknown>;
|
|
125
|
+
event_id?: string;
|
|
126
|
+
occurred_at?: string;
|
|
127
|
+
};
|
|
128
|
+
type BundleStorefrontEvent = Required<Pick<BundleTrackInput, 'event_id' | 'occurred_at'>> & Omit<BundleTrackInput, 'event_id' | 'occurred_at'>;
|
|
129
|
+
type FetchLike = (input: string, init?: {
|
|
130
|
+
method?: string;
|
|
131
|
+
headers?: Record<string, string>;
|
|
132
|
+
body?: string;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
keepalive?: boolean;
|
|
135
|
+
credentials?: RequestCredentials;
|
|
136
|
+
}) => Promise<{
|
|
137
|
+
ok: boolean;
|
|
138
|
+
status: number;
|
|
139
|
+
json(): Promise<unknown>;
|
|
140
|
+
}>;
|
|
141
|
+
type CartLineLike = {
|
|
142
|
+
id?: string | number;
|
|
143
|
+
key?: string;
|
|
144
|
+
quantity?: number;
|
|
145
|
+
properties?: Record<string, unknown> | null;
|
|
146
|
+
};
|
|
147
|
+
type ObserveTarget = {
|
|
148
|
+
element: Element;
|
|
149
|
+
tracking_token: string;
|
|
150
|
+
offer_id: string;
|
|
151
|
+
request_id: string;
|
|
152
|
+
};
|
|
153
|
+
type VisibilityEvidence = {
|
|
154
|
+
ratio: number;
|
|
155
|
+
duration_ms: number;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `failed` is a proven clean no-op: a successful fresh cart read showed that
|
|
160
|
+
* nothing of this instance reached the cart, so no compensation ran.
|
|
161
|
+
* `compensated` means partially written lines were zeroed.
|
|
162
|
+
* `recoverable_error` covers every unproven outcome: instance lines whose
|
|
163
|
+
* cart/update.js compensation failed, and double network failures where the
|
|
164
|
+
* add may have been accepted but the cart read could not prove either way.
|
|
165
|
+
* Callers must not blind-retry a `recoverable_error` as if it were `failed`.
|
|
166
|
+
*/
|
|
167
|
+
type BundleAddResult = {
|
|
168
|
+
status: 'added';
|
|
169
|
+
} | {
|
|
170
|
+
status: 'failed';
|
|
171
|
+
} | {
|
|
172
|
+
status: 'compensated';
|
|
173
|
+
} | {
|
|
174
|
+
status: 'recoverable_error';
|
|
175
|
+
};
|
|
176
|
+
type AddBundleToCartOptions = {
|
|
177
|
+
offer: BundleCartOffer;
|
|
178
|
+
fetchImpl?: FetchLike;
|
|
179
|
+
track: (input: BundleTrackInput) => void;
|
|
180
|
+
/** Injectable for deterministic tests; defaults to crypto.randomUUID(). */
|
|
181
|
+
createId?: () => string;
|
|
182
|
+
/** Shopify.routes.root, e.g. `/` or `/fr/`. */
|
|
183
|
+
routeRoot?: string;
|
|
184
|
+
onAdded?: () => void | Promise<void>;
|
|
185
|
+
/** Injectable epoch milliseconds for quote-expiry tests. */
|
|
186
|
+
now?: () => number;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Adds every exact signed component in one Shopify Ajax Cart request. It does
|
|
190
|
+
* not optimistically track success: the add response *and* a fresh cart read
|
|
191
|
+
* must both prove each exact Variant/private-property tuple.
|
|
192
|
+
*/
|
|
193
|
+
declare function addBundleToCart(options: AddBundleToCartOptions): Promise<BundleAddResult>;
|
|
194
|
+
|
|
195
|
+
type BuyTogetherInitOptions = {
|
|
196
|
+
proxyBase: string;
|
|
197
|
+
fetchImpl?: FetchLike;
|
|
198
|
+
flushIntervalMs?: number;
|
|
199
|
+
flushThreshold?: number;
|
|
200
|
+
createEventId?: () => string;
|
|
201
|
+
now?: () => number;
|
|
202
|
+
documentRef?: Document;
|
|
203
|
+
windowRef?: Window;
|
|
204
|
+
IntersectionObserverImpl?: typeof IntersectionObserver;
|
|
205
|
+
};
|
|
206
|
+
type BundleMountPDPOptions = {
|
|
207
|
+
root: Element;
|
|
208
|
+
request: BundlePDPRenderedRequest;
|
|
209
|
+
signal?: AbortSignal;
|
|
210
|
+
};
|
|
211
|
+
type BuyTogetherClient = {
|
|
212
|
+
resolve: (request: BundleResolveRequest, signal?: AbortSignal) => Promise<BundleResolveResponse | null>;
|
|
213
|
+
resolvePDP: (request: BundlePDPRenderedRequest, signal?: AbortSignal) => Promise<BundleRenderedResponse | null>;
|
|
214
|
+
quote: (request: BundleQuoteRequest, signal?: AbortSignal) => Promise<BundleQuoteResponse | null>;
|
|
215
|
+
mountPDP: (options: BundleMountPDPOptions) => Promise<() => void>;
|
|
216
|
+
addAll: (offer: BundleCartOffer, options?: Omit<AddBundleToCartOptions, 'offer' | 'track' | 'fetchImpl'>) => Promise<BundleAddResult>;
|
|
217
|
+
track: (input: BundleTrackInput) => void;
|
|
218
|
+
flush: () => Promise<void>;
|
|
219
|
+
destroy: () => void;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* The Bundle renderer has independent data attributes. Do not make a card
|
|
223
|
+
* trackable from Cross Sell provenance or from generic product markup.
|
|
224
|
+
*/
|
|
225
|
+
declare function collectBundleObserveTargets(root: Element): ObserveTarget[];
|
|
226
|
+
declare function init(options: BuyTogetherInitOptions): BuyTogetherClient;
|
|
227
|
+
|
|
228
|
+
declare const MAX_BUNDLE_EVENTS_BATCH = 100;
|
|
229
|
+
type BundleEventQueueOptions = {
|
|
230
|
+
proxyBase?: string;
|
|
231
|
+
fetchImpl?: FetchLike;
|
|
232
|
+
createEventId?: () => string;
|
|
233
|
+
now?: () => number;
|
|
234
|
+
onEnqueued?: (count: number) => void;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Deliberately separate from the Cross Sell queue: Bundle event payloads carry
|
|
238
|
+
* Offer identity and are delivered only to the Bundle event route.
|
|
239
|
+
*/
|
|
240
|
+
declare function createBundleEventQueue(options?: BundleEventQueueOptions): {
|
|
241
|
+
track: (input: BundleTrackInput) => BundleStorefrontEvent;
|
|
242
|
+
enqueue: (event: BundleStorefrontEvent) => void;
|
|
243
|
+
flush: () => Promise<void>;
|
|
244
|
+
pending: () => BundleStorefrontEvent[];
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
type BundleFlushControllerOptions = {
|
|
248
|
+
flush: () => Promise<void>;
|
|
249
|
+
intervalMs?: number;
|
|
250
|
+
threshold?: number;
|
|
251
|
+
setIntervalImpl?: (callback: () => void, intervalMs: number) => number | ReturnType<typeof setInterval>;
|
|
252
|
+
clearIntervalImpl?: (handle: number | ReturnType<typeof setInterval>) => void;
|
|
253
|
+
documentRef?: Document;
|
|
254
|
+
windowRef?: Window;
|
|
255
|
+
};
|
|
256
|
+
/** Page-lifecycle delivery coordinator owned solely by the Bundle event queue. */
|
|
257
|
+
declare function createBundleFlushController(options: BundleFlushControllerOptions): {
|
|
258
|
+
start: () => void;
|
|
259
|
+
stop: () => void;
|
|
260
|
+
flushNow: () => Promise<void>;
|
|
261
|
+
notifyEnqueued(count: number): void;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
declare const BUNDLE_IMPRESSION_MIN_RATIO = 0.5;
|
|
265
|
+
declare const BUNDLE_IMPRESSION_MIN_DURATION_MS = 500;
|
|
266
|
+
type BundleImpressionPayload = {
|
|
267
|
+
tracking_token: string;
|
|
268
|
+
offer_id: string;
|
|
269
|
+
request_id: string;
|
|
270
|
+
attrs: {
|
|
271
|
+
visibility: VisibilityEvidence;
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
type BundleImpressionObserverOptions = {
|
|
275
|
+
onImpression: (payload: BundleImpressionPayload) => void;
|
|
276
|
+
IntersectionObserverImpl?: typeof IntersectionObserver;
|
|
277
|
+
now?: () => number;
|
|
278
|
+
setTimeoutImpl?: (callback: () => void, delayMs: number) => number | ReturnType<typeof setTimeout>;
|
|
279
|
+
clearTimeoutImpl?: (handle: number | ReturnType<typeof setTimeout>) => void;
|
|
280
|
+
documentRef?: Document;
|
|
281
|
+
};
|
|
282
|
+
/** Independent 50%/500ms Bundle observer, never shared with Cross Sell. */
|
|
283
|
+
declare function createBundleImpressionObserver(options: BundleImpressionObserverOptions): {
|
|
284
|
+
observe: (_items: ObserveTarget[]) => void;
|
|
285
|
+
disconnect: () => void;
|
|
286
|
+
seenTokens: () => Set<string>;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
type PDPRequestSequencerOptions<Request, Result> = {
|
|
290
|
+
resolve: (request: Request, signal: AbortSignal) => Promise<Result | null>;
|
|
291
|
+
/** Called synchronously for every source-variant change. */
|
|
292
|
+
onClear: () => void;
|
|
293
|
+
/** Only receives results that still belong to the current request. */
|
|
294
|
+
onResult: (result: Result) => void;
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Keeps PDP output tied to the currently selected variant. A variation switch
|
|
298
|
+
* immediately removes stale bundle content, aborts the old fetch, and fences
|
|
299
|
+
* late responses by monotonically increasing request generation.
|
|
300
|
+
*/
|
|
301
|
+
declare function createPDPRequestSequencer<Request, Result>(options: PDPRequestSequencerOptions<Request, Result>): {
|
|
302
|
+
run: (request: Request) => Promise<Result | null>;
|
|
303
|
+
cancel: () => void;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
type QuoteAttempt = {
|
|
307
|
+
revision: number;
|
|
308
|
+
signal: AbortSignal;
|
|
309
|
+
};
|
|
310
|
+
/** One-lane quote fence: every new selection aborts and invalidates its predecessor. */
|
|
311
|
+
declare function createQuoteRequestFence(): {
|
|
312
|
+
begin(): QuoteAttempt;
|
|
313
|
+
isCurrent(attempt: QuoteAttempt): boolean;
|
|
314
|
+
cancel(): void;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
type ResolveOptions = {
|
|
318
|
+
/** App Proxy base, e.g. `/apps/tab-cross-sell-dev`. */
|
|
319
|
+
proxyBase: string;
|
|
320
|
+
fetchImpl?: FetchLike;
|
|
321
|
+
signal?: AbortSignal;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Reject malformed selector requests before they cross the App Proxy. This is
|
|
325
|
+
* defense-in-depth; customer-api repeats all selector validation authoritatively.
|
|
326
|
+
*/
|
|
327
|
+
declare function isValidBundleResolveRequest(request: BundleResolveRequest): boolean;
|
|
328
|
+
/** Agency SDK resolve; failed/invalid response remains an empty integration. */
|
|
329
|
+
declare function resolveBundleOffers(request: BundleResolveRequest, options: ResolveOptions): Promise<BundleResolveResponse | null>;
|
|
330
|
+
/** Official PDP-only rendered resolve. It does not accept slots or explicit IDs. */
|
|
331
|
+
declare function resolveBundlePDP(request: BundlePDPRenderedRequest, options: ResolveOptions): Promise<BundleRenderedResponse | null>;
|
|
332
|
+
/** Re-quotes one exact selection; malformed/stale responses never become addable. */
|
|
333
|
+
declare function quoteBundleOffer(request: BundleQuoteRequest, options: ResolveOptions): Promise<BundleQuoteResponse | null>;
|
|
334
|
+
|
|
335
|
+
export { type AddBundleToCartOptions, BUNDLE_IMPRESSION_MIN_DURATION_MS, BUNDLE_IMPRESSION_MIN_RATIO, type BundleAddResult, type BundleCartContext, type BundleCartLine, type BundleCartOffer, type BundleComponent, type BundleEligibleVariant, type BundleEventType, type BundleMoney, type BundleMountPDPOptions, type BundleOffer, type BundlePDPRenderedRequest, type BundleQuoteRequest, type BundleQuoteResponse, type BundleQuoteSelection, type BundleRenderedResponse, type BundleRequestContext, type BundleResolveRequest, type BundleResolveResponse, type BundleResolvedComponent, type BundleStorefrontEvent, type BundleTrackInput, type BuyTogetherClient, type BuyTogetherInitOptions, type CartLineLike, type FetchLike, MAX_BUNDLE_EVENTS_BATCH, type ObserveTarget, type VisibilityEvidence, addBundleToCart, collectBundleObserveTargets, createBundleEventQueue, createBundleFlushController, createBundleImpressionObserver, createPDPRequestSequencer, createQuoteRequestFence, init, isValidBundleResolveRequest, quoteBundleOffer, resolveBundleOffers, resolveBundlePDP };
|