@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/dist/index.js ADDED
@@ -0,0 +1,877 @@
1
+ // src/uuid.ts
2
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3
+ var NIL_UUID = "00000000-0000-0000-0000-000000000000";
4
+ function isUUID(value) {
5
+ return UUID_PATTERN.test(value);
6
+ }
7
+ function isCanonicalUUID(value) {
8
+ return typeof value === "string" && value !== NIL_UUID && value === value.toLowerCase() && isUUID(value);
9
+ }
10
+ function createUUID() {
11
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
12
+ return crypto.randomUUID();
13
+ }
14
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (character) => {
15
+ const random = Math.random() * 16 | 0;
16
+ const nibble = character === "x" ? random : random & 3 | 8;
17
+ return nibble.toString(16);
18
+ });
19
+ }
20
+
21
+ // src/commerce.ts
22
+ var VARIANT_GID = /^gid:\/\/shopify\/ProductVariant\/([1-9]\d*)$/;
23
+ var PRODUCT_GID = /^gid:\/\/shopify\/Product\/([1-9]\d*)$/;
24
+ function routeURL(routeRoot, path) {
25
+ let root = typeof routeRoot === "string" && routeRoot ? routeRoot : "/";
26
+ if (!root.endsWith("/")) root += "/";
27
+ return `${root}${path}`;
28
+ }
29
+ function componentsAreExact(components) {
30
+ if (components.length < 2 || components.length > 4) return false;
31
+ const componentIDs = /* @__PURE__ */ new Set();
32
+ const products = /* @__PURE__ */ new Set();
33
+ const variants = /* @__PURE__ */ new Set();
34
+ for (const component of components) {
35
+ if (!component || !isCanonicalUUID(component.component_id) || !PRODUCT_GID.test(component.product_id) || !VARIANT_GID.test(component.variant_id)) {
36
+ return false;
37
+ }
38
+ if (componentIDs.has(component.component_id) || products.has(component.product_id) || variants.has(component.variant_id)) return false;
39
+ componentIDs.add(component.component_id);
40
+ products.add(component.product_id);
41
+ variants.add(component.variant_id);
42
+ }
43
+ return true;
44
+ }
45
+ function requiredLines(offer, instanceID, requestID) {
46
+ if (!isCanonicalUUID(offer.offer_id) || !offer.tracking_token.trim() || !isCanonicalUUID(offer.bundle_instance_id) || !isCanonicalUUID(requestID) || !componentsAreExact(offer.components)) {
47
+ return null;
48
+ }
49
+ const lines = [];
50
+ for (const component of offer.components) {
51
+ const match = component.variant_id.match(VARIANT_GID);
52
+ if (!match) return null;
53
+ lines.push({
54
+ component,
55
+ variantNumericID: match[1],
56
+ properties: {
57
+ _tab_bundle_instance_id: instanceID,
58
+ _tab_bundle_tracking_token: offer.tracking_token,
59
+ _tab_bundle_offer_id: offer.offer_id,
60
+ _tab_bundle_component_id: component.component_id,
61
+ _tab_bundle_request_id: requestID
62
+ }
63
+ });
64
+ }
65
+ return lines;
66
+ }
67
+ function cartLinesFrom(body) {
68
+ if (!body || typeof body !== "object") return [];
69
+ const items = body.items;
70
+ return Array.isArray(items) ? items : [];
71
+ }
72
+ function lineMatches(line, wanted) {
73
+ if (String(line.id ?? "") !== wanted.variantNumericID || Number(line.quantity ?? 0) < 1) return false;
74
+ const properties = line.properties;
75
+ if (!properties || typeof properties !== "object") return false;
76
+ return Object.entries(wanted.properties).every(([key, value]) => properties[key] === value);
77
+ }
78
+ function provesAll(lines, expected) {
79
+ return expected.every((wanted) => lines.some((line) => lineMatches(line, wanted)));
80
+ }
81
+ function hasExpectedLine(lines, expected) {
82
+ return expected.some((wanted) => lines.some((line) => lineMatches(line, wanted)));
83
+ }
84
+ function matchingKeys(lines, expected) {
85
+ const keys = /* @__PURE__ */ new Set();
86
+ for (const line of lines) {
87
+ if (typeof line.key !== "string" || !line.key) continue;
88
+ if (expected.some((wanted) => lineMatches(line, wanted))) keys.add(line.key);
89
+ }
90
+ return [...keys];
91
+ }
92
+ async function safeJSON(response) {
93
+ try {
94
+ return await response.json();
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ async function compensate(fetchImpl, routeRoot, keys, provedAbsence, expected) {
100
+ if (keys.length === 0) {
101
+ return provedAbsence ? { status: "failed" } : { status: "recoverable_error" };
102
+ }
103
+ try {
104
+ const response = await fetchImpl(routeURL(routeRoot, "cart/update.js"), {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({ updates: Object.fromEntries(keys.map((key) => [key, 0])) }),
108
+ credentials: "same-origin"
109
+ });
110
+ if (!response.ok) return { status: "recoverable_error" };
111
+ const cartResponse = await fetchImpl(routeURL(routeRoot, "cart.js"), {
112
+ method: "GET",
113
+ credentials: "same-origin"
114
+ });
115
+ if (!cartResponse.ok) return { status: "recoverable_error" };
116
+ const body = await safeJSON(cartResponse);
117
+ const items = body && typeof body === "object" ? body.items : null;
118
+ if (!Array.isArray(items)) return { status: "recoverable_error" };
119
+ return hasExpectedLine(items, expected) ? { status: "recoverable_error" } : { status: "compensated" };
120
+ } catch {
121
+ return { status: "recoverable_error" };
122
+ }
123
+ }
124
+ async function addBundleToCart(options) {
125
+ const quoteExpiry = Date.parse(options.offer.quote_expires_at);
126
+ if (!Number.isFinite(quoteExpiry) || quoteExpiry <= (options.now ?? Date.now)()) return { status: "failed" };
127
+ const createId = options.createId ?? createUUID;
128
+ const instanceID = options.offer.bundle_instance_id;
129
+ const requestID = createId();
130
+ const expected = requiredLines(options.offer, instanceID, requestID);
131
+ if (!expected) return { status: "failed" };
132
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
133
+ let addLines = [];
134
+ let addProved = false;
135
+ try {
136
+ const addResponse = await fetchImpl(routeURL(options.routeRoot, "cart/add.js"), {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({
140
+ items: expected.map((wanted) => ({
141
+ id: Number(wanted.variantNumericID),
142
+ quantity: 1,
143
+ properties: wanted.properties
144
+ }))
145
+ }),
146
+ credentials: "same-origin"
147
+ });
148
+ if (addResponse.ok) {
149
+ addLines = cartLinesFrom(await safeJSON(addResponse));
150
+ addProved = provesAll(addLines, expected);
151
+ }
152
+ } catch {
153
+ }
154
+ let cartLines = [];
155
+ let cartProved = false;
156
+ let cartReadOK = false;
157
+ try {
158
+ const cartResponse = await fetchImpl(routeURL(options.routeRoot, "cart.js"), {
159
+ method: "GET",
160
+ credentials: "same-origin"
161
+ });
162
+ if (cartResponse.ok) {
163
+ const body = await safeJSON(cartResponse);
164
+ const items = body && typeof body === "object" ? body.items : null;
165
+ if (Array.isArray(items)) {
166
+ cartReadOK = true;
167
+ cartLines = items;
168
+ cartProved = provesAll(cartLines, expected);
169
+ }
170
+ }
171
+ } catch {
172
+ }
173
+ if (addProved && cartProved) {
174
+ try {
175
+ options.track({
176
+ event_type: "add_to_cart",
177
+ offer_id: options.offer.offer_id,
178
+ request_id: requestID,
179
+ tracking_token: options.offer.tracking_token
180
+ });
181
+ } catch {
182
+ }
183
+ try {
184
+ await options.onAdded?.();
185
+ } catch {
186
+ }
187
+ return { status: "added" };
188
+ }
189
+ const keys = cartReadOK ? matchingKeys(cartLines, expected) : matchingKeys(addLines, expected);
190
+ return compensate(fetchImpl, options.routeRoot, keys, cartReadOK, expected);
191
+ }
192
+
193
+ // src/events.ts
194
+ var MAX_BUNDLE_EVENTS_BATCH = 100;
195
+ var MAX_PENDING_BUNDLE_EVENTS = 100;
196
+ var RETRY_BACKOFF_BASE_MS = 2e3;
197
+ var RETRY_BACKOFF_MAX_MS = 6e4;
198
+ var MAX_BUNDLE_EVENTS_BATCH_BYTES = 56 * 1024;
199
+ function normalizeProxyBase(proxyBase) {
200
+ return proxyBase.replace(/\/+$/, "");
201
+ }
202
+ function utf8Length(value) {
203
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(value).length;
204
+ return value.length;
205
+ }
206
+ var BATCH_ENVELOPE_BYTES = utf8Length('{"items":[]}');
207
+ function nextBatch(pending, remaining) {
208
+ const batch = [];
209
+ let bytes = BATCH_ENVELOPE_BYTES;
210
+ const max = Math.min(MAX_BUNDLE_EVENTS_BATCH, remaining, pending.length);
211
+ for (let index = 0; index < max; index += 1) {
212
+ const eventBytes = utf8Length(JSON.stringify(pending[index])) + (batch.length > 0 ? 1 : 0);
213
+ if (batch.length > 0 && bytes + eventBytes > MAX_BUNDLE_EVENTS_BATCH_BYTES) break;
214
+ batch.push(pending[index]);
215
+ bytes += eventBytes;
216
+ }
217
+ return { batch, overBudget: bytes > MAX_BUNDLE_EVENTS_BATCH_BYTES };
218
+ }
219
+ function createBundleEventQueue(options = {}) {
220
+ const pending = [];
221
+ const createEventId = options.createEventId ?? createUUID;
222
+ const now = options.now ?? (() => Date.now());
223
+ let backoffMs = 0;
224
+ let nextAttemptAt = 0;
225
+ function enqueue(event) {
226
+ if (pending.length >= MAX_PENDING_BUNDLE_EVENTS) pending.shift();
227
+ pending.push(event);
228
+ options.onEnqueued?.(pending.length);
229
+ }
230
+ function track(input) {
231
+ const event = {
232
+ event_id: input.event_id ?? createEventId(),
233
+ occurred_at: input.occurred_at ?? new Date(now()).toISOString(),
234
+ event_type: input.event_type,
235
+ tracking_token: input.tracking_token,
236
+ offer_id: input.offer_id,
237
+ request_id: input.request_id,
238
+ attrs: input.attrs
239
+ };
240
+ enqueue(event);
241
+ return event;
242
+ }
243
+ function scheduleRetry() {
244
+ backoffMs = Math.min(
245
+ backoffMs > 0 ? backoffMs * 2 : RETRY_BACKOFF_BASE_MS,
246
+ RETRY_BACKOFF_MAX_MS
247
+ );
248
+ nextAttemptAt = now() + backoffMs;
249
+ }
250
+ async function flush() {
251
+ if (pending.length === 0) return;
252
+ if (!options.proxyBase) throw new Error("proxyBase is required to flush Bundle events");
253
+ if (now() < nextAttemptAt) return;
254
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
255
+ const countAtStart = pending.length;
256
+ let processed = 0;
257
+ while (processed < countAtStart) {
258
+ const { batch, overBudget } = nextBatch(pending, countAtStart - processed);
259
+ if (batch.length === 0) return;
260
+ let response;
261
+ try {
262
+ response = await fetchImpl(`${normalizeProxyBase(options.proxyBase)}/bundle-offers/events`, {
263
+ method: "POST",
264
+ headers: { "Content-Type": "application/json" },
265
+ body: JSON.stringify({ items: batch }),
266
+ // An over-budget single event would be rejected synchronously with
267
+ // keepalive; deliver it as an ordinary request instead.
268
+ keepalive: !overBudget,
269
+ credentials: "same-origin"
270
+ });
271
+ } catch {
272
+ scheduleRetry();
273
+ return;
274
+ }
275
+ if (!response.ok && (response.status === 429 || response.status >= 500)) {
276
+ scheduleRetry();
277
+ return;
278
+ }
279
+ backoffMs = 0;
280
+ nextAttemptAt = 0;
281
+ const delivered = new Set(batch.map((event) => event.event_id));
282
+ for (let index = pending.length - 1; index >= 0; index -= 1) {
283
+ if (delivered.has(pending[index].event_id)) pending.splice(index, 1);
284
+ }
285
+ processed += batch.length;
286
+ }
287
+ }
288
+ return { track, enqueue, flush, pending: () => pending.slice() };
289
+ }
290
+
291
+ // src/flush.ts
292
+ function createBundleFlushController(options) {
293
+ const intervalMs = options.intervalMs ?? 2e3;
294
+ const threshold = options.threshold ?? 10;
295
+ const setIntervalImpl = options.setIntervalImpl ?? setInterval;
296
+ const clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
297
+ let timer = null;
298
+ let flushing = false;
299
+ let flushAgain = false;
300
+ async function flushNow() {
301
+ if (flushing) {
302
+ flushAgain = true;
303
+ return;
304
+ }
305
+ flushing = true;
306
+ try {
307
+ do {
308
+ flushAgain = false;
309
+ await options.flush();
310
+ } while (flushAgain);
311
+ } finally {
312
+ flushing = false;
313
+ }
314
+ }
315
+ const onLifecycle = () => {
316
+ void flushNow().catch(() => {
317
+ });
318
+ };
319
+ function start() {
320
+ if (timer !== null) return;
321
+ timer = setIntervalImpl(onLifecycle, intervalMs);
322
+ options.documentRef?.addEventListener("visibilitychange", onLifecycle);
323
+ options.windowRef?.addEventListener("pagehide", onLifecycle);
324
+ }
325
+ function stop() {
326
+ if (timer !== null) {
327
+ clearIntervalImpl(timer);
328
+ timer = null;
329
+ }
330
+ options.documentRef?.removeEventListener("visibilitychange", onLifecycle);
331
+ options.windowRef?.removeEventListener("pagehide", onLifecycle);
332
+ }
333
+ return {
334
+ start,
335
+ stop,
336
+ flushNow,
337
+ notifyEnqueued(count) {
338
+ if (count >= threshold) void flushNow().catch(() => {
339
+ });
340
+ }
341
+ };
342
+ }
343
+
344
+ // src/observer.ts
345
+ var BUNDLE_IMPRESSION_MIN_RATIO = 0.5;
346
+ var BUNDLE_IMPRESSION_MIN_DURATION_MS = 500;
347
+ var RATIO_EPSILON = 1e-4;
348
+ function createBundleImpressionObserver(options) {
349
+ const now = options.now ?? (() => Date.now());
350
+ const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
351
+ const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
352
+ const Observer = options.IntersectionObserverImpl ?? globalThis.IntersectionObserver;
353
+ const seen = /* @__PURE__ */ new Set();
354
+ const targets = /* @__PURE__ */ new WeakMap();
355
+ const pending = /* @__PURE__ */ new Map();
356
+ function hidden() {
357
+ return options.documentRef?.hidden === true;
358
+ }
359
+ function emitAfterThreshold(item) {
360
+ if (item.timer !== null || hidden()) return;
361
+ item.startedAt = now();
362
+ item.timer = setTimeoutImpl(() => {
363
+ const current = pending.get(item.target.tracking_token);
364
+ if (!current || seen.has(current.target.tracking_token) || hidden()) return;
365
+ current.timer = null;
366
+ seen.add(current.target.tracking_token);
367
+ pending.delete(current.target.tracking_token);
368
+ options.onImpression({
369
+ tracking_token: current.target.tracking_token,
370
+ offer_id: current.target.offer_id,
371
+ request_id: current.target.request_id,
372
+ attrs: {
373
+ visibility: { ratio: current.ratio, duration_ms: now() - current.startedAt }
374
+ }
375
+ });
376
+ }, BUNDLE_IMPRESSION_MIN_DURATION_MS);
377
+ }
378
+ const onVisibilityChange = () => {
379
+ for (const item of pending.values()) {
380
+ if (hidden()) {
381
+ if (item.timer !== null) clearTimeoutImpl(item.timer);
382
+ item.timer = null;
383
+ } else {
384
+ emitAfterThreshold(item);
385
+ }
386
+ }
387
+ };
388
+ if (!Observer) {
389
+ return {
390
+ observe: (_items) => {
391
+ },
392
+ disconnect: () => {
393
+ },
394
+ seenTokens: () => new Set(seen)
395
+ };
396
+ }
397
+ const observer = new Observer(
398
+ (entries) => {
399
+ for (const entry of entries) {
400
+ const target = targets.get(entry.target);
401
+ if (!target || seen.has(target.tracking_token)) continue;
402
+ const qualifies = entry.isIntersecting && entry.intersectionRatio >= BUNDLE_IMPRESSION_MIN_RATIO - RATIO_EPSILON;
403
+ const current = pending.get(target.tracking_token);
404
+ if (!qualifies) {
405
+ if (current?.timer !== null && current?.timer !== void 0) clearTimeoutImpl(current.timer);
406
+ pending.delete(target.tracking_token);
407
+ continue;
408
+ }
409
+ if (current) {
410
+ current.ratio = entry.intersectionRatio;
411
+ current.target = target;
412
+ emitAfterThreshold(current);
413
+ continue;
414
+ }
415
+ const item = {
416
+ target,
417
+ ratio: entry.intersectionRatio,
418
+ startedAt: now(),
419
+ timer: null
420
+ };
421
+ pending.set(target.tracking_token, item);
422
+ emitAfterThreshold(item);
423
+ }
424
+ },
425
+ { threshold: [0, BUNDLE_IMPRESSION_MIN_RATIO, 1] }
426
+ );
427
+ options.documentRef?.addEventListener("visibilitychange", onVisibilityChange);
428
+ return {
429
+ observe(items) {
430
+ for (const item of items) {
431
+ targets.set(item.element, item);
432
+ observer.observe(item.element);
433
+ }
434
+ },
435
+ disconnect() {
436
+ for (const item of pending.values()) {
437
+ if (item.timer !== null) clearTimeoutImpl(item.timer);
438
+ }
439
+ pending.clear();
440
+ observer.disconnect();
441
+ options.documentRef?.removeEventListener("visibilitychange", onVisibilityChange);
442
+ },
443
+ seenTokens: () => new Set(seen)
444
+ };
445
+ }
446
+
447
+ // src/resolve.ts
448
+ var SLOT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
449
+ var PRODUCT_GID2 = /^gid:\/\/shopify\/Product\/[1-9]\d{0,19}$/;
450
+ var VARIANT_GID2 = /^gid:\/\/shopify\/ProductVariant\/[1-9]\d{0,19}$/;
451
+ var MONEY_AMOUNT = /^(?:0|[1-9]\d*)(?:\.\d+)?$/;
452
+ var DEFAULT_RESOLVE_LIMIT = 10;
453
+ var MAX_POSITION = 2147483647;
454
+ function normalizeProxyBase2(proxyBase) {
455
+ return proxyBase.replace(/\/+$/, "");
456
+ }
457
+ function validContext(request) {
458
+ return Boolean(request.context?.country?.trim() && request.cart?.currency?.trim());
459
+ }
460
+ function isRecord(value) {
461
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
462
+ }
463
+ function isNonBlankString(value) {
464
+ return typeof value === "string" && value.trim().length > 0;
465
+ }
466
+ function isPositiveInteger(value) {
467
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
468
+ }
469
+ function isPosition(value) {
470
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_POSITION;
471
+ }
472
+ function hasPositiveMoney(value, currency) {
473
+ if (!isRecord(value)) return false;
474
+ const amount = value.amount;
475
+ return typeof amount === "string" && amount === amount.trim() && MONEY_AMOUNT.test(amount) && /[1-9]/.test(amount) && value.currency === currency;
476
+ }
477
+ function hasExactComponents(value, currency) {
478
+ if (!Array.isArray(value) || value.length < 2 || value.length > 4) return false;
479
+ const componentIDs = /* @__PURE__ */ new Set();
480
+ const products = /* @__PURE__ */ new Set();
481
+ const variants = /* @__PURE__ */ new Set();
482
+ let previousProductID = null;
483
+ return value.every((candidate) => {
484
+ if (!isRecord(candidate)) return false;
485
+ const componentID = candidate.component_id;
486
+ const productID = candidate.product_id;
487
+ const variantID = candidate.variant_id;
488
+ const options = candidate.options;
489
+ const eligibleVariants = candidate.variants;
490
+ if (!isCanonicalUUID(componentID) || typeof productID !== "string" || !PRODUCT_GID2.test(productID) || typeof variantID !== "string" || !VARIANT_GID2.test(variantID) || !isNonBlankString(candidate.product_title) || !Array.isArray(options) || !options.every(isNonBlankString) || !Array.isArray(eligibleVariants) || eligibleVariants.length < 1 || !eligibleVariants.every((variant) => hasEligibleVariant(variant, currency)) || !eligibleVariants.some((variant) => isRecord(variant) && variant.variant_id === variantID && variant.available === true) || "image_url" in candidate && !isNonBlankString(candidate.image_url) || componentIDs.has(componentID) || products.has(productID) || variants.has(variantID) || previousProductID !== null && previousProductID >= productID) {
491
+ return false;
492
+ }
493
+ componentIDs.add(componentID);
494
+ products.add(productID);
495
+ variants.add(variantID);
496
+ previousProductID = productID;
497
+ return true;
498
+ });
499
+ }
500
+ function hasEligibleVariant(value, currency) {
501
+ if (!isRecord(value)) return false;
502
+ return typeof value.variant_id === "string" && VARIANT_GID2.test(value.variant_id) && isNonBlankString(value.title) && Array.isArray(value.option_values) && value.option_values.every(isNonBlankString) && value.available === true && hasPositiveMoney(value.price, currency);
503
+ }
504
+ function hasFutureQuoteExpiry(value) {
505
+ if (typeof value !== "string") return false;
506
+ const timestamp = Date.parse(value);
507
+ return Number.isFinite(timestamp) && timestamp > Date.now();
508
+ }
509
+ function hasValidOfferSurface(offer, request, expectedSurface, expectedSlot) {
510
+ if (expectedSurface) {
511
+ return offer.surface === expectedSurface && (expectedSurface === "api_slot" ? offer.slot_key === expectedSlot : offer.slot_key === void 0);
512
+ }
513
+ if (typeof request.slot_key === "string") {
514
+ return offer.surface === "api_slot" && offer.slot_key === request.slot_key && request.slot_key.length <= 64 && SLOT_KEY.test(request.slot_key);
515
+ }
516
+ return offer.surface === "api_offer_ids" && offer.slot_key === void 0;
517
+ }
518
+ function hasContractBundleOffer(value, requestID, request, expectedSurface, expectedSlot) {
519
+ if (!isRecord(value)) return false;
520
+ const trackingToken = value.tracking_token;
521
+ return isCanonicalUUID(value.campaign_id) && isCanonicalUUID(value.offer_id) && isCanonicalUUID(value.publish_version_id) && isPositiveInteger(value.campaign_version) && isPositiveInteger(value.revision) && isPosition(value.position) && isNonBlankString(value.name) && isNonBlankString(value.message) && hasExactComponents(value.components, request.cart.currency) && hasPositiveMoney(value.regular_total, request.cart.currency) && hasPositiveMoney(value.offer_total, request.cart.currency) && hasPositiveMoney(value.savings, request.cart.currency) && typeof trackingToken === "string" && trackingToken === trackingToken.trim() && trackingToken.length > 0 && isCanonicalUUID(value.bundle_instance_id) && hasFutureQuoteExpiry(value.quote_expires_at) && value.request_id === requestID && hasValidOfferSurface(value, request, expectedSurface, expectedSlot);
522
+ }
523
+ function isValidQuoteRequest(request) {
524
+ if (!validContext(request) || !isNonBlankString(request.tracking_token) || request.tracking_token !== request.tracking_token.trim() || request.tracking_token.length > 1024 || !Array.isArray(request.selections) || request.selections.length < 2 || request.selections.length > 4) return false;
525
+ const components = /* @__PURE__ */ new Set();
526
+ const products = /* @__PURE__ */ new Set();
527
+ const variants = /* @__PURE__ */ new Set();
528
+ return request.selections.every((selected) => {
529
+ if (!isCanonicalUUID(selected.component_id) || !PRODUCT_GID2.test(selected.product_id) || !VARIANT_GID2.test(selected.variant_id) || components.has(selected.component_id) || products.has(selected.product_id) || variants.has(selected.variant_id)) return false;
530
+ components.add(selected.component_id);
531
+ products.add(selected.product_id);
532
+ variants.add(selected.variant_id);
533
+ return true;
534
+ });
535
+ }
536
+ function hasContractQuoteResponse(value, request) {
537
+ if (!isRecord(value) || !isCanonicalUUID(value.request_id) || typeof value.eligible !== "boolean") return false;
538
+ if (!value.eligible) return value.offer === void 0 && ["stale_offer", "invalid_selection", "temporarily_unavailable"].includes(String(value.reason));
539
+ if (value.reason !== void 0 || !isRecord(value.offer)) return false;
540
+ const offer = value.offer;
541
+ if (offer.request_id !== value.request_id || !["pdp", "api_slot", "api_offer_ids"].includes(String(offer.surface))) return false;
542
+ const selected = new Map(request.selections.map((item) => [item.component_id, item]));
543
+ if (!Array.isArray(offer.components) || offer.components.length !== request.selections.length || offer.components.some((item) => !isRecord(item) || selected.get(String(item.component_id))?.product_id !== item.product_id || selected.get(String(item.component_id))?.variant_id !== item.variant_id)) return false;
544
+ const surface = offer.surface;
545
+ const slot = surface === "api_slot" && typeof offer.slot_key === "string" ? offer.slot_key : void 0;
546
+ const resolveRequest = surface === "api_slot" ? { slot_key: slot ?? "", context: request.context, cart: request.cart } : { offer_ids: [String(offer.offer_id)], context: request.context, cart: request.cart };
547
+ return hasContractBundleOffer(offer, value.request_id, resolveRequest, surface, slot);
548
+ }
549
+ function hasContractResolveResponse(value, request) {
550
+ if (!isRecord(value) || !isCanonicalUUID(value.request_id) || !Array.isArray(value.offers)) return false;
551
+ const requestID = value.request_id;
552
+ const limit = request.limit ?? DEFAULT_RESOLVE_LIMIT;
553
+ if (value.offers.length > limit) return false;
554
+ const offerIDs = /* @__PURE__ */ new Set();
555
+ const instanceIDs = /* @__PURE__ */ new Set();
556
+ let previousRequestedIndex = -1;
557
+ return value.offers.every((offer) => {
558
+ if (!hasContractBundleOffer(offer, requestID, request)) return false;
559
+ const identity = offer;
560
+ const offerID = identity.offer_id;
561
+ const instanceID = identity.bundle_instance_id;
562
+ if (offerIDs.has(offerID) || instanceIDs.has(instanceID)) return false;
563
+ offerIDs.add(offerID);
564
+ instanceIDs.add(instanceID);
565
+ if (!Array.isArray(request.offer_ids)) return true;
566
+ const requestedIndex = request.offer_ids.indexOf(offerID);
567
+ if (requestedIndex < 0 || requestedIndex <= previousRequestedIndex) return false;
568
+ previousRequestedIndex = requestedIndex;
569
+ return true;
570
+ });
571
+ }
572
+ function isValidBundleResolveRequest(request) {
573
+ if (!validContext(request)) return false;
574
+ if (request.limit !== void 0 && (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 20)) {
575
+ return false;
576
+ }
577
+ const hasSlot = typeof request.slot_key === "string";
578
+ const hasOffers = Array.isArray(request.offer_ids);
579
+ if (hasSlot === hasOffers) return false;
580
+ if (hasSlot) return request.slot_key.length <= 64 && SLOT_KEY.test(request.slot_key);
581
+ if (!hasOffers || request.offer_ids.length < 1 || request.offer_ids.length > 20) return false;
582
+ const ids = /* @__PURE__ */ new Set();
583
+ return request.offer_ids.every((id) => {
584
+ if (!isCanonicalUUID(id) || ids.has(id)) return false;
585
+ ids.add(id);
586
+ return true;
587
+ });
588
+ }
589
+ function isValidPDPRequest(request) {
590
+ return Boolean(
591
+ validContext(request) && PRODUCT_GID2.test(request.source_product_id) && VARIANT_GID2.test(request.source_variant_id) && isCanonicalUUID(request.bundle_instance_id)
592
+ );
593
+ }
594
+ async function readData(response) {
595
+ try {
596
+ const body = await response.json();
597
+ return body?.data;
598
+ } catch {
599
+ return null;
600
+ }
601
+ }
602
+ async function resolveBundleOffers(request, options) {
603
+ if (!isValidBundleResolveRequest(request)) return null;
604
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
605
+ try {
606
+ const response = await fetchImpl(`${normalizeProxyBase2(options.proxyBase)}/bundle-offers/resolve`, {
607
+ method: "POST",
608
+ headers: { "Content-Type": "application/json" },
609
+ body: JSON.stringify(request),
610
+ signal: options.signal,
611
+ credentials: "same-origin"
612
+ });
613
+ if (!response.ok) return null;
614
+ const data = await readData(response);
615
+ if (!hasContractResolveResponse(data, request)) return null;
616
+ return { request_id: data.request_id, offers: data.offers };
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+ async function resolveBundlePDP(request, options) {
622
+ if (!isValidPDPRequest(request)) return null;
623
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
624
+ try {
625
+ const response = await fetchImpl(
626
+ `${normalizeProxyBase2(options.proxyBase)}/bundle-offers/pdp/rendered`,
627
+ {
628
+ method: "POST",
629
+ headers: { "Content-Type": "application/json" },
630
+ body: JSON.stringify(request),
631
+ signal: options.signal,
632
+ credentials: "same-origin"
633
+ }
634
+ );
635
+ if (!response.ok) return null;
636
+ const data = await readData(response);
637
+ if (!data || typeof data !== "object") return null;
638
+ const value = data;
639
+ if (typeof value.html !== "string" || !isCanonicalUUID(value.request_id)) return null;
640
+ return { html: value.html, request_id: value.request_id };
641
+ } catch {
642
+ return null;
643
+ }
644
+ }
645
+ async function quoteBundleOffer(request, options) {
646
+ if (!isValidQuoteRequest(request)) return null;
647
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
648
+ try {
649
+ const response = await fetchImpl(`${normalizeProxyBase2(options.proxyBase)}/bundle-offers/quote`, {
650
+ method: "POST",
651
+ headers: { "Content-Type": "application/json" },
652
+ body: JSON.stringify(request),
653
+ signal: options.signal,
654
+ credentials: "same-origin"
655
+ });
656
+ if (!response.ok) return null;
657
+ const data = await readData(response);
658
+ return hasContractQuoteResponse(data, request) ? data : null;
659
+ } catch {
660
+ return null;
661
+ }
662
+ }
663
+
664
+ // src/client.ts
665
+ function textAttribute(element, name) {
666
+ return String(element.getAttribute(name) || "").trim();
667
+ }
668
+ function collectBundleObserveTargets(root) {
669
+ return Array.from(root.querySelectorAll("[data-tab-buy-together-offer]")).map((element) => ({
670
+ element,
671
+ tracking_token: textAttribute(element, "data-tab-buy-together-tracking-token"),
672
+ offer_id: textAttribute(element, "data-tab-buy-together-offer-id"),
673
+ request_id: textAttribute(element, "data-tab-buy-together-request-id")
674
+ }));
675
+ }
676
+ function isTrackable(target) {
677
+ return Boolean(target.tracking_token && target.offer_id && target.request_id);
678
+ }
679
+ function init(options) {
680
+ const documentRef = options.documentRef ?? (typeof document !== "undefined" ? document : void 0);
681
+ const windowRef = options.windowRef ?? (typeof window !== "undefined" ? window : void 0);
682
+ const observers = /* @__PURE__ */ new Set();
683
+ const mountedPDP = /* @__PURE__ */ new WeakMap();
684
+ const activePDP = /* @__PURE__ */ new Set();
685
+ let notifyEnqueued = () => {
686
+ };
687
+ const queue = createBundleEventQueue({
688
+ proxyBase: options.proxyBase,
689
+ fetchImpl: options.fetchImpl,
690
+ createEventId: options.createEventId,
691
+ now: options.now,
692
+ onEnqueued: (count) => notifyEnqueued(count)
693
+ });
694
+ const flushController = createBundleFlushController({
695
+ flush: () => queue.flush(),
696
+ intervalMs: options.flushIntervalMs,
697
+ threshold: options.flushThreshold,
698
+ documentRef,
699
+ windowRef
700
+ });
701
+ notifyEnqueued = (count) => flushController.notifyEnqueued(count);
702
+ flushController.start();
703
+ function track(input) {
704
+ queue.track(input);
705
+ }
706
+ function resolve(request, signal) {
707
+ return resolveBundleOffers(request, {
708
+ proxyBase: options.proxyBase,
709
+ fetchImpl: options.fetchImpl,
710
+ signal
711
+ });
712
+ }
713
+ function resolvePDP(request, signal) {
714
+ return resolveBundlePDP(request, {
715
+ proxyBase: options.proxyBase,
716
+ fetchImpl: options.fetchImpl,
717
+ signal
718
+ });
719
+ }
720
+ function quote(request, signal) {
721
+ return quoteBundleOffer(request, { proxyBase: options.proxyBase, fetchImpl: options.fetchImpl, signal });
722
+ }
723
+ async function mountPDP(mountOptions) {
724
+ const prior = mountedPDP.get(mountOptions.root);
725
+ prior?.controller.abort();
726
+ prior?.unmount();
727
+ if (prior) activePDP.delete(prior);
728
+ const controller = new AbortController();
729
+ const mounted = { controller, unmount: () => {
730
+ } };
731
+ mountedPDP.set(mountOptions.root, mounted);
732
+ activePDP.add(mounted);
733
+ mountOptions.root.innerHTML = "";
734
+ const abortExternal = () => controller.abort();
735
+ mountOptions.signal?.addEventListener("abort", abortExternal, { once: true });
736
+ if (mountOptions.signal?.aborted) controller.abort();
737
+ const rendered = await resolvePDP(mountOptions.request, controller.signal);
738
+ mountOptions.signal?.removeEventListener("abort", abortExternal);
739
+ if (mountedPDP.get(mountOptions.root) !== mounted || controller.signal.aborted) {
740
+ activePDP.delete(mounted);
741
+ return () => {
742
+ };
743
+ }
744
+ if (!rendered || !rendered.html.trim()) {
745
+ return () => {
746
+ };
747
+ }
748
+ mountOptions.root.innerHTML = rendered.html;
749
+ const targets = collectBundleObserveTargets(mountOptions.root).filter(isTrackable);
750
+ if (targets.length === 0) return () => {
751
+ };
752
+ const observer = createBundleImpressionObserver({
753
+ IntersectionObserverImpl: options.IntersectionObserverImpl,
754
+ documentRef,
755
+ now: options.now,
756
+ onImpression: (payload) => {
757
+ track({
758
+ event_type: "impression",
759
+ tracking_token: payload.tracking_token,
760
+ offer_id: payload.offer_id,
761
+ request_id: payload.request_id,
762
+ attrs: payload.attrs
763
+ });
764
+ }
765
+ });
766
+ observer.observe(targets);
767
+ observers.add(observer);
768
+ mounted.unmount = () => {
769
+ observer.disconnect();
770
+ observers.delete(observer);
771
+ };
772
+ return () => {
773
+ if (mountedPDP.get(mountOptions.root) === mounted) {
774
+ mounted.unmount();
775
+ mountedPDP.delete(mountOptions.root);
776
+ activePDP.delete(mounted);
777
+ }
778
+ };
779
+ }
780
+ function addAll(offer, addOptions = {}) {
781
+ return addBundleToCart({
782
+ ...addOptions,
783
+ offer,
784
+ fetchImpl: options.fetchImpl,
785
+ track
786
+ });
787
+ }
788
+ function destroy() {
789
+ for (const mounted of activePDP) {
790
+ mounted.controller.abort();
791
+ mounted.unmount();
792
+ }
793
+ activePDP.clear();
794
+ for (const observer of observers) observer.disconnect();
795
+ observers.clear();
796
+ flushController.stop();
797
+ void flushController.flushNow().catch(() => {
798
+ });
799
+ }
800
+ return {
801
+ resolve,
802
+ resolvePDP,
803
+ quote,
804
+ mountPDP,
805
+ addAll,
806
+ track,
807
+ flush: () => flushController.flushNow().catch(() => {
808
+ }),
809
+ destroy
810
+ };
811
+ }
812
+
813
+ // src/pdp-sequencer.ts
814
+ function createPDPRequestSequencer(options) {
815
+ let generation = 0;
816
+ let controller = null;
817
+ async function run(request) {
818
+ generation += 1;
819
+ const currentGeneration = generation;
820
+ controller?.abort();
821
+ controller = new AbortController();
822
+ options.onClear();
823
+ try {
824
+ const result = await options.resolve(request, controller.signal);
825
+ if (currentGeneration !== generation || controller.signal.aborted || result === null) return null;
826
+ options.onResult(result);
827
+ return result;
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+ function cancel() {
833
+ generation += 1;
834
+ controller?.abort();
835
+ controller = null;
836
+ }
837
+ return { run, cancel };
838
+ }
839
+
840
+ // src/quote-fence.ts
841
+ function createQuoteRequestFence() {
842
+ let revision = 0;
843
+ let controller = null;
844
+ return {
845
+ begin() {
846
+ controller?.abort();
847
+ controller = new AbortController();
848
+ return { revision: ++revision, signal: controller.signal };
849
+ },
850
+ isCurrent(attempt) {
851
+ return attempt.revision === revision && !attempt.signal.aborted;
852
+ },
853
+ cancel() {
854
+ revision += 1;
855
+ controller?.abort();
856
+ controller = null;
857
+ }
858
+ };
859
+ }
860
+ export {
861
+ BUNDLE_IMPRESSION_MIN_DURATION_MS,
862
+ BUNDLE_IMPRESSION_MIN_RATIO,
863
+ MAX_BUNDLE_EVENTS_BATCH,
864
+ addBundleToCart,
865
+ collectBundleObserveTargets,
866
+ createBundleEventQueue,
867
+ createBundleFlushController,
868
+ createBundleImpressionObserver,
869
+ createPDPRequestSequencer,
870
+ createQuoteRequestFence,
871
+ init,
872
+ isValidBundleResolveRequest,
873
+ quoteBundleOffer,
874
+ resolveBundleOffers,
875
+ resolveBundlePDP
876
+ };
877
+ //# sourceMappingURL=index.js.map