@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,447 @@
1
+ import type { Assignment, CommerceShippingSettings } from "./types";
2
+ import { onShopifyCartMutation } from "./shopify-cart-events";
3
+ import {
4
+ findCartDrawerContainer,
5
+ installCartDrawerOpenListener,
6
+ } from "./surface-signals";
7
+
8
+ type ShopifyWindow = Window & {
9
+ Shopify?: {
10
+ currency?: { active?: string };
11
+ routes?: { root?: string };
12
+ };
13
+ };
14
+
15
+ type ActiveShipping = {
16
+ experimentId: string;
17
+ currency: string;
18
+ variant: CommerceShippingSettings["experiments"][number]["variants"][string];
19
+ eligibilityTarget: { productGids: string[]; variantGids: string[] };
20
+ renderEnabled: boolean;
21
+ thresholdCents: number | null;
22
+ minorUnits: number;
23
+ };
24
+
25
+ let current: ActiveShipping | null = null;
26
+ let stopCartMutationListener: (() => void) | null = null;
27
+ let stopDrawerListener: (() => void) | null = null;
28
+ let placementObserver: MutationObserver | null = null;
29
+ let placementCheckTimer: number | null = null;
30
+
31
+ function removeRenderedElement(): void {
32
+ element?.remove();
33
+ element = null;
34
+ }
35
+ let element: HTMLElement | null = null;
36
+ let lastSubtotal: number | null = null;
37
+ let refreshSequence = 0;
38
+ let cartAbortController: AbortController | null = null;
39
+ const cartEligibility = new Map<string, boolean>();
40
+
41
+ export function isShippingCartEligible(experimentId: string): boolean {
42
+ // Fail closed: until a cart read proves the target is absent, an enforced
43
+ // experiment stays charge-affecting — an unevaluated (e.g. unsigned-plane)
44
+ // experiment must not silently downgrade to advisory stamping.
45
+ return cartEligibility.get(experimentId) ?? true;
46
+ }
47
+
48
+ type ShippingVariantLike = {
49
+ treatment: string;
50
+ threshold: string | null;
51
+ target?: { productGids: string[]; variantGids: string[] };
52
+ };
53
+
54
+ /** Shared exposure classification for the sync loop and the exposure fence.
55
+ * Treatments prove eligibility on their OWN target; controls mirror the
56
+ * treatment set — any-targeted for scope (an unscoped sibling keeps the
57
+ * control universal), every-bound for currency (a thresholdless percent
58
+ * sibling keeps the control cross-currency eligible). */
59
+ export function classifyShippingExposure(
60
+ variants: Record<string, ShippingVariantLike>,
61
+ isControl: boolean,
62
+ ownVariant: ShippingVariantLike | undefined,
63
+ ): {
64
+ eligibilityTarget: { productGids: string[]; variantGids: string[] };
65
+ needsTargetProof: boolean;
66
+ isCurrencyBound: boolean;
67
+ } {
68
+ const empty = { productGids: [] as string[], variantGids: [] as string[] };
69
+ const scoped = (candidate: ShippingVariantLike | undefined) =>
70
+ (candidate?.target?.productGids.length ?? 0) > 0 ||
71
+ (candidate?.target?.variantGids.length ?? 0) > 0;
72
+ const bound = (candidate: ShippingVariantLike | undefined) =>
73
+ candidate != null &&
74
+ candidate.treatment !== "none" &&
75
+ (candidate.threshold !== null || candidate.treatment !== "percent_off");
76
+ const treatments = Object.values(variants).filter(
77
+ (candidate) => candidate.treatment !== "none",
78
+ );
79
+ if (!isControl) {
80
+ return {
81
+ eligibilityTarget: ownVariant?.target ?? empty,
82
+ needsTargetProof: scoped(ownVariant),
83
+ isCurrencyBound: bound(ownVariant),
84
+ };
85
+ }
86
+ const hasUnscopedTreatment = treatments.some((candidate) => !scoped(candidate));
87
+ return {
88
+ eligibilityTarget: hasUnscopedTreatment
89
+ ? empty
90
+ : treatments.reduce(
91
+ (target, candidate) => ({
92
+ productGids: [...target.productGids, ...(candidate.target?.productGids ?? [])],
93
+ variantGids: [...target.variantGids, ...(candidate.target?.variantGids ?? [])],
94
+ }),
95
+ empty,
96
+ ),
97
+ needsTargetProof: treatments.some(scoped),
98
+ isCurrencyBound: treatments.length > 0 && treatments.every(bound),
99
+ };
100
+ }
101
+
102
+ /** True only after shipping setup or a successful cart read proves eligibility. */
103
+ export function isShippingCartEligibilityConfirmed(
104
+ experimentId: string,
105
+ ): boolean {
106
+ return cartEligibility.get(experimentId) === true;
107
+ }
108
+
109
+ function currencyMinorUnits(currency: string): number | null {
110
+ try {
111
+ const units = new Intl.NumberFormat("en", {
112
+ style: "currency",
113
+ currency,
114
+ }).resolvedOptions().maximumFractionDigits;
115
+ return typeof units === "number" &&
116
+ Number.isInteger(units) &&
117
+ units >= 0 &&
118
+ // ISO 4217 tops out at exponent 4 (CLF, UYW).
119
+ units <= 4
120
+ ? units
121
+ : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function thresholdCents(value: string, minorUnits: number): number | null {
128
+ const fractionPattern =
129
+ minorUnits === 0 ? "" : `(?:\\.(\\d{1,${minorUnits}}))?`;
130
+ const match = new RegExp(`^(\\d+)${fractionPattern}$`).exec(value);
131
+ if (!match) return null;
132
+ const cents = Number(match[1] + (match[2] ?? "").padEnd(minorUnits, "0"));
133
+ return Number.isSafeInteger(cents) && cents > 0 ? cents : null;
134
+ }
135
+
136
+ function shopifyRoot(): string {
137
+ const root = (window as ShopifyWindow).Shopify?.routes?.root;
138
+ if (typeof root !== "string") return "/";
139
+ try {
140
+ const parsed = new URL(root, location.origin);
141
+ if (parsed.origin !== location.origin || parsed.search || parsed.hash)
142
+ return "/";
143
+ return parsed.pathname.endsWith("/")
144
+ ? parsed.pathname
145
+ : `${parsed.pathname}/`;
146
+ } catch {
147
+ return "/";
148
+ }
149
+ }
150
+
151
+ function cartPath(resource: string): string {
152
+ return `${shopifyRoot()}${resource}`;
153
+ }
154
+
155
+ function activeShipping(
156
+ settings: CommerceShippingSettings,
157
+ assignments: Assignment[],
158
+ ): ActiveShipping | null {
159
+ if (typeof window === "undefined") return null;
160
+ const shopify = (window as ShopifyWindow).Shopify;
161
+ if (!shopify) return null;
162
+ const currency = shopify.currency?.active;
163
+ if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) return null;
164
+ const minorUnits = currencyMinorUnits(currency);
165
+ if (minorUnits === null) return null;
166
+ const candidates: ActiveShipping[] = [];
167
+ for (const experiment of settings.experiments) {
168
+ const assignment = assignments.find(
169
+ (item) => item.experimentId === experiment.experiment_id,
170
+ );
171
+ const variant = assignment
172
+ ? experiment.variants[assignment.variationId]
173
+ : undefined;
174
+ const exposure = classifyShippingExposure(
175
+ experiment.variants,
176
+ Boolean(assignment?.isControl),
177
+ variant,
178
+ );
179
+ const eligibilityTarget = exposure.eligibilityTarget;
180
+ const isExperimentTargeted = exposure.needsTargetProof;
181
+ const isCurrencyBound = exposure.isCurrencyBound;
182
+ if (
183
+ !variant ||
184
+ (experiment.assignment_epoch != null &&
185
+ experiment.assignment_epoch !== assignment?.assignmentEpoch) ||
186
+ (isCurrencyBound && currency !== experiment.currency) ||
187
+ // Controls stay as eligibility candidates (renderEnabled excludes them
188
+ // from the banner): an untargeted threshold control must confirm cart
189
+ // currency the same way its treatment sibling would, or it never exposes.
190
+ (variant.treatment === "none" && !assignment?.isControl)
191
+ ) {
192
+ continue;
193
+ }
194
+ const threshold =
195
+ variant.threshold === null
196
+ ? null
197
+ : thresholdCents(variant.threshold, minorUnits);
198
+ if (variant.threshold !== null && threshold === null) continue;
199
+ // Controls use the treatment union for symmetric eligibility; treatments
200
+ // use only their own arm's target.
201
+ cartEligibility.set(
202
+ experiment.experiment_id,
203
+ false,
204
+ );
205
+ candidates.push({
206
+ experimentId: experiment.experiment_id,
207
+ currency,
208
+ variant,
209
+ eligibilityTarget,
210
+ renderEnabled: !assignment?.isControl && variant.treatment !== "none",
211
+ thresholdCents: threshold,
212
+ minorUnits,
213
+ });
214
+ }
215
+ return candidates.length === 1 ? candidates[0] : null;
216
+ }
217
+
218
+ function money(cents: number, currency: string, minorUnits: number): string {
219
+ return new Intl.NumberFormat(undefined, {
220
+ style: "currency",
221
+ currency,
222
+ }).format(cents / 10 ** minorUnits);
223
+ }
224
+
225
+ function resolveCopy(
226
+ template: string,
227
+ subtotal: number,
228
+ active: ActiveShipping,
229
+ ): string {
230
+ return template
231
+ .split("{remaining}")
232
+ .join(
233
+ money(
234
+ Math.max(0, (active.thresholdCents ?? 0) - subtotal),
235
+ active.currency,
236
+ active.minorUnits,
237
+ ),
238
+ )
239
+ .split("{threshold}")
240
+ .join(
241
+ money(active.thresholdCents ?? 0, active.currency, active.minorUnits),
242
+ );
243
+ }
244
+
245
+ function render(subtotal: number, active: ActiveShipping): boolean {
246
+ removeRenderedElement();
247
+ const reached =
248
+ active.thresholdCents !== null && subtotal >= active.thresholdCents;
249
+ const copy = resolveCopy(
250
+ active.thresholdCents === null
251
+ ? active.variant.bannerCopy
252
+ : reached
253
+ ? active.variant.reachedCopy
254
+ : active.variant.progressCopy,
255
+ subtotal,
256
+ active,
257
+ );
258
+ if (!copy.trim()) return false;
259
+
260
+ const wrapper = document.createElement("div");
261
+ wrapper.dataset.apexShipping = "";
262
+ wrapper.setAttribute("role", "status");
263
+ wrapper.setAttribute("aria-live", "polite");
264
+ wrapper.setAttribute("aria-atomic", "true");
265
+ wrapper.style.cssText =
266
+ "box-sizing:border-box;padding:10px 12px;background:#f4f4f0;color:#1a1a1a;font:500 13px/1.4 system-ui,sans-serif;z-index:2147483646";
267
+ const line = document.createElement("div");
268
+ line.textContent = copy;
269
+ wrapper.appendChild(line);
270
+ if (
271
+ active.variant.treatment === "progress_bar" &&
272
+ active.thresholdCents !== null
273
+ ) {
274
+ const track = document.createElement("div");
275
+ const progress = Math.min(
276
+ 100,
277
+ Math.max(0, (subtotal / active.thresholdCents) * 100),
278
+ );
279
+ track.setAttribute("role", "progressbar");
280
+ track.setAttribute("aria-label", "Free shipping progress");
281
+ track.setAttribute("aria-valuemin", "0");
282
+ track.setAttribute("aria-valuemax", "100");
283
+ track.setAttribute("aria-valuenow", String(Math.round(progress)));
284
+ track.style.cssText =
285
+ "height:6px;margin-top:8px;overflow:hidden;border-radius:999px;background:#d9d9d2";
286
+ const fill = document.createElement("div");
287
+ fill.style.cssText = `height:100%;width:${progress}%;border-radius:inherit;background:#1a1a1a`;
288
+ track.appendChild(fill);
289
+ wrapper.appendChild(track);
290
+ }
291
+ const drawer = findCartDrawerContainer();
292
+ if (drawer) {
293
+ drawer.prepend(wrapper);
294
+ } else {
295
+ wrapper.style.position = "fixed";
296
+ wrapper.style.inset = "0 0 auto";
297
+ document.body.prepend(wrapper);
298
+ }
299
+ element = wrapper;
300
+ return true;
301
+ }
302
+
303
+ function cartMatchesTarget(
304
+ items: unknown,
305
+ target: { productGids: string[]; variantGids: string[] },
306
+ ): boolean {
307
+ if (target.productGids.length === 0 && target.variantGids.length === 0)
308
+ return true;
309
+ if (!Array.isArray(items)) return false;
310
+ const products = new Set(target.productGids);
311
+ const variants = new Set(target.variantGids);
312
+ return items.some((item) => {
313
+ if (!item || typeof item !== "object") return false;
314
+ const line = item as { product_id?: unknown; variant_id?: unknown };
315
+ const product =
316
+ typeof line.product_id === "number" || typeof line.product_id === "string"
317
+ ? `gid://shopify/Product/${line.product_id}`
318
+ : null;
319
+ const variant =
320
+ typeof line.variant_id === "number" || typeof line.variant_id === "string"
321
+ ? `gid://shopify/ProductVariant/${line.variant_id}`
322
+ : null;
323
+ return (
324
+ (product !== null && products.has(product)) ||
325
+ (variant !== null && variants.has(variant))
326
+ );
327
+ });
328
+ }
329
+
330
+ async function refreshCart(
331
+ active: ActiveShipping,
332
+ onEligibilityConfirmed?: () => void,
333
+ ): Promise<void> {
334
+ const sequence = ++refreshSequence;
335
+ cartAbortController?.abort();
336
+ const controller =
337
+ typeof AbortController === "function" ? new AbortController() : null;
338
+ cartAbortController = controller;
339
+ const timeout = controller
340
+ ? window.setTimeout(() => controller.abort(), 5_000)
341
+ : null;
342
+ lastSubtotal = null;
343
+ element?.remove();
344
+ element = null;
345
+ try {
346
+ const response = await window.fetch(cartPath("cart.js"), {
347
+ headers: { Accept: "application/json" },
348
+ ...(controller ? { signal: controller.signal } : {}),
349
+ });
350
+ if (!response.ok) return;
351
+ const cart = (await response.json()) as {
352
+ currency?: unknown;
353
+ items_subtotal_price?: unknown;
354
+ items?: unknown;
355
+ };
356
+ const subtotal = cart.items_subtotal_price;
357
+ const targetEligible = cartMatchesTarget(
358
+ cart.items,
359
+ active.eligibilityTarget,
360
+ );
361
+ if (sequence !== refreshSequence || current !== active) return;
362
+ const currencyEligible = cart.currency === active.currency;
363
+ cartEligibility.set(active.experimentId, targetEligible && currencyEligible);
364
+ if (!targetEligible || !currencyEligible) {
365
+ lastSubtotal = null;
366
+ removeRenderedElement();
367
+ }
368
+ if (
369
+ !currencyEligible ||
370
+ typeof subtotal !== "number" ||
371
+ !Number.isSafeInteger(subtotal) ||
372
+ subtotal < 0 ||
373
+ !targetEligible
374
+ )
375
+ return;
376
+ lastSubtotal = subtotal;
377
+ onEligibilityConfirmed?.();
378
+ if (active.renderEnabled) render(subtotal, active);
379
+ } catch {
380
+ // Shipping messaging is additive; storefront cart behavior must never fail with it.
381
+ } finally {
382
+ if (timeout !== null) window.clearTimeout(timeout);
383
+ if (cartAbortController === controller) cartAbortController = null;
384
+ }
385
+ }
386
+
387
+ function installPlacementObserver(active: ActiveShipping): void {
388
+ placementObserver = new MutationObserver(() => {
389
+ if (placementCheckTimer !== null) return;
390
+ placementCheckTimer = window.setTimeout(() => {
391
+ placementCheckTimer = null;
392
+ if (
393
+ current !== active ||
394
+ lastSubtotal === null ||
395
+ cartEligibility.get(active.experimentId) !== true
396
+ )
397
+ return;
398
+ const parent = findCartDrawerContainer() ?? document.body;
399
+ if (!element?.isConnected || element.parentElement !== parent) {
400
+ render(lastSubtotal, active);
401
+ }
402
+ }, 80);
403
+ });
404
+ placementObserver.observe(document.documentElement, {
405
+ childList: true,
406
+ subtree: true,
407
+ });
408
+ }
409
+
410
+ export function applyCommerceShipping(
411
+ settings: CommerceShippingSettings | undefined,
412
+ assignments: Assignment[],
413
+ onEligibilityConfirmed?: () => void,
414
+ ): void {
415
+ cleanupCommerceShipping();
416
+ if (!settings) return;
417
+ const active = activeShipping(settings, assignments);
418
+ if (!active) return;
419
+ current = active;
420
+ stopCartMutationListener = onShopifyCartMutation(() => {
421
+ if (current === active) void refreshCart(active, onEligibilityConfirmed);
422
+ });
423
+ installPlacementObserver(active);
424
+ stopDrawerListener = installCartDrawerOpenListener((open) => {
425
+ if (open) void refreshCart(active, onEligibilityConfirmed);
426
+ });
427
+ void refreshCart(active, onEligibilityConfirmed);
428
+ }
429
+
430
+ export function cleanupCommerceShipping(): void {
431
+ refreshSequence += 1;
432
+ cartAbortController?.abort();
433
+ cartAbortController = null;
434
+ current = null;
435
+ cartEligibility.clear();
436
+ lastSubtotal = null;
437
+ element?.remove();
438
+ element = null;
439
+ stopDrawerListener?.();
440
+ stopDrawerListener = null;
441
+ placementObserver?.disconnect();
442
+ placementObserver = null;
443
+ if (placementCheckTimer !== null) window.clearTimeout(placementCheckTimer);
444
+ placementCheckTimer = null;
445
+ stopCartMutationListener?.();
446
+ stopCartMutationListener = null;
447
+ }