@drip-apex/sdk 0.1.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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,979 @@
1
+ import { fetchWithoutShopifyCartMutationNotification } from "./shopify-cart-events";
2
+ import {
3
+ isShopifyStorefront,
4
+ shopifyCartPath,
5
+ shopifyRoutesRoot,
6
+ type CartAssignment,
7
+ } from "./shopify-cart";
8
+ import type { CommerceOffersSettings } from "./types";
9
+
10
+ const MARKER = "_apex_gwp";
11
+ const DECLINE_PREFIX = "drip_gwp_declined:";
12
+ const INSERTED_PREFIX = "drip_gwp_inserted:";
13
+
14
+ type CartLine = {
15
+ key?: unknown;
16
+ quantity?: unknown;
17
+ variant_id?: unknown;
18
+ final_line_price?: unknown;
19
+ line_price?: unknown;
20
+ properties?: unknown;
21
+ };
22
+ type ShopifyCart = { token?: unknown; currency?: unknown; items?: unknown };
23
+
24
+ // Record value when the cart's identity is unknown — matches any cart.
25
+ const ANY_CART = "*";
26
+
27
+ export type GiftSyncOptions = {
28
+ /** Per-arm proof from the CURRENT stamped offers plane. Insertion requires
29
+ * it: config membership alone can cover an assignment whose offers proof
30
+ * was refused (e.g. shared price+offers experiment with price-only proof),
31
+ * and the Function would charge that "gift" at full price. */
32
+ isArmProven?: (assignment: CartAssignment) => boolean;
33
+ /** A cart mutation invalidates the once-per-page teardown check. */
34
+ recheckTeardown?: boolean;
35
+ /** Fail-closed reconciliation after a FAILED stamp sync: privilege-reducing
36
+ * cleanup only (threshold/currency/orphan removals, quantity normalization)
37
+ * — never insert, because no fresh proof exists for a new line. */
38
+ suppressInsertion?: boolean;
39
+ };
40
+
41
+ let inFlight: Promise<void> | null = null;
42
+ let queuedArgs: {
43
+ settings: CommerceOffersSettings | undefined;
44
+ assignments: CartAssignment[];
45
+ options: GiftSyncOptions | undefined;
46
+ generation: number;
47
+ } | null = null;
48
+ // In-memory fallback when localStorage is unavailable (strict privacy modes).
49
+ const insertedThisSession = new Map<string, string>();
50
+ const selectedGiftOptions = new Map<string, { gid: string; tierThreshold: string; pending?: boolean }>();
51
+ const dispatchedChoices = new Set<string>();
52
+ // The choice tier currently applicable per experiment — selections bind to
53
+ // THIS tier, not to whichever prompts were dispatched earlier in the session.
54
+ const activeChoiceTier = new Map<string, string>();
55
+ // Bumped whenever new sync arguments are accepted: an in-flight pass built
56
+ // on older arguments must not repopulate tier liveness after the clear.
57
+ let choiceTierGeneration = 0;
58
+ let latestArgs: { settings: CommerceOffersSettings | undefined; assignments: CartAssignment[]; options?: GiftSyncOptions } | null = null;
59
+
60
+ type GiftTier = { threshold: string; gift_variant_gid: string; gift_choice_variant_gids?: string[] };
61
+ export type PendingGiftChoice = {
62
+ experimentId: string;
63
+ threshold: string;
64
+ options: string[];
65
+ };
66
+ function giftTiers(variant: Record<string, unknown>): GiftTier[] | null {
67
+ const raw = Array.isArray(variant.tiers) && variant.tiers.length > 0
68
+ ? variant.tiers
69
+ : [{ threshold: variant.threshold, gift_variant_gid: variant.gift_variant_gid }];
70
+ const tiers: GiftTier[] = [];
71
+ for (const item of raw) {
72
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
73
+ const tier = item as Record<string, unknown>;
74
+ if (typeof tier.threshold !== "string" || !/^\d+$/.test(tier.threshold) ||
75
+ typeof tier.gift_variant_gid !== "string" ||
76
+ // Malformed fixed-tier GIDs would reach choice dispatch with a null
77
+ // giftId and abort reconciliation mid-run; money-path config fails
78
+ // closed at parse time instead.
79
+ numericVariantId(tier.gift_variant_gid) === null) return null;
80
+ const choices = tier.gift_choice_variant_gids;
81
+ if (choices !== undefined && (!Array.isArray(choices) || choices.length < 2 || choices.length > 4 ||
82
+ !choices.every((gid) => typeof gid === "string" && numericVariantId(gid) !== null))) return null;
83
+ tiers.push({ threshold: tier.threshold, gift_variant_gid: tier.gift_variant_gid,
84
+ ...(choices ? { gift_choice_variant_gids: choices as string[] } : {}) });
85
+ }
86
+ if (tiers.length < 1 || tiers.length > 3 || tiers.some((tier, index) =>
87
+ !Number.isSafeInteger(Number(tier.threshold)) ||
88
+ (index > 0 && Number(tier.threshold) <= Number(tiers[index - 1].threshold)))) return null;
89
+ return tiers;
90
+ }
91
+
92
+ function declined(experimentId: string): boolean {
93
+ try { return sessionStorage.getItem(`${DECLINE_PREFIX}${experimentId}`) === "1"; } catch { return false; }
94
+ }
95
+
96
+ function recordDecline(experimentId: string): void {
97
+ try { sessionStorage.setItem(`${DECLINE_PREFIX}${experimentId}`, "1"); } catch { /* best effort */ }
98
+ }
99
+
100
+ // Insertion state persists in LOCAL storage: the Shopify cart itself is
101
+ // durable across browser sessions, so the record of "Apex added this line"
102
+ // must outlive the session too — otherwise a shopper returning after the
103
+ // offer stopped would keep a no-longer-discounted gift (teardown cleanup
104
+ // could not know to look), and a manual removal in a fresh session would
105
+ // read as "never inserted" and re-add the gift. The record VALUE is the cart
106
+ // token the insertion happened on, so a normal cart transition (checkout
107
+ // emptied the old cart) is not misread as a manual decline on the new one.
108
+ // This is charge-correctness state (experiment id + cart token), like the
109
+ // cart attribute stamp, not analytics. Decline flags stay session-scoped.
110
+ function insertionRecord(experimentId: string): string | null {
111
+ try {
112
+ return localStorage.getItem(`${INSERTED_PREFIX}${experimentId}`)
113
+ ?? insertedThisSession.get(experimentId) ?? null;
114
+ } catch { return insertedThisSession.get(experimentId) ?? null; }
115
+ }
116
+
117
+ function recordCart(record: string): string {
118
+ if (!record.startsWith("{")) return record;
119
+ try { const parsed = JSON.parse(record) as { cart?: unknown }; return typeof parsed.cart === "string" ? parsed.cart : record; } catch { return record; }
120
+ }
121
+ function recordVariantGid(record: string): string | null {
122
+ if (!record.startsWith("{")) return null;
123
+ try {
124
+ const parsed = JSON.parse(record) as { variantGid?: unknown };
125
+ return typeof parsed.variantGid === "string" ? parsed.variantGid : null;
126
+ } catch { return null; }
127
+ }
128
+ function recordTierThreshold(record: string): string | null {
129
+ if (!record.startsWith("{")) return null;
130
+ try {
131
+ const parsed = JSON.parse(record) as { tierThreshold?: unknown };
132
+ return typeof parsed.tierThreshold === "string" ? parsed.tierThreshold : null;
133
+ } catch { return null; }
134
+ }
135
+ function insertionMatchesCart(record: string, cartToken: string | null): boolean {
136
+ const token = recordCart(record);
137
+ return token === ANY_CART || cartToken == null || token === cartToken;
138
+ }
139
+
140
+ function recordInserted(experimentId: string, cartToken: string | null, variantGid?: string, tierThreshold?: string): void {
141
+ const token = cartToken ?? ANY_CART;
142
+ const value = variantGid || tierThreshold ? JSON.stringify({ cart: token, variantGid, tierThreshold }) : token;
143
+ insertedThisSession.set(experimentId, value);
144
+ try { localStorage.setItem(`${INSERTED_PREFIX}${experimentId}`, value); } catch { /* best effort */ }
145
+ // The durable record landing IS the settle point for a pending selection —
146
+ // waiting for another reconciliation pass leaves a last-pass add pending
147
+ // forever and re-opens the cross-tab revert.
148
+ const selection = selectedGiftOptions.get(experimentId);
149
+ if (selection?.pending && selection.gid === variantGid) {
150
+ selectedGiftOptions.set(experimentId, {
151
+ gid: selection.gid,
152
+ tierThreshold: selection.tierThreshold,
153
+ });
154
+ }
155
+ }
156
+
157
+ function clearInserted(experimentId: string): void {
158
+ insertedThisSession.delete(experimentId);
159
+ try { localStorage.removeItem(`${INSERTED_PREFIX}${experimentId}`); } catch { /* best effort */ }
160
+ }
161
+
162
+ function clearDispatchedChoices(experimentId: string): void {
163
+ dispatchedChoices.forEach((key) => {
164
+ if (key.startsWith(`${experimentId}:`)) dispatchedChoices.delete(key);
165
+ });
166
+ }
167
+
168
+ function listInsertionRecords(): string[] {
169
+ const ids = new Set<string>(insertedThisSession.keys());
170
+ try {
171
+ for (let i = 0; i < localStorage.length; i += 1) {
172
+ const key = localStorage.key(i);
173
+ if (key?.startsWith(INSERTED_PREFIX)) ids.add(key.slice(INSERTED_PREFIX.length));
174
+ }
175
+ } catch { /* fall through */ }
176
+ return [...ids];
177
+ }
178
+
179
+ function hasInsertionRecords(): boolean {
180
+ return listInsertionRecords().length > 0;
181
+ }
182
+
183
+ // The teardown read (offers plane tombstoned to an empty experiments list)
184
+ // runs once per PAGE LOAD — not per session — so a login/customer-account
185
+ // cart merge that lands a marked line on a later page is still discovered;
186
+ // in-page cart mutations reset the flag via the tombstone observer.
187
+ let teardownChecked = false;
188
+
189
+ function markTeardownChecked(): void {
190
+ teardownChecked = true;
191
+ }
192
+
193
+ function numericVariantId(gid: string): number | null {
194
+ const match = /^gid:\/\/shopify\/ProductVariant\/(\d+)$/.exec(gid);
195
+ if (!match) return null;
196
+ const id = Number(match[1]);
197
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
198
+ }
199
+
200
+ function marker(line: CartLine): string | null {
201
+ if (!line.properties || typeof line.properties !== "object" || Array.isArray(line.properties)) return null;
202
+ const value = (line.properties as Record<string, unknown>)[MARKER];
203
+ return typeof value === "string" ? value : null;
204
+ }
205
+
206
+ function lineVariantId(line: CartLine): string | null {
207
+ if (typeof line.variant_id === "number" && Number.isSafeInteger(line.variant_id)) return String(line.variant_id);
208
+ if (typeof line.variant_id === "string" && /^\d+$/.test(line.variant_id)) return line.variant_id;
209
+ return null;
210
+ }
211
+
212
+ function lineAmount(line: CartLine): number | null {
213
+ // The offer Function compares each non-gift line's cost.subtotalAmount —
214
+ // the PRE-line-discount amount. Ajax `line_price` is that same basis;
215
+ // `final_line_price` is post-discount and would disagree on discounted
216
+ // carts, removing a gift the Function still authorizes.
217
+ const value = line.line_price;
218
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
219
+ }
220
+
221
+ async function mutate(path: string, body: unknown): Promise<boolean> {
222
+ // Never throws: a network-level rejection mid-pass must not discard the
223
+ // pass's accumulated mutation state (earlier successful removals still
224
+ // warrant the fixpoint retry and the cart-changed signal).
225
+ try {
226
+ const response = await fetchWithoutShopifyCartMutationNotification(path, {
227
+ method: "POST",
228
+ headers: { "Content-Type": "application/json" },
229
+ body: JSON.stringify(body),
230
+ });
231
+ return response.ok;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /** Returns true when this pass mutated the cart (caller re-runs to a fixpoint). */
238
+ async function run(
239
+ settings: CommerceOffersSettings | undefined,
240
+ assignments: CartAssignment[],
241
+ options: GiftSyncOptions | undefined,
242
+ generation: number,
243
+ ): Promise<boolean> {
244
+ if (!isShopifyStorefront() || typeof fetch !== "function") return false;
245
+ let response: Response;
246
+ try {
247
+ response = await fetchWithoutShopifyCartMutationNotification(shopifyCartPath(), {
248
+ method: "GET", headers: { Accept: "application/json" }, cache: "no-store",
249
+ });
250
+ } catch { return false; }
251
+ if (!response.ok) return false;
252
+ const cart = await response.json() as ShopifyCart;
253
+ if (!Array.isArray(cart.items) || typeof cart.currency !== "string") return false;
254
+ let cartMutated = false;
255
+ const lines = cart.items.filter((item): item is CartLine => !!item && typeof item === "object");
256
+ const cartToken = typeof cart.token === "string" && cart.token ? cart.token : null;
257
+ const assigned = new Map(assignments.map((item) => [item.experimentId, item.variationId]));
258
+ // Charge-only pins authorize the cart stamp for an off-target page but are
259
+ // deliberately excluded from DOM, persistence, and exposure — inserting a
260
+ // physical gift for them would give away inventory the shopper was never
261
+ // shown. They still count as ACTIVE for removal/keep decisions.
262
+ const chargeOnlyExperimentIds = new Set(
263
+ assignments.filter((item) => item.commercePriceOnly).map((item) => item.experimentId),
264
+ );
265
+ const experiments = settings?.experiments ?? [];
266
+ // Only experiments covered by the signed offers authorization may INSERT:
267
+ // without it the Function cannot discount the added line, so the "gift"
268
+ // would be charged at full price. Removal/maintenance stays unrestricted.
269
+ const authorizedOfferExperimentIds = new Set(
270
+ settings?.authorization?.experimentIds ?? [],
271
+ );
272
+ const root = shopifyRoutesRoot();
273
+ const changePath = `${root}cart/change.js`;
274
+ // Tracks gift variants inserted within THIS run: two parallel GWP tests
275
+ // configured with the same gift variant must share one line (the Function
276
+ // consolidates both candidates onto the first matching line), or the
277
+ // second marked line would be charged at full price.
278
+ const insertedVariantIdsThisRun = new Set<string>();
279
+ // Tracks lines removed within THIS run so presence checks against the
280
+ // immutable snapshot do not see a line that is already gone — otherwise a
281
+ // still-qualifying experiment sharing the removed gift's variant would
282
+ // silently skip its own insertion.
283
+ const removedLineKeys = new Set<string>();
284
+ const removeLine = async (line: CartLine): Promise<boolean> => {
285
+ if (typeof line.key !== "string") return false;
286
+ if (!(await mutate(changePath, { id: line.key, quantity: 0 }))) return false;
287
+ removedLineKeys.add(line.key);
288
+ cartMutated = true;
289
+ return true;
290
+ };
291
+ const linePresent = (line: CartLine): boolean =>
292
+ typeof line.key === "string" ? !removedLineKeys.has(line.key) : true;
293
+
294
+ // Orphan cleanup first: a marked line whose experiment left the offers
295
+ // projection (stopped, archived, kill-switched), whose assignment is gone,
296
+ // or whose assigned variant no longer describes a GWP offer (relaunched
297
+ // under a different offer type) will never be authorized by the discount
298
+ // Function again — the shopper would be charged full price for it. Remove
299
+ // it before any insertion logic; keep the insertion record when removal
300
+ // fails so the next sync retries instead of abandoning the line.
301
+ const activeGiftExperiments = new Set(
302
+ experiments
303
+ .filter((experiment) => {
304
+ const variationId = assigned.get(experiment.experiment_id);
305
+ const variant = variationId ? experiment.variants[variationId] : undefined;
306
+ if (!variant || typeof variant !== "object") return false;
307
+ // With arm proof available, an assignment absent from the CURRENT
308
+ // stamped offers plane is not active: the Function will not discount
309
+ // its line, so keeping it would charge the shopper.
310
+ if (options?.isArmProven) {
311
+ const assignment = assignments.find(
312
+ (candidate) => candidate.experimentId === experiment.experiment_id,
313
+ );
314
+ if (!assignment || !options.isArmProven(assignment)) return false;
315
+ }
316
+ if ("control" in variant && variant.control === true) return true;
317
+ return "offer_type" in variant && variant.offer_type === "gwp_threshold"
318
+ && giftTiers(variant as Record<string, unknown>) !== null;
319
+ })
320
+ .map((experiment) => experiment.experiment_id),
321
+ );
322
+ for (const line of lines) {
323
+ const mark = marker(line);
324
+ if (mark == null || activeGiftExperiments.has(mark)) continue;
325
+ // Seed the durable record BEFORE attempting removal: if the mutation
326
+ // fails transiently, the record keeps later syncs (and later sessions)
327
+ // retrying even after this session's tombstone read is throttled.
328
+ recordInserted(mark, cartToken);
329
+ if (await removeLine(line)) clearInserted(mark);
330
+ }
331
+ // A record whose experiment is inactive AND whose line is already gone
332
+ // (checkout consumed the cart, or the shopper removed it before teardown)
333
+ // has nothing left to reconcile — clear it, or it would trigger a cart
334
+ // read on every later apply and misread a relaunch as a manual decline.
335
+ for (const experimentId of listInsertionRecords()) {
336
+ if (activeGiftExperiments.has(experimentId)) continue;
337
+ if (lines.some((line) => marker(line) === experimentId && linePresent(line))) continue;
338
+ clearInserted(experimentId);
339
+ }
340
+ // Throttle only once the cart has actually been read this session; failed
341
+ // orphan removals keep their seeded records, which bypass the throttle.
342
+ if (settings && experiments.length === 0) markTeardownChecked();
343
+
344
+ for (const experiment of experiments) {
345
+ // Not live until this pass proves the choice prompt eligible: every
346
+ // early exit (unassigned, control, currency mismatch, unproven arm,
347
+ // suppression, decline) leaves the tier cleared so a delayed
348
+ // selectGiftOption callback cannot bind to a phantom prompt.
349
+ activeChoiceTier.delete(experiment.experiment_id);
350
+ const variationId = assigned.get(experiment.experiment_id);
351
+ if (!variationId) continue;
352
+ const variant = experiment.variants[variationId];
353
+ if (!variant || typeof variant !== "object") continue;
354
+ // ALL marked lines for this experiment: concurrent tabs during a
355
+ // relaunch can leave several (e.g. old and new gift variants), and the
356
+ // Function discounts at most one unit of the current variant.
357
+ const managedLines = () => lines.filter(
358
+ (line) => marker(line) === experiment.experiment_id && linePresent(line),
359
+ );
360
+ // Removes every managed line; true when none that could be removed
361
+ // remains (lines without a usable key were never removable and follow
362
+ // the historical clear-anyway behavior).
363
+ const removeAllManaged = async (): Promise<boolean> => {
364
+ let allRemoved = true;
365
+ for (const line of managedLines()) {
366
+ if (typeof line.key !== "string") continue;
367
+ if (!(await removeLine(line))) {
368
+ allRemoved = false;
369
+ // A failed REQUIRED removal (below-threshold, control, currency)
370
+ // is unresolved work: the Function no longer discounts the line,
371
+ // so force another fixpoint pass to retry within this
372
+ // reconciliation instead of waiting for an unrelated cart event.
373
+ cartMutated = true;
374
+ }
375
+ }
376
+ return allRemoved;
377
+ };
378
+ if ("control" in variant && variant.control === true) {
379
+ if (await removeAllManaged()) clearInserted(experiment.experiment_id);
380
+ continue;
381
+ }
382
+ if (!("offer_type" in variant) || variant.offer_type !== "gwp_threshold") continue;
383
+ const tiers = giftTiers(variant as Record<string, unknown>);
384
+ if (!tiers) continue;
385
+ if (cart.currency.toUpperCase() !== experiment.currency.toUpperCase()) {
386
+ // The Function refuses cross-currency carts, so a market/currency switch
387
+ // strands an existing managed line at full price — fail closed by
388
+ // removing it, not just by skipping insertion. Not a decline: the gift
389
+ // may re-insert if the shopper switches back.
390
+ if (await removeAllManaged()) clearInserted(experiment.experiment_id);
391
+ continue;
392
+ }
393
+ const allGiftIds = new Set(tiers.flatMap((tier) => tier.gift_choice_variant_gids ?? [tier.gift_variant_gid])
394
+ .map(numericVariantId).filter((id): id is number => id !== null).map(String));
395
+ let subtotal = 0;
396
+ let valid = true;
397
+ for (const line of lines) {
398
+ if (!linePresent(line) || allGiftIds.has(lineVariantId(line) ?? "")) continue;
399
+ const amount = lineAmount(line);
400
+ if (amount == null || !Number.isSafeInteger(subtotal + amount)) { valid = false; break; }
401
+ subtotal += amount;
402
+ }
403
+ const applicableTier = valid ? tiers.reduce<GiftTier | null>((chosen, tier) => {
404
+ const threshold = Number(tier.threshold);
405
+ return Number.isSafeInteger(threshold) && threshold >= 0 && subtotal >= threshold ? tier : chosen;
406
+ }, null) : null;
407
+ if (!valid || tiers.some((tier) => !Number.isSafeInteger(Number(tier.threshold)))) {
408
+ // Fail closed: an unrepresentable threshold means the Function cannot
409
+ // legitimately discount this gift — an Apex-managed line left from an
410
+ // earlier (lower) threshold would charge full price. Remove every
411
+ // managed line for this experiment before skipping it.
412
+ for (const line of managedLines()) {
413
+ if (typeof line.key !== "string") continue;
414
+ await removeLine(line);
415
+ }
416
+ continue;
417
+ }
418
+ if (!applicableTier) {
419
+ clearDispatchedChoices(experiment.experiment_id);
420
+ activeChoiceTier.delete(experiment.experiment_id);
421
+ if (await removeAllManaged()) clearInserted(experiment.experiment_id);
422
+ continue;
423
+ }
424
+ const choiceGids = applicableTier.gift_choice_variant_gids;
425
+ // Every applicable-tier transition re-arms the chooser: keys that do not
426
+ // belong to the CURRENT tier (including all keys while a fixed tier
427
+ // applies) are stale. Only the current batch may mutate dedup state.
428
+ if (generation === choiceTierGeneration) {
429
+ const currentKey = choiceGids
430
+ ? `${experiment.experiment_id}:${applicableTier.threshold}:${choiceGids.join(",")}`
431
+ : null;
432
+ dispatchedChoices.forEach((key) => {
433
+ if (key.startsWith(`${experiment.experiment_id}:`) && key !== currentKey) {
434
+ dispatchedChoices.delete(key);
435
+ }
436
+ });
437
+ }
438
+ const markChoicePromptLive = () => {
439
+ if (
440
+ choiceGids &&
441
+ !options?.suppressInsertion &&
442
+ authorizedOfferExperimentIds.has(experiment.experiment_id) &&
443
+ !declined(experiment.experiment_id)
444
+ ) {
445
+ const assignment = assignments.find(
446
+ (candidate) => candidate.experimentId === experiment.experiment_id,
447
+ );
448
+ if (
449
+ (!options?.isArmProven || (assignment && options.isArmProven(assignment))) &&
450
+ generation === choiceTierGeneration
451
+ ) {
452
+ activeChoiceTier.set(experiment.experiment_id, applicableTier.threshold);
453
+ }
454
+ }
455
+ };
456
+ const extendedRecord = Array.isArray((variant as Record<string, unknown>).tiers);
457
+ const persistedRecord = insertionRecord(experiment.experiment_id);
458
+ const persistedGid = persistedRecord && insertionMatchesCart(persistedRecord, cartToken)
459
+ ? recordVariantGid(persistedRecord)
460
+ : null;
461
+ // A recorded tier that DIFFERS from the applicable tier is a stale
462
+ // lower-tier gift: entering a choice tier must present the choice. An
463
+ // ABSENT tier (snapshot-seeded cross-device record, legacy plain-token
464
+ // record) has no provenance: recover it only while the applicable tier
465
+ // is the LOWEST choice tier containing that gid — once the subtotal has
466
+ // crossed into a higher choice tier sharing the GID, the new tier's
467
+ // prompt must not be suppressed by a tierless record.
468
+ const lowestChoiceTierThresholdFor = (gid: string | null | undefined) =>
469
+ gid == null
470
+ ? undefined
471
+ : tiers.find((tier) => tier.gift_choice_variant_gids?.includes(gid))?.threshold;
472
+ const persistedTierThreshold =
473
+ persistedRecord === null ? null : recordTierThreshold(persistedRecord);
474
+ const tierlessRecoveryAllowed = (gid: string | null | undefined) =>
475
+ lowestChoiceTierThresholdFor(gid) === applicableTier.threshold;
476
+ // In-memory selections are tier-bound: a choice made for another tier
477
+ // (tier crossing, or a selectGiftOption call while a different tier
478
+ // applied) must not suppress the new tier's choice prompt.
479
+ const inMemorySelection = selectedGiftOptions.get(experiment.experiment_id);
480
+ let inMemoryGid = inMemorySelection?.tierThreshold === applicableTier.threshold
481
+ ? inMemorySelection.gid
482
+ : undefined;
483
+ const markedCurrentTierGid = choiceGids?.find((gid) => {
484
+ const variantId = numericVariantId(gid);
485
+ return variantId !== null && lines.some((line) =>
486
+ linePresent(line) && marker(line) === experiment.experiment_id &&
487
+ lineVariantId(line) === String(variantId));
488
+ });
489
+ const persistedChoiceIsCurrentTier = persistedRecord !== null &&
490
+ (persistedTierThreshold === applicableTier.threshold ||
491
+ (persistedTierThreshold === null &&
492
+ tierlessRecoveryAllowed(persistedGid ?? markedCurrentTierGid)));
493
+ // Cross-tab arbitration: the durable record is the source of truth once
494
+ // this tab's own selection has been persisted. A PENDING selection (made
495
+ // here, not yet recorded) still wins — clobbering the user's fresh click
496
+ // with an older record would be worse. Once our record lands (gids
497
+ // match), pending clears; a later differing durable record means another
498
+ // tab chose — drop the stale local selection instead of reverting theirs.
499
+ if (inMemorySelection && inMemoryGid) {
500
+ const persistedCurrentTierGid =
501
+ persistedChoiceIsCurrentTier && choiceGids?.includes(persistedGid ?? "")
502
+ ? persistedGid
503
+ : null;
504
+ if (inMemorySelection.pending && persistedCurrentTierGid === inMemoryGid) {
505
+ selectedGiftOptions.set(experiment.experiment_id, {
506
+ gid: inMemorySelection.gid,
507
+ tierThreshold: inMemorySelection.tierThreshold,
508
+ });
509
+ } else if (
510
+ !inMemorySelection.pending &&
511
+ persistedCurrentTierGid &&
512
+ persistedCurrentTierGid !== inMemoryGid
513
+ ) {
514
+ selectedGiftOptions.delete(experiment.experiment_id);
515
+ inMemoryGid = undefined;
516
+ }
517
+ }
518
+ const selectedGid = choiceGids?.includes(inMemoryGid ?? "")
519
+ ? inMemoryGid
520
+ : persistedChoiceIsCurrentTier && choiceGids?.includes(persistedGid ?? "") ? persistedGid!
521
+ : persistedChoiceIsCurrentTier ? markedCurrentTierGid : undefined;
522
+ if (selectedGid) {
523
+ const priorPending =
524
+ selectedGiftOptions.get(experiment.experiment_id)?.gid === selectedGid &&
525
+ selectedGiftOptions.get(experiment.experiment_id)?.pending === true;
526
+ selectedGiftOptions.set(experiment.experiment_id, {
527
+ gid: selectedGid,
528
+ tierThreshold: applicableTier.threshold,
529
+ ...(priorPending ? { pending: true } : {}),
530
+ });
531
+ }
532
+ const desiredGid = choiceGids ? selectedGid : applicableTier.gift_variant_gid;
533
+ const giftId = desiredGid ? numericVariantId(desiredGid) : null;
534
+ const allowedCurrentIds = new Set((choiceGids ?? [applicableTier.gift_variant_gid])
535
+ .map(numericVariantId).filter((id): id is number => id !== null).map(String));
536
+ // Keep at most ONE line carrying the current gift variant; every other
537
+ // marked line (stale variant after a relaunch, or a concurrent-tab
538
+ // duplicate) is removed — the Function will not discount it. Removal
539
+ // failures skip the experiment: falling through could misread the
540
+ // shopper's cart as a manual decline.
541
+ let managed: CartLine | undefined;
542
+ let staleRemovalFailed = false;
543
+ let removedStaleForExperiment = false;
544
+ for (const line of managedLines()) {
545
+ if (!managed && giftId !== null && lineVariantId(line) === String(giftId)) {
546
+ managed = line;
547
+ continue;
548
+ }
549
+ if (typeof line.key !== "string") continue;
550
+ if (await removeLine(line)) removedStaleForExperiment = true;
551
+ else staleRemovalFailed = true;
552
+ }
553
+ // Any failed stale removal is unresolved work: force another fixpoint
554
+ // pass so the retry happens now, not at the next unrelated cart event —
555
+ // the Function discounts at most the one current-variant unit.
556
+ if (staleRemovalFailed) cartMutated = true;
557
+ if (staleRemovalFailed && !managed) continue;
558
+ if (!managed && removedStaleForExperiment) {
559
+ // OUR stale-variant removal, not a shopper decline of the new gift:
560
+ // clear the record so the decline logic below cannot misread it and
561
+ // the current gift may insert.
562
+ clearInserted(experiment.experiment_id);
563
+ }
564
+ if (managed && lines.some((line) =>
565
+ line !== managed && linePresent(line) && marker(line) !== experiment.experiment_id &&
566
+ allowedCurrentIds.has(lineVariantId(line) ?? ""))) {
567
+ // The Function picks one allowed option by cart-line id, not by Apex's
568
+ // marker. If a shopper or another experiment added an allowed option,
569
+ // our marked gift could therefore become a full-price line. Relinquish
570
+ // Apex's line and let that other option satisfy the tier.
571
+ if (typeof managed.key === "string" && await removeLine(managed)) {
572
+ clearInserted(experiment.experiment_id);
573
+ } else {
574
+ cartMutated = true;
575
+ }
576
+ continue;
577
+ }
578
+ if (!desiredGid || giftId === null) {
579
+ clearInserted(experiment.experiment_id);
580
+ markChoicePromptLive();
581
+ if (!options?.suppressInsertion && authorizedOfferExperimentIds.has(experiment.experiment_id) && !declined(experiment.experiment_id)) {
582
+ const assignment = assignments.find((candidate) => candidate.experimentId === experiment.experiment_id);
583
+ if (!options?.isArmProven || (assignment && options.isArmProven(assignment))) {
584
+ const eventKey = `${experiment.experiment_id}:${applicableTier.threshold}:${choiceGids!.join(",")}`;
585
+ // Only the current batch may dispatch or record dedup state: a
586
+ // stale batch consuming the key would mute the queued batch's
587
+ // valid prompt while its own event points at dead liveness.
588
+ if (generation === choiceTierGeneration) {
589
+ if (!dispatchedChoices.has(eventKey) && typeof window !== "undefined") {
590
+ dispatchedChoices.add(eventKey);
591
+ // Detail carries a COPY: chooser code mutating the array must
592
+ // not corrupt the server-authored Function configuration that
593
+ // selectGiftOption validates against.
594
+ emitChoicePrompt({ experimentId: experiment.experiment_id, threshold: applicableTier.threshold, options: [...choiceGids!] });
595
+ }
596
+ }
597
+ }
598
+ }
599
+ continue;
600
+ }
601
+ if (managed) {
602
+ markChoicePromptLive();
603
+ if (options?.suppressInsertion) {
604
+ // Fail-closed pass after a failed stamp refresh: keep the line only
605
+ // when durable evidence ties it to THIS cart — after a cart-identity
606
+ // change (customer-cart merge) the old proof does not cover the new
607
+ // cart and the Function will not discount the line.
608
+ const record = insertionRecord(experiment.experiment_id);
609
+ if (!record || !insertionMatchesCart(record, cartToken)) {
610
+ if (await removeAllManaged()) clearInserted(experiment.experiment_id);
611
+ continue;
612
+ }
613
+ } else {
614
+ // A correctly marked line is always Apex-inserted (shoppers cannot
615
+ // set our line property), so seed the insertion record when a fresh
616
+ // session discovers one — a later disappearance must count as a
617
+ // manual decline.
618
+ recordInserted(experiment.experiment_id, cartToken, extendedRecord ? desiredGid : undefined, extendedRecord ? applicableTier.threshold : undefined);
619
+ }
620
+ // The Function only zeroes a single unit; extra units on the managed
621
+ // line would be charged, so normalize a shopper/theme increment to one.
622
+ if (
623
+ typeof managed.key === "string" &&
624
+ typeof managed.quantity === "number" &&
625
+ managed.quantity !== 1
626
+ ) {
627
+ // An off-model quantity always warrants another fixpoint pass — even
628
+ // when this normalize fails, the re-read retries within the run
629
+ // instead of leaving an extra charged unit until the next mutation.
630
+ cartMutated = true;
631
+ await mutate(changePath, { id: managed.key, quantity: 1 });
632
+ }
633
+ continue;
634
+ }
635
+ // Off-target charge-only pins never insert, and their line's absence on
636
+ // this page is not decline evidence — the targeted page owns both calls.
637
+ if (chargeOnlyExperimentIds.has(experiment.experiment_id)) {
638
+ // But when WE removed this experiment's shared gift line in this run,
639
+ // clear the co-owner evidence now: removedByUsThisRun does not survive
640
+ // the pass, and a later targeted page would misread the absent line as
641
+ // a shopper decline and never reinsert the still-eligible gift.
642
+ if (
643
+ insertionRecord(experiment.experiment_id) != null &&
644
+ lines.some(
645
+ (line) => !linePresent(line) && allowedCurrentIds.has(lineVariantId(line) ?? ""),
646
+ )
647
+ ) {
648
+ clearInserted(experiment.experiment_id);
649
+ }
650
+ continue;
651
+ }
652
+ // A shopper-owned copy of the gift satisfies the offer without giving
653
+ // Apex ownership of it — as does a copy just inserted for a parallel
654
+ // GWP test with the same gift variant (the Function consolidates both
655
+ // experiments' candidates onto that one line). This MUST precede the
656
+ // decline logic: a co-owner's record with the shared line still present
657
+ // is not a removal.
658
+ const satisfyingLine = lines.find(
659
+ (line) => linePresent(line) && allowedCurrentIds.has(lineVariantId(line) ?? ""),
660
+ );
661
+ const insertedAllowedId = [...insertedVariantIdsThisRun].find((id) => allowedCurrentIds.has(id));
662
+ if (satisfyingLine || insertedAllowedId) {
663
+ markChoicePromptLive();
664
+ // Sharing an APEX-INSERTED line makes this experiment a co-owner:
665
+ // record insertion state so the shopper's later removal declines every
666
+ // experiment the line represents, not just the one in its marker.
667
+ // Unmarked shopper-owned copies stay unrecorded — removing one's own
668
+ // purchase is not a gift decline.
669
+ if (
670
+ insertedAllowedId ||
671
+ (satisfyingLine && marker(satisfyingLine) != null)
672
+ ) {
673
+ const satisfyingGid = satisfyingLine
674
+ ? choiceGids?.find((gid) => String(numericVariantId(gid)) === lineVariantId(satisfyingLine))
675
+ : choiceGids?.find((gid) => String(numericVariantId(gid)) === insertedAllowedId);
676
+ recordInserted(experiment.experiment_id, cartToken, extendedRecord ? satisfyingGid ?? desiredGid : undefined, extendedRecord ? applicableTier.threshold : undefined);
677
+ }
678
+ continue;
679
+ }
680
+ const record = insertionRecord(experiment.experiment_id);
681
+ if (record != null) {
682
+ clearInserted(experiment.experiment_id);
683
+ // Only a missing line on the SAME cart the gift was inserted on is a
684
+ // manual decline. A new cart (checkout consumed the old one) starts
685
+ // clean, and a shared line WE removed this run (its owner entered
686
+ // control or lost eligibility) lets a still-qualifying co-owner take
687
+ // ownership instead of being marked declined.
688
+ const removedByUsThisRun = lines.some(
689
+ (line) => !linePresent(line) && lineVariantId(line) === String(giftId),
690
+ );
691
+ if (!removedByUsThisRun && insertionMatchesCart(record, cartToken)) {
692
+ recordDecline(experiment.experiment_id);
693
+ continue;
694
+ }
695
+ }
696
+ if (declined(experiment.experiment_id)) continue;
697
+ if (options?.suppressInsertion) continue;
698
+ if (!authorizedOfferExperimentIds.has(experiment.experiment_id)) continue;
699
+ if (options?.isArmProven) {
700
+ const localAssignment = assignments.find(
701
+ (candidate) => candidate.experimentId === experiment.experiment_id,
702
+ );
703
+ if (!localAssignment || !options.isArmProven(localAssignment)) continue;
704
+ }
705
+ const addResponse = await fetchWithoutShopifyCartMutationNotification(`${root}cart/add.js`, {
706
+ method: "POST",
707
+ headers: { "Content-Type": "application/json" },
708
+ body: JSON.stringify({
709
+ items: [{ id: giftId, quantity: 1, properties: { [MARKER]: experiment.experiment_id } }],
710
+ }),
711
+ }).catch(() => null);
712
+ if (!addResponse?.ok) {
713
+ // A failed add for an explicit CHOICE dead-ends the shopper: the
714
+ // pending selection would keep retrying the same variant while the
715
+ // cleared tier liveness rejects any new selectGiftOption call. Drop
716
+ // the failed selection and re-arm the prompt so another option can
717
+ // be chosen.
718
+ const failedSelection = selectedGiftOptions.get(experiment.experiment_id);
719
+ if (choiceGids && failedSelection?.gid === desiredGid) {
720
+ selectedGiftOptions.delete(experiment.experiment_id);
721
+ clearDispatchedChoices(experiment.experiment_id);
722
+ // Re-arm immediately: the pass-start tier clear plus a failed add
723
+ // with no cart mutation means no follow-up pass runs, so the chooser
724
+ // would stay dead (selection ignored, no pending prompts) until an
725
+ // unrelated reconciliation. Restore tier liveness and re-dispatch so
726
+ // an alternative option can be chosen right away.
727
+ markChoicePromptLive();
728
+ if (generation === choiceTierGeneration && typeof window !== "undefined") {
729
+ const retryKey = `${experiment.experiment_id}:${applicableTier.threshold}:${choiceGids.join(",")}`;
730
+ if (!dispatchedChoices.has(retryKey)) {
731
+ dispatchedChoices.add(retryKey);
732
+ emitChoicePrompt({ experimentId: experiment.experiment_id, threshold: applicableTier.threshold, options: [...choiceGids] });
733
+ }
734
+ }
735
+ }
736
+ continue;
737
+ }
738
+ if (addResponse?.ok) {
739
+ cartMutated = true;
740
+ recordInserted(experiment.experiment_id, cartToken, extendedRecord ? desiredGid : undefined, extendedRecord ? applicableTier.threshold : undefined);
741
+ insertedVariantIdsThisRun.add(String(giftId));
742
+ // Concurrent tabs can both add; Shopify consolidates identical
743
+ // variant+property lines by bumping quantity while the Function only
744
+ // discounts one unit. Verify from the add response and normalize.
745
+ try {
746
+ const body = await addResponse.json() as { items?: unknown };
747
+ const addedLine = Array.isArray(body.items)
748
+ ? body.items.find((item): item is CartLine =>
749
+ !!item && typeof item === "object" &&
750
+ marker(item as CartLine) === experiment.experiment_id)
751
+ : undefined;
752
+ if (
753
+ addedLine && typeof addedLine.key === "string" &&
754
+ typeof addedLine.quantity === "number" && addedLine.quantity > 1
755
+ ) {
756
+ // Detected consolidation always forces a follow-up fixpoint pass:
757
+ // even if this normalize fails, the re-read sees quantity > 1 on
758
+ // the managed line and retries rather than leaving a charged unit.
759
+ cartMutated = true;
760
+ await mutate(changePath, { id: addedLine.key, quantity: 1 });
761
+ }
762
+ } catch { /* best effort */ }
763
+ }
764
+ }
765
+ return cartMutated;
766
+ }
767
+
768
+ /**
769
+ * Seed durable insertion records from any cart snapshot that passes through
770
+ * the SDK (e.g. the /cart/update.js stamp-write response). Marked lines can
771
+ * only originate from Apex, so seeing one IS the insertion evidence — this is
772
+ * how a fresh device or a cleared-storage session over a persistent (or
773
+ * customer-synced) cart rediscovers gifts it must reconcile or tear down.
774
+ * Returns true when a marked line was found.
775
+ */
776
+ export function noteCartSnapshotForGifts(cart: unknown): boolean {
777
+ const snapshot = cart as { token?: unknown; items?: unknown } | null;
778
+ if (!Array.isArray(snapshot?.items)) return false;
779
+ const cartToken = typeof snapshot.token === "string" && snapshot.token ? snapshot.token : null;
780
+ let found = false;
781
+ for (const item of snapshot.items) {
782
+ if (!item || typeof item !== "object") continue;
783
+ const mark = marker(item as CartLine);
784
+ if (mark == null) continue;
785
+ const variantId = lineVariantId(item as CartLine);
786
+ // A snapshot write must not erase tier ownership an in-flow record
787
+ // already carries: overwriting a tier-bound choice with a variant-only
788
+ // record would let a later tier crossing adopt it without re-prompting.
789
+ const existing = insertionRecord(mark);
790
+ const existingTier = existing && insertionMatchesCart(existing, cartToken)
791
+ ? recordTierThreshold(existing)
792
+ : null;
793
+ recordInserted(mark, cartToken, variantId
794
+ ? `gid://shopify/ProductVariant/${variantId}`
795
+ : undefined, existingTier ?? undefined);
796
+ found = true;
797
+ }
798
+ return found;
799
+ }
800
+
801
+ export function syncGiftWithPurchase(
802
+ settings: CommerceOffersSettings | undefined,
803
+ assignments: CartAssignment[],
804
+ options?: GiftSyncOptions,
805
+ ): Promise<void> {
806
+ latestArgs = { settings, assignments, options };
807
+ installChoiceRequestListener();
808
+ // Liveness derives from the PREVIOUS eligibility state until the async
809
+ // reconciliation below re-proves it; clear synchronously and advance the
810
+ // generation so neither a chooser callback nor an in-flight pass built on
811
+ // the old arguments can resurrect a stale proven tier.
812
+ choiceTierGeneration += 1;
813
+ const batchGeneration = choiceTierGeneration;
814
+ activeChoiceTier.clear();
815
+ if (options?.recheckTeardown) teardownChecked = false;
816
+ if (!settings?.experiments.length && !hasInsertionRecords()) {
817
+ // No offers plane at all: nothing to reconcile (shops that never ran
818
+ // offers have no tombstone). A present-but-empty plane is the teardown
819
+ // tombstone — read the cart once per page load (reset on cart mutations)
820
+ // so an orphaned gift is discovered even when local insertion records
821
+ // are gone (cleared storage, another device over a customer-synced cart).
822
+ if (!settings || teardownChecked) return Promise.resolve();
823
+ }
824
+ if (inFlight) {
825
+ // The active run may have read the cart before the mutation that
826
+ // triggered this call — queue one trailing run (latest args win) instead
827
+ // of dropping the reconciliation.
828
+ queuedArgs = { settings, assignments, options, generation: batchGeneration };
829
+ return inFlight;
830
+ }
831
+ // Any pass that mutates the cart re-reads and re-verifies its decisions
832
+ // (bounded): the cart may have changed between the read and the mutation
833
+ // (e.g. the subtotal dropped below threshold before the gift add landed),
834
+ // and shared-line decisions must be order-independent across experiments.
835
+ const runToFixpoint = async (
836
+ nextSettings: CommerceOffersSettings | undefined,
837
+ nextAssignments: CartAssignment[],
838
+ nextOptions: GiftSyncOptions | undefined,
839
+ // Fixed per argument batch: a later batch bumps the global counter, and
840
+ // every remaining pass of THIS batch must keep failing the liveness
841
+ // check rather than re-reading the now-current generation.
842
+ generation: number,
843
+ ) => {
844
+ let anyMutated = false;
845
+ for (let pass = 0; pass < 3; pass += 1) {
846
+ const mutated = await run(nextSettings, nextAssignments, nextOptions, generation).catch(() => false);
847
+ if (mutated) anyMutated = true;
848
+ if (!mutated) break;
849
+ }
850
+ if (anyMutated && typeof window !== "undefined") {
851
+ // Our mutations suppress the SDK's own cart observer, so themes whose
852
+ // drawers render from their original add/change responses would show a
853
+ // stale cart. Publish a documented signal for themes/integrators to
854
+ // re-render from /cart.js (checkout charges are correct regardless —
855
+ // the backend cart is authoritative; this is display freshness).
856
+ try {
857
+ window.dispatchEvent(
858
+ new CustomEvent("apex:cart:changed", { detail: { source: "gwp_gift" } }),
859
+ );
860
+ } catch { /* best effort */ }
861
+ }
862
+ };
863
+ const pending = (async () => {
864
+ await runToFixpoint(settings, assignments, options, batchGeneration);
865
+ while (queuedArgs) {
866
+ const next = queuedArgs;
867
+ queuedArgs = null;
868
+ await runToFixpoint(next.settings, next.assignments, next.options, next.generation);
869
+ }
870
+ if (choiceReplayRequested) getPendingGiftChoices().forEach(emitChoicePrompt);
871
+ choiceReplayRequested = false;
872
+ })().finally(() => {
873
+ if (inFlight === pending) inFlight = null;
874
+ if (!inFlight && queuedArgs) {
875
+ const next = queuedArgs;
876
+ queuedArgs = null;
877
+ void syncGiftWithPurchase(next.settings, next.assignments, next.options);
878
+ }
879
+ });
880
+ inFlight = pending;
881
+ return pending;
882
+ }
883
+
884
+ /** Selects an option previously offered by `apex:gwp:choice`. The next
885
+ * reconciliation validates the current signed arm/tier before mutating. */
886
+ export function selectGiftOption(experimentId: string, variantGid: string): Promise<void> {
887
+ if (!latestArgs || numericVariantId(variantGid) === null) return Promise.resolve();
888
+ const experiment = latestArgs.settings?.experiments.find((item) => item.experiment_id === experimentId);
889
+ const variationId = latestArgs.assignments.find((item) => item.experimentId === experimentId)?.variationId;
890
+ const variant = variationId ? experiment?.variants[variationId] : undefined;
891
+ if (!variant || typeof variant !== "object") return Promise.resolve();
892
+ const tiers = giftTiers(variant as Record<string, unknown>);
893
+ const containingTiers = tiers?.filter((tier) =>
894
+ tier.gift_choice_variant_gids?.includes(variantGid)) ?? [];
895
+ // The same GID can be a valid choice in several tiers: bind the selection
896
+ // to the tier that is applicable right now (tracked each reconciliation
897
+ // pass), so answering the live prompt records the live tier — dispatched
898
+ // keys from earlier tier crossings must not decide this.
899
+ const applicableThreshold = activeChoiceTier.get(experimentId);
900
+ // No live choice tier (shopper dropped below threshold, or moved to a
901
+ // fixed tier) → a delayed chooser callback must be ignored, not recorded
902
+ // against a tier that is no longer prompting.
903
+ const owningTier = containingTiers.find(
904
+ (tier) => tier.threshold === applicableThreshold,
905
+ );
906
+ if (!owningTier) return Promise.resolve();
907
+ selectedGiftOptions.set(experimentId, {
908
+ gid: variantGid,
909
+ tierThreshold: owningTier.threshold,
910
+ pending: true,
911
+ });
912
+ clearDispatchedChoices(experimentId);
913
+ return syncGiftWithPurchase(latestArgs.settings, latestArgs.assignments, latestArgs.options);
914
+ }
915
+
916
+ function emitChoicePrompt(detail: PendingGiftChoice): void {
917
+ dispatchEvent(new CustomEvent("apex:gwp:choice", { detail }));
918
+ }
919
+
920
+ // Documented replay contract: dispatching apex:gwp:choice:request makes the
921
+ // SDK re-emit apex:gwp:choice for every live, unanswered prompt, so choosers
922
+ // mounted after the initial dispatch can recover it. Registered lazily from
923
+ // the sync path so importing this module (e.g. via the @drip-apex/sdk/chooser
924
+ // entry) stays free of runtime side effects.
925
+ let choiceRequestListenerInstalled = false;
926
+ let choiceReplayRequested = false;
927
+ function installChoiceRequestListener(): void {
928
+ if (choiceRequestListenerInstalled || typeof window === "undefined") return;
929
+ choiceRequestListenerInstalled = true;
930
+ window.addEventListener("apex:gwp:choice:request", () => {
931
+ if (inFlight) choiceReplayRequested = true;
932
+ getPendingGiftChoices().forEach(emitChoicePrompt);
933
+ });
934
+ }
935
+
936
+ /** Returns live, unanswered gift-choice prompts. This replayable query lets a
937
+ * chooser mounted after `apex:gwp:choice` was dispatched discover the same
938
+ * prompt; pass an experiment id to restrict the result. Returned option
939
+ * arrays are copies and may be safely modified by caller code. */
940
+ export function getPendingGiftChoices(experimentId?: string): PendingGiftChoice[] {
941
+ if (!latestArgs) return [];
942
+ const prompts: PendingGiftChoice[] = [];
943
+ for (const [activeExperimentId, threshold] of activeChoiceTier) {
944
+ if (experimentId !== undefined && activeExperimentId !== experimentId) continue;
945
+ const selected = selectedGiftOptions.get(activeExperimentId);
946
+ if (selected?.tierThreshold === threshold) continue;
947
+ const experiment = latestArgs.settings?.experiments.find(
948
+ (item) => item.experiment_id === activeExperimentId,
949
+ );
950
+ const variationId = latestArgs.assignments.find(
951
+ (item) => item.experimentId === activeExperimentId,
952
+ )?.variationId;
953
+ const variant = variationId ? experiment?.variants[variationId] : undefined;
954
+ if (!variant || typeof variant !== "object") continue;
955
+ const tier = giftTiers(variant as Record<string, unknown>)?.find(
956
+ (candidate) => candidate.threshold === threshold,
957
+ );
958
+ if (!tier?.gift_choice_variant_gids) continue;
959
+ prompts.push({
960
+ experimentId: activeExperimentId,
961
+ threshold,
962
+ options: [...tier.gift_choice_variant_gids],
963
+ });
964
+ }
965
+ return prompts;
966
+ }
967
+
968
+ export const __giftManagerInternals = {
969
+ reset() {
970
+ inFlight = null;
971
+ queuedArgs = null;
972
+ insertedThisSession.clear();
973
+ selectedGiftOptions.clear();
974
+ activeChoiceTier.clear();
975
+ dispatchedChoices.clear();
976
+ latestArgs = null;
977
+ teardownChecked = false;
978
+ },
979
+ };