@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,941 @@
1
+ import type { Assignment, CommercePriceSettings } from "./types";
2
+ import { formatCentsLike, parseDisplayedCents } from "./money";
3
+
4
+ type PriceAssignmentAuthorization = (assignment: Assignment) => boolean;
5
+
6
+ export const COMMERCE_PRICE_SELECTORS =
7
+ '.price,.price__regular,.price-item,.price-item--regular,.product__price,.product-price,[data-price],[itemprop="price"]'.split(
8
+ ",",
9
+ );
10
+ export const COMMERCE_PRICE_EXCLUSIONS =
11
+ 's,del,.price__compare,.price-item--compare,[class*="compare-at"]'.split(",");
12
+
13
+ type ShopifyWindow = Window & {
14
+ Shopify?: { currency?: { active?: string } };
15
+ ShopifyAnalytics?: {
16
+ meta?: {
17
+ page?: { pageType?: string; handle?: string };
18
+ product?: {
19
+ handle?: string;
20
+ variants?: Array<{ id?: string | number; price?: string | number }>;
21
+ };
22
+ };
23
+ };
24
+ };
25
+
26
+ const ZERO_DECIMAL =
27
+ /^(BIF|CLP|DJF|GNF|ISK|JPY|KMF|KRW|PYG|RWF|UGX|VND|VUV|XAF|XOF|XPF)$/;
28
+ let observer: MutationObserver | null = null;
29
+ let rootObserver: MutationObserver | null = null;
30
+ let variantForm: HTMLFormElement | null = null;
31
+ const handleVariantFormEvent = () => guardedSwapPass();
32
+ let originals = new Map<Text, { original: string; applied: string }>();
33
+ let originalAriaLabels = new Map<
34
+ Element,
35
+ { original: string; applied: string }
36
+ >();
37
+ let compareAtNodes: HTMLElement[] = [];
38
+ let hiddenCompareAtNodes = new Map<HTMLElement, string>();
39
+ let renderedDisplayAssignment: Assignment | null = null;
40
+ let exposureChanged: (() => void) | undefined;
41
+ let current: {
42
+ settings: CommercePriceSettings;
43
+ assignments: Assignment[];
44
+ isCohortStampedOnCart: () => boolean | "pending";
45
+ isAssignmentAuthorized: PriceAssignmentAuthorization;
46
+ } | null = null;
47
+ let applying = false;
48
+ let metaRetryTimer: ReturnType<typeof setTimeout> | null = null;
49
+ let stampCheckTimer: ReturnType<typeof setTimeout> | null = null;
50
+ let stampCheckDeadline = 0;
51
+ let pendingActivation: {
52
+ settings: CommercePriceSettings;
53
+ assignments: Assignment[];
54
+ isCohortStampedOnCart: () => boolean | "pending";
55
+ deadline: number;
56
+ onResolved?: () => void;
57
+ isAssignmentAuthorized: PriceAssignmentAuthorization;
58
+ } | null = null;
59
+
60
+ /** Whether price teardown has state to restore, disconnect, or cancel. */
61
+ export function hasCommercePriceState(): boolean {
62
+ return (
63
+ observer !== null ||
64
+ rootObserver !== null ||
65
+ originals.size > 0 ||
66
+ originalAriaLabels.size > 0 ||
67
+ current !== null ||
68
+ applying ||
69
+ metaRetryTimer !== null ||
70
+ stampCheckTimer !== null ||
71
+ pendingActivation !== null
72
+ );
73
+ }
74
+
75
+ /**
76
+ * How long activation may wait for the cohort stamp to be confirmed. The tested
77
+ * price is not shown until Shopify has accepted the signed cart attributes — a
78
+ * shopper must never be shown a price we cannot prove Shopify will charge.
79
+ */
80
+ const STAMP_CONFIRM_TIMEOUT_MS = 8000;
81
+
82
+ /**
83
+ * Shopify populates ShopifyAnalytics.meta.product AFTER the SDK boots (measured on a
84
+ * live storefront: SDK at 43ms, meta.product still absent at 46ms). Activation must
85
+ * therefore be retried instead of failing permanently, or the swap never runs on a
86
+ * real store.
87
+ */
88
+ const META_RETRY_INTERVAL_MS = 60;
89
+ const META_RETRY_WINDOW_MS = 5000;
90
+
91
+ function shopifyProductMetaReady(): boolean {
92
+ if (typeof window === "undefined") return false;
93
+ const meta = (window as ShopifyWindow).ShopifyAnalytics?.meta;
94
+ return Boolean(
95
+ (window as ShopifyWindow).Shopify && Array.isArray(meta?.product?.variants),
96
+ );
97
+ }
98
+
99
+ function cancelMetaRetry(): void {
100
+ if (metaRetryTimer != null) clearTimeout(metaRetryTimer);
101
+ metaRetryTimer = null;
102
+ }
103
+
104
+ function cancelStampCheck(): void {
105
+ if (stampCheckTimer != null) clearTimeout(stampCheckTimer);
106
+ stampCheckTimer = null;
107
+ stampCheckDeadline = 0;
108
+ }
109
+
110
+ function scheduleStampCheck(): void {
111
+ if (stampCheckTimer != null) return;
112
+ if (stampCheckDeadline === 0)
113
+ stampCheckDeadline = Date.now() + STAMP_CONFIRM_TIMEOUT_MS;
114
+ stampCheckTimer = setTimeout(() => {
115
+ stampCheckTimer = null;
116
+ if (!current) return;
117
+ const status = current.isCohortStampedOnCart();
118
+ if (status === false) {
119
+ // The cohort will never reach the cart: restore the published price.
120
+ cleanupCommercePrice();
121
+ return;
122
+ }
123
+ if (status === "pending") {
124
+ if (Date.now() >= stampCheckDeadline) {
125
+ // Unconfirmed for too long — fail closed rather than keep showing a price
126
+ // the Cart Transform may not apply.
127
+ cleanupCommercePrice();
128
+ return;
129
+ }
130
+ scheduleStampCheck();
131
+ }
132
+ }, 25);
133
+ }
134
+
135
+ function activeExperiments(
136
+ settings: CommercePriceSettings,
137
+ assignments: Assignment[],
138
+ isAssignmentAuthorized: PriceAssignmentAuthorization = () => true,
139
+ ) {
140
+ if (typeof window === "undefined") return [];
141
+ const w = window as ShopifyWindow;
142
+ const meta = w.ShopifyAnalytics?.meta;
143
+ if (
144
+ !w.Shopify ||
145
+ meta?.page?.pageType !== "product" ||
146
+ !Array.isArray(meta.product?.variants)
147
+ )
148
+ return [];
149
+ const currency = w.Shopify.currency?.active;
150
+ const pageVariantIds = new Set(
151
+ meta.product.variants
152
+ .filter((variant) => variant.id != null)
153
+ .map((variant) => String(variant.id)),
154
+ );
155
+ const active = [];
156
+ for (const experiment of settings.experiments) {
157
+ const assignment = assignments.find(
158
+ (item) => item.experimentId === experiment.experiment_id,
159
+ );
160
+ const adjustments = assignment
161
+ ? experiment.variants[assignment.variationId]
162
+ : undefined;
163
+ const targetsCurrentProduct = adjustments?.some((adjustment) => {
164
+ const variantId = adjustment.variant_gid.split("/").pop();
165
+ return variantId != null && pageVariantIds.has(variantId);
166
+ });
167
+ const epochMatches =
168
+ experiment.assignment_epoch == null ||
169
+ experiment.assignment_epoch === assignment?.assignmentEpoch;
170
+ if (
171
+ assignment &&
172
+ isAssignmentAuthorized(assignment) &&
173
+ epochMatches &&
174
+ !assignment.isControl &&
175
+ currency === experiment.currency &&
176
+ adjustments &&
177
+ targetsCurrentProduct
178
+ ) {
179
+ active.push({ experiment, assignment, currency });
180
+ }
181
+ }
182
+ return active;
183
+ }
184
+
185
+ function hasActiveChargedPrice(
186
+ settings: CommercePriceSettings,
187
+ assignments: Assignment[],
188
+ isAssignmentAuthorized: PriceAssignmentAuthorization,
189
+ ): boolean {
190
+ const active = activeExperiments(
191
+ settings,
192
+ assignments,
193
+ isAssignmentAuthorized,
194
+ );
195
+ if (active.length > 0)
196
+ return active.some(({ experiment, assignment }) =>
197
+ experiment.variants[assignment.variationId]?.some(
198
+ (adjustment) => typeof adjustment.fixedPricePerUnit === "string",
199
+ ),
200
+ );
201
+ // Before metadata or authorization arrives, retain the strict charged-arm
202
+ // barrier so activation can be revisited when the cart proof lands.
203
+ return settings.experiments.some((experiment) => {
204
+ const assignment = assignments.find(
205
+ (candidate) =>
206
+ candidate.experimentId === experiment.experiment_id &&
207
+ !candidate.isControl,
208
+ );
209
+ return Boolean(
210
+ assignment &&
211
+ (experiment.assignment_epoch == null ||
212
+ experiment.assignment_epoch === assignment.assignmentEpoch) &&
213
+ experiment.variants[assignment.variationId]?.some(
214
+ (adjustment) => typeof adjustment.fixedPricePerUnit === "string",
215
+ ),
216
+ );
217
+ });
218
+ }
219
+
220
+ /**
221
+ * True when a price treatment is assigned AND the page either is eligible now or could
222
+ * still become eligible (Shopify has not published product metadata yet). The reveal
223
+ * barrier uses this so a metadata-late PDP cannot flash the ORIGINAL price before the
224
+ * swap lands. The anti-flicker timeout still caps how long content can stay hidden.
225
+ */
226
+ export function isCommercePriceWorkPending(
227
+ settings: CommercePriceSettings | undefined,
228
+ assignments: Assignment[],
229
+ isAssignmentAuthorized: PriceAssignmentAuthorization = () => true,
230
+ ): boolean {
231
+ if (!settings) return false;
232
+ if (isCommercePriceActive(settings, assignments, isAssignmentAuthorized))
233
+ return true;
234
+ if (shopifyProductMetaReady()) return false;
235
+ return settings.experiments.some((experiment) =>
236
+ assignments.some(
237
+ (assignment) =>
238
+ assignment.experimentId === experiment.experiment_id &&
239
+ isAssignmentAuthorized(assignment) &&
240
+ !assignment.isControl &&
241
+ (experiment.assignment_epoch == null ||
242
+ experiment.assignment_epoch === assignment.assignmentEpoch),
243
+ ),
244
+ );
245
+ }
246
+
247
+ export function isCommercePriceActive(
248
+ settings: CommercePriceSettings | undefined,
249
+ assignments: Assignment[],
250
+ isAssignmentAuthorized: PriceAssignmentAuthorization = () => true,
251
+ ): boolean {
252
+ try {
253
+ return Boolean(
254
+ settings &&
255
+ activeExperiments(settings, assignments, isAssignmentAuthorized).length,
256
+ );
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ function isStrictlyLowerPrice(candidate: string, current: string): boolean {
263
+ return Number(candidate) < Number(current);
264
+ }
265
+
266
+ function treatmentUnits(value: string, minorUnits: number): number | null {
267
+ const match = value.match(/^(\d+)(?:\.(\d+))?$/);
268
+ if (!match) return null;
269
+ const fraction = match[2] ?? "";
270
+ const discarded = fraction.slice(minorUnits);
271
+ if (/[^0]/.test(discarded)) return null;
272
+ const units = Number(
273
+ match[1] + (fraction + "0".repeat(minorUnits)).slice(0, minorUnits),
274
+ );
275
+ return Number.isSafeInteger(units) ? units : null;
276
+ }
277
+
278
+ function replaceNumericText(
279
+ value: string,
280
+ targets: Map<number, number>,
281
+ minorUnits: number,
282
+ ): string | null {
283
+ const parsed = parseDisplayedCents(value, minorUnits);
284
+ if (!parsed) return null;
285
+ const replacement = targets.get(parsed.cents);
286
+ if (replacement == null || Number.isNaN(replacement)) return null;
287
+ return (
288
+ value.slice(0, parsed.start) +
289
+ formatCentsLike(replacement, parsed.convention) +
290
+ value.slice(parsed.end)
291
+ );
292
+ }
293
+
294
+ function applyAccessibleLabel(
295
+ node: Text,
296
+ container: Element,
297
+ targets: Map<number, number>,
298
+ minorUnits: number,
299
+ ): void {
300
+ const labelled = node.parentElement?.closest("[aria-label]");
301
+ if (!labelled || !container.contains(labelled)) return;
302
+ const label = labelled.getAttribute("aria-label");
303
+ if (label == null) return;
304
+ const replacement = replaceNumericText(label, targets, minorUnits);
305
+ if (replacement == null || replacement === label) return;
306
+ const previous = originalAriaLabels.get(labelled);
307
+ originalAriaLabels.set(labelled, {
308
+ original:
309
+ previous && label === previous.applied ? previous.original : label,
310
+ applied: replacement,
311
+ });
312
+ labelled.setAttribute("aria-label", replacement);
313
+ }
314
+
315
+ function guardedSwapPass(): void {
316
+ try {
317
+ swapPass();
318
+ } catch {
319
+ cleanupCommercePrice();
320
+ }
321
+ }
322
+
323
+ function productHandleFromUrl(value: string): string | null {
324
+ try {
325
+ const path = new URL(value, location.href).pathname;
326
+ const match = path.match(/\/products\/([^/]+)/i);
327
+ return match?.[1] ? decodeURIComponent(match[1]).toLowerCase() : null;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ function currentProductHandle(): string | null {
334
+ const meta = (window as ShopifyWindow).ShopifyAnalytics?.meta;
335
+ const metadataHandle = meta?.page?.handle ?? meta?.product?.handle;
336
+ if (typeof metadataHandle === "string" && metadataHandle.trim()) {
337
+ return metadataHandle.trim().toLowerCase();
338
+ }
339
+ const canonical = document.querySelector<HTMLLinkElement>(
340
+ 'link[rel="canonical"][href]',
341
+ );
342
+ return canonical ? productHandleFromUrl(canonical.href) : null;
343
+ }
344
+
345
+ function isElementLinkedToDifferentProduct(
346
+ node: Element,
347
+ currentHandle: string | null,
348
+ ): boolean {
349
+ const wrappingAnchor = node.closest<HTMLAnchorElement>(
350
+ 'a[href*="/products/"]',
351
+ );
352
+ const labelledContainer = node.closest(
353
+ "li,article,[data-product-id],[class*='product-card'],[class*='product-item'],[class*='card']",
354
+ );
355
+ const siblingContainer =
356
+ node.parentElement && node.parentElement !== document.body
357
+ ? node.parentElement
358
+ : null;
359
+ const container = labelledContainer ?? siblingContainer;
360
+ const productAnchors = container
361
+ ? Array.from(
362
+ container.querySelectorAll<HTMLAnchorElement>('a[href*="/products/"]'),
363
+ ).filter(
364
+ (candidate) =>
365
+ labelledContainer != null || candidate.parentElement === container,
366
+ )
367
+ : [];
368
+ const linkedHandles = new Set(
369
+ productAnchors
370
+ .map((candidate) => productHandleFromUrl(candidate.href))
371
+ .filter((handle): handle is string => handle != null),
372
+ );
373
+ // A descendant link is ownership evidence only inside the nearest explicit
374
+ // product/card container (or the price's direct parent for sibling labels),
375
+ // and only when that container identifies one product. A recommendation
376
+ // elsewhere under a broad PDP ancestor must not claim the main price.
377
+ const anchor =
378
+ wrappingAnchor ??
379
+ (linkedHandles.size === 1 ? (productAnchors[0] ?? null) : null);
380
+ if (!anchor) return false;
381
+ const linkedHandle = productHandleFromUrl(anchor.href);
382
+ if (!linkedHandle) return false;
383
+ // If current-product identity is unavailable, fail open only for unlinked
384
+ // candidates: rewriting any product-linked price could corrupt another item.
385
+ return currentHandle == null || linkedHandle !== currentHandle;
386
+ }
387
+
388
+ function isLinkedToDifferentProduct(
389
+ node: Text,
390
+ currentHandle: string | null,
391
+ ): boolean {
392
+ return node.parentElement
393
+ ? isElementLinkedToDifferentProduct(node.parentElement, currentHandle)
394
+ : currentHandle == null;
395
+ }
396
+
397
+ function currentVariantId(
398
+ variants: Array<{ id?: string | number; price?: string | number }>,
399
+ ): string | null {
400
+ const currentHandle = currentProductHandle();
401
+ const productForm = Array.from(
402
+ document.querySelectorAll<HTMLFormElement>('form[action*="/cart/add"]'),
403
+ ).find((form) => !isElementLinkedToDifferentProduct(form, currentHandle));
404
+ const selected = productForm?.querySelector<
405
+ HTMLInputElement | HTMLSelectElement
406
+ >(
407
+ "[name=id][type=radio]:checked,select[name=id],input[name=id]:not([type=radio])",
408
+ )?.value;
409
+ if (selected && variants.some((variant) => String(variant.id) === selected))
410
+ return selected;
411
+ const urlVariant = new URL(location.href).searchParams.get("variant");
412
+ if (
413
+ urlVariant &&
414
+ variants.some((variant) => String(variant.id) === urlVariant)
415
+ )
416
+ return urlVariant;
417
+ return variants.length === 1 && variants[0]?.id != null
418
+ ? String(variants[0].id)
419
+ : null;
420
+ }
421
+
422
+ function swapPass(): void {
423
+ if (!current || applying) return;
424
+ const stampStatus = hasActiveChargedPrice(
425
+ current.settings,
426
+ current.assignments,
427
+ current.isAssignmentAuthorized,
428
+ )
429
+ ? current.isCohortStampedOnCart()
430
+ : true;
431
+ if (stampStatus === false) {
432
+ cleanupCommercePrice();
433
+ return;
434
+ }
435
+ if (stampStatus === "pending") scheduleStampCheck();
436
+ applying = true;
437
+ try {
438
+ const active = activeExperiments(
439
+ current.settings,
440
+ current.assignments,
441
+ current.isAssignmentAuthorized,
442
+ );
443
+ const variants = (window as ShopifyWindow).ShopifyAnalytics?.meta?.product
444
+ ?.variants;
445
+ if (!active.length || !Array.isArray(variants)) return;
446
+ const currency = active[0]!.currency;
447
+ const minorUnits = ZERO_DECIMAL.test(currency) ? 0 : 2;
448
+ // ShopifyAnalytics keeps prices on Shopify's two-decimal stored scale even
449
+ // when the storefront formats a zero-decimal currency such as JPY.
450
+ const shopifyStoredScale = minorUnits ? 1 : 100;
451
+ const published = new Map<string, number>();
452
+ for (const variant of variants) {
453
+ const price = Number(variant.price);
454
+ if (variant.id != null && Number.isSafeInteger(price) && price >= 0)
455
+ published.set(String(variant.id), price);
456
+ }
457
+ const selectedByVariant = new Map<string, string>();
458
+ for (const candidate of active) {
459
+ for (const adjustment of candidate.experiment.variants[
460
+ candidate.assignment.variationId
461
+ ]) {
462
+ if (typeof adjustment.fixedPricePerUnit !== "string") continue;
463
+ const id = adjustment.variant_gid.split("/").pop();
464
+ if (!id || !published.has(id)) continue;
465
+ const selected = selectedByVariant.get(id);
466
+ if (
467
+ selected == null ||
468
+ isStrictlyLowerPrice(adjustment.fixedPricePerUnit, selected)
469
+ ) {
470
+ selectedByVariant.set(id, adjustment.fixedPricePerUnit);
471
+ }
472
+ }
473
+ }
474
+ const compareByVariant = new Map<
475
+ string,
476
+ {
477
+ compare: { mode: "set"; amount: string } | { mode: "clear" };
478
+ assignment: Assignment;
479
+ }
480
+ >();
481
+ // Launch-side conflict screening rejects concurrent price tests on one
482
+ // variant, but the projection can still briefly carry both. Deterministic
483
+ // tie-break instead of last-iterated-wins: process in experiment-id order
484
+ // and keep the FIRST adjustment per variant, so every shopper session
485
+ // resolves the same collision identically.
486
+ const compareCandidates = [...active].sort((left, right) =>
487
+ left.experiment.experiment_id < right.experiment.experiment_id ? -1 : 1,
488
+ );
489
+ for (const candidate of compareCandidates) {
490
+ for (const adjustment of candidate.experiment.variants[
491
+ candidate.assignment.variationId
492
+ ]) {
493
+ const id = adjustment.variant_gid.split("/").pop();
494
+ if (
495
+ id &&
496
+ published.has(id) &&
497
+ adjustment.compareAt &&
498
+ !compareByVariant.has(id)
499
+ ) {
500
+ compareByVariant.set(id, {
501
+ compare: adjustment.compareAt,
502
+ assignment: candidate.assignment,
503
+ });
504
+ }
505
+ }
506
+ }
507
+ const targets = new Map<number, number>();
508
+ // The Function records every colliding experiment but charges the lowest
509
+ // authorized match. Keep every assignment exposure-eligible while using
510
+ // that same winner solely for the shopper-facing display.
511
+ for (const [id, fixedPricePerUnit] of selectedByVariant) {
512
+ const source = published.get(id);
513
+ const treatmentDisplayAmount = treatmentUnits(
514
+ fixedPricePerUnit,
515
+ minorUnits,
516
+ );
517
+ // The display plane must mirror the Cart Transform's decrease-only
518
+ // invariant. Invalid, equal, or higher treatment prices fail closed.
519
+ if (
520
+ source == null ||
521
+ treatmentDisplayAmount == null ||
522
+ treatmentDisplayAmount * shopifyStoredScale >= source
523
+ )
524
+ continue;
525
+ if (source % shopifyStoredScale) continue;
526
+ const sourceDisplayAmount = source / shopifyStoredScale;
527
+ const existing = targets.get(sourceDisplayAmount);
528
+ targets.set(
529
+ sourceDisplayAmount,
530
+ existing == null || existing === treatmentDisplayAmount
531
+ ? treatmentDisplayAmount
532
+ : NaN,
533
+ );
534
+ }
535
+ // Compare-at framing must describe a real discount. Reject malformed,
536
+ // equal, or lower reference prices instead of hiding the theme's native
537
+ // framing and manufacturing a false sale signal.
538
+ for (const [id, { compare }] of compareByVariant) {
539
+ if (compare.mode !== "set") continue;
540
+ const source = published.get(id);
541
+ const compareAmount = treatmentUnits(compare.amount, minorUnits);
542
+ const sourceAmount =
543
+ source != null && source % shopifyStoredScale === 0
544
+ ? source / shopifyStoredScale
545
+ : null;
546
+ const displayedAmount =
547
+ sourceAmount == null ? null : targets.get(sourceAmount) || sourceAmount;
548
+ if (
549
+ compareAmount == null ||
550
+ displayedAmount == null ||
551
+ compareAmount <= displayedAmount
552
+ ) {
553
+ compareByVariant.delete(id);
554
+ }
555
+ }
556
+ // A later DOM/variant pass can invalidate previously rendered framing.
557
+ // Continue through reconciliation when display eligibility was previously
558
+ // granted so stale treatment UI and exposure state are both withdrawn.
559
+ if (
560
+ !targets.size &&
561
+ !compareByVariant.size &&
562
+ renderedDisplayAssignment === null
563
+ )
564
+ return;
565
+
566
+ const productHandle = currentProductHandle();
567
+ const exclusion = COMMERCE_PRICE_EXCLUSIONS.join(",");
568
+ const containers = Array.from(
569
+ document.querySelectorAll(COMMERCE_PRICE_SELECTORS.join(",")),
570
+ );
571
+ const selectedVariantId = currentVariantId(variants);
572
+ const selectedCompare = selectedVariantId
573
+ ? compareByVariant.get(selectedVariantId)
574
+ : undefined;
575
+ const compare = selectedCompare?.compare;
576
+ let nextRenderedDisplayAssignment: Assignment | null = null;
577
+ compareAtNodes = compareAtNodes.filter((node) => node.isConnected);
578
+ const removeInjectedFraming = () => {
579
+ for (const node of compareAtNodes) node.remove();
580
+ compareAtNodes = [];
581
+ };
582
+ const removeApexCompareAt = () => {
583
+ removeInjectedFraming();
584
+ for (const [node, display] of hiddenCompareAtNodes)
585
+ node.style.display = display;
586
+ hiddenCompareAtNodes = new Map();
587
+ };
588
+ if (compare) {
589
+ const existingCompareNodes = new Set<HTMLElement>();
590
+ for (const container of containers) {
591
+ if (isElementLinkedToDifferentProduct(container, productHandle))
592
+ continue;
593
+ const scope = container.parentElement ?? container;
594
+ for (const existing of Array.from(
595
+ scope.querySelectorAll<HTMLElement>(exclusion),
596
+ )) {
597
+ if (existing.hasAttribute("data-apex-compare-at")) continue;
598
+ if (isElementLinkedToDifferentProduct(existing, productHandle))
599
+ continue;
600
+ existingCompareNodes.add(existing);
601
+ }
602
+ }
603
+ for (const existing of existingCompareNodes) {
604
+ if (!hiddenCompareAtNodes.has(existing))
605
+ hiddenCompareAtNodes.set(existing, existing.style.display);
606
+ existing.style.display = "none";
607
+ }
608
+ if (compare.mode === "set") {
609
+ const amount = treatmentUnits(compare.amount, minorUnits);
610
+ const source = selectedVariantId
611
+ ? published.get(selectedVariantId)
612
+ : undefined;
613
+ // The current price is read from Shopify and is never replaced for a
614
+ // compare-at-only arm. Only add merchant-authored reference framing.
615
+ if (amount != null && source != null) {
616
+ const eligible = containers.filter(
617
+ (container) =>
618
+ !isElementLinkedToDifferentProduct(container, productHandle),
619
+ );
620
+ // Prefer a LEAF price node: an outer wrapper's textContent bundles
621
+ // regular/sale/compare labels, and cloning it into the injected <s>
622
+ // renders duplicated or garbled prices.
623
+ const selector = COMMERCE_PRICE_SELECTORS.join(",");
624
+ const leaves = eligible.filter(
625
+ (container) => !container.querySelector(selector),
626
+ );
627
+ for (const container of leaves.length > 0 ? leaves : eligible) {
628
+ let node =
629
+ container.parentElement?.querySelector<HTMLElement>(
630
+ "[data-apex-compare-at]",
631
+ ) ?? null;
632
+ if (!node) {
633
+ node = document.createElement("s");
634
+ node.dataset.apexCompareAt = "true";
635
+ node.className = "apex-compare-at";
636
+ container.parentElement?.insertBefore(node, container);
637
+ compareAtNodes.push(node);
638
+ }
639
+ const sourceDisplayAmount = source / shopifyStoredScale;
640
+ const currentText = container.textContent ?? "";
641
+ const parsedCurrent = parseDisplayedCents(currentText, minorUnits);
642
+ // A node whose text still holds several amounts is not safe to
643
+ // clone-and-replace; format only the configured amount using the
644
+ // leaf's convention.
645
+ const singleAmount = parsedCurrent
646
+ ? parseDisplayedCents(
647
+ currentText.slice(0, parsedCurrent.start) +
648
+ currentText.slice(parsedCurrent.end),
649
+ minorUnits,
650
+ ) === null
651
+ : false;
652
+ const nextText = singleAmount
653
+ ? (replaceNumericText(
654
+ currentText,
655
+ new Map([[sourceDisplayAmount, amount]]),
656
+ minorUnits,
657
+ ) ??
658
+ (parsedCurrent
659
+ ? currentText.slice(0, parsedCurrent.start) +
660
+ formatCentsLike(amount, parsedCurrent.convention) +
661
+ currentText.slice(parsedCurrent.end)
662
+ : compare.amount))
663
+ : parsedCurrent
664
+ ? formatCentsLike(amount, parsedCurrent.convention)
665
+ : compare.amount;
666
+ if (node.textContent !== nextText) node.textContent = nextText;
667
+ }
668
+ if (compareAtNodes.some((node) => node.isConnected)) {
669
+ nextRenderedDisplayAssignment = selectedCompare!.assignment;
670
+ }
671
+ if (eligible.length === 0) removeApexCompareAt();
672
+ } else removeApexCompareAt();
673
+ }
674
+ // A clear arm's treatment IS the hidden native strikethrough: remove
675
+ // only injected framing and keep the natives hidden until the
676
+ // assignment ends or the variant's mode changes.
677
+ if (compare.mode === "clear") {
678
+ removeInjectedFraming();
679
+ if (hiddenCompareAtNodes.size > 0) {
680
+ nextRenderedDisplayAssignment = selectedCompare!.assignment;
681
+ }
682
+ }
683
+ } else {
684
+ removeApexCompareAt();
685
+ }
686
+ for (const container of containers) {
687
+ if (container.matches(exclusion) || container.closest(exclusion))
688
+ continue;
689
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
690
+ let node: Text | null;
691
+ while ((node = walker.nextNode() as Text | null)) {
692
+ if (node.parentElement?.closest(exclusion)) continue;
693
+ if (isLinkedToDifferentProduct(node, productHandle)) continue;
694
+ const replacement = replaceNumericText(node.data, targets, minorUnits);
695
+ if (replacement == null || replacement === node.data) continue;
696
+ applyAccessibleLabel(node, container, targets, minorUnits);
697
+ const previous = originals.get(node);
698
+ originals.set(node, {
699
+ original:
700
+ previous && node.data === previous.applied
701
+ ? previous.original
702
+ : node.data,
703
+ applied: replacement,
704
+ });
705
+ node.data = replacement;
706
+ }
707
+ }
708
+ observer?.disconnect();
709
+ rootObserver?.disconnect();
710
+ observer = new MutationObserver(guardedSwapPass);
711
+ containers.forEach((container) =>
712
+ observer!.observe(container, {
713
+ childList: true,
714
+ characterData: true,
715
+ subtree: true,
716
+ }),
717
+ );
718
+ rootObserver = new MutationObserver((records) => {
719
+ const selector = COMMERCE_PRICE_SELECTORS.join(",");
720
+ if (
721
+ records.some((record) =>
722
+ Array.from(record.addedNodes).some(
723
+ (node) =>
724
+ node instanceof Element &&
725
+ (node.matches(selector) || Boolean(node.querySelector(selector))),
726
+ ),
727
+ )
728
+ )
729
+ guardedSwapPass();
730
+ });
731
+ rootObserver.observe(document.body, { childList: true, subtree: true });
732
+ const eligibilityChanged =
733
+ nextRenderedDisplayAssignment !== renderedDisplayAssignment;
734
+ renderedDisplayAssignment = nextRenderedDisplayAssignment;
735
+ if (eligibilityChanged) exposureChanged?.();
736
+ const nextVariantForm =
737
+ Array.from(
738
+ document.querySelectorAll<HTMLFormElement>('form[action*="/cart/add"]'),
739
+ ).find(
740
+ (form) => !isElementLinkedToDifferentProduct(form, productHandle),
741
+ ) ?? null;
742
+ if (variantForm !== nextVariantForm) {
743
+ variantForm?.removeEventListener("change", handleVariantFormEvent);
744
+ variantForm?.removeEventListener("input", handleVariantFormEvent);
745
+ variantForm = nextVariantForm;
746
+ variantForm?.addEventListener("change", handleVariantFormEvent);
747
+ variantForm?.addEventListener("input", handleVariantFormEvent);
748
+ }
749
+ } finally {
750
+ applying = false;
751
+ }
752
+ }
753
+
754
+ function activateCommercePrice(
755
+ settings: CommercePriceSettings,
756
+ assignments: Assignment[],
757
+ isCohortStampedOnCart: () => boolean | "pending",
758
+ deadline: number,
759
+ onResolved?: () => void,
760
+ isAssignmentAuthorized: PriceAssignmentAuthorization = () => true,
761
+ ): void {
762
+ const stampStatus = hasActiveChargedPrice(
763
+ settings,
764
+ assignments,
765
+ isAssignmentAuthorized,
766
+ )
767
+ ? isCohortStampedOnCart()
768
+ : true;
769
+ if (stampStatus === "pending") {
770
+ if (stampCheckDeadline === 0) {
771
+ stampCheckDeadline = Date.now() + STAMP_CONFIRM_TIMEOUT_MS;
772
+ }
773
+ if (Date.now() >= stampCheckDeadline) {
774
+ cancelStampCheck();
775
+ const resolve = pendingActivation?.onResolved ?? onResolved;
776
+ if (pendingActivation) pendingActivation.onResolved = undefined;
777
+ resolve?.();
778
+ return;
779
+ }
780
+ if (stampCheckTimer == null) {
781
+ stampCheckTimer = setTimeout(() => {
782
+ stampCheckTimer = null;
783
+ activateCommercePrice(
784
+ settings,
785
+ assignments,
786
+ isCohortStampedOnCart,
787
+ deadline,
788
+ onResolved,
789
+ isAssignmentAuthorized,
790
+ );
791
+ }, 25);
792
+ }
793
+ return;
794
+ }
795
+ cancelStampCheck();
796
+ if (stampStatus === false) {
797
+ pendingActivation = null;
798
+ // No cohort on the cart: the Function cannot charge the tested price, so the
799
+ // published price must stay on screen. Resolve the reveal barrier immediately.
800
+ cancelMetaRetry();
801
+ onResolved?.();
802
+ return;
803
+ }
804
+ if (!isCommercePriceActive(settings, assignments, isAssignmentAuthorized)) {
805
+ // Retry only while the page could still BECOME eligible — i.e. Shopify has not
806
+ // published product metadata yet. Every other inactive reason (control cohort,
807
+ // currency mismatch, non-product page) is final and must not spin.
808
+ if (!shopifyProductMetaReady() && Date.now() < deadline) {
809
+ metaRetryTimer = setTimeout(
810
+ () =>
811
+ activateCommercePrice(
812
+ settings,
813
+ assignments,
814
+ isCohortStampedOnCart,
815
+ deadline,
816
+ onResolved,
817
+ isAssignmentAuthorized,
818
+ ),
819
+ META_RETRY_INTERVAL_MS,
820
+ );
821
+ return;
822
+ }
823
+ // Activation is final and inactive — release the reveal barrier.
824
+ cancelMetaRetry();
825
+ pendingActivation = null;
826
+ onResolved?.();
827
+ return;
828
+ }
829
+ cancelMetaRetry();
830
+ pendingActivation = null;
831
+ current = {
832
+ settings,
833
+ assignments,
834
+ isCohortStampedOnCart,
835
+ isAssignmentAuthorized,
836
+ };
837
+ try {
838
+ swapPass();
839
+ } catch {
840
+ cleanupCommercePrice();
841
+ }
842
+ // The swap has landed (or failed and restored): content may be revealed.
843
+ onResolved?.();
844
+ }
845
+
846
+ export function applyCommercePrice(
847
+ settings: CommercePriceSettings | undefined,
848
+ assignments: Assignment[],
849
+ isCohortStampedOnCart: () => boolean | "pending" = () => false,
850
+ onResolved?: () => void,
851
+ isAssignmentAuthorized: PriceAssignmentAuthorization = () => true,
852
+ ): void {
853
+ cleanupCommercePrice();
854
+ if (!settings) {
855
+ onResolved?.();
856
+ return;
857
+ }
858
+ const deadline = Date.now() + META_RETRY_WINDOW_MS;
859
+ pendingActivation = {
860
+ settings,
861
+ assignments,
862
+ isCohortStampedOnCart,
863
+ deadline,
864
+ onResolved,
865
+ isAssignmentAuthorized,
866
+ };
867
+ activateCommercePrice(
868
+ settings,
869
+ assignments,
870
+ isCohortStampedOnCart,
871
+ deadline,
872
+ onResolved,
873
+ isAssignmentAuthorized,
874
+ );
875
+ }
876
+
877
+ /** Revisit a timed-out activation when the in-flight Shopify cart write lands. */
878
+ export function notifyCommerceCartStampConfirmed(): void {
879
+ if (!pendingActivation) return;
880
+ const activation = pendingActivation;
881
+ activation.deadline = Math.max(
882
+ activation.deadline,
883
+ Date.now() + META_RETRY_WINDOW_MS,
884
+ );
885
+ activateCommercePrice(
886
+ activation.settings,
887
+ activation.assignments,
888
+ activation.isCohortStampedOnCart,
889
+ activation.deadline,
890
+ activation.onResolved,
891
+ activation.isAssignmentAuthorized,
892
+ );
893
+ }
894
+
895
+ /** Whether a display-only price assignment has produced shopper-visible framing. */
896
+ export function isCommercePriceExposureEligible(
897
+ settings: CommercePriceSettings | undefined,
898
+ assignment: Assignment,
899
+ ): boolean {
900
+ const adjustments = settings?.experiments.find(
901
+ (experiment) => experiment.experiment_id === assignment.experimentId,
902
+ )?.variants[assignment.variationId];
903
+ return Boolean(
904
+ assignment.isControl ||
905
+ !adjustments?.some((adjustment) => adjustment.compareAt) ||
906
+ adjustments.some(
907
+ (adjustment) => typeof adjustment.fixedPricePerUnit === "string",
908
+ ) ||
909
+ renderedDisplayAssignment === assignment,
910
+ );
911
+ }
912
+
913
+ export const setCommercePriceExposureListener = (listener: () => void) => {
914
+ exposureChanged = listener;
915
+ };
916
+
917
+ export function cleanupCommercePrice(): void {
918
+ observer?.disconnect();
919
+ observer = null;
920
+ rootObserver?.disconnect();
921
+ rootObserver = null;
922
+ variantForm?.removeEventListener("change", handleVariantFormEvent);
923
+ variantForm?.removeEventListener("input", handleVariantFormEvent);
924
+ variantForm = null;
925
+ for (const [node, value] of originals) node.data = value.original;
926
+ originals = new Map();
927
+ for (const [element, value] of originalAriaLabels)
928
+ element.setAttribute("aria-label", value.original);
929
+ originalAriaLabels = new Map();
930
+ for (const node of compareAtNodes) node.remove();
931
+ compareAtNodes = [];
932
+ for (const [node, display] of hiddenCompareAtNodes)
933
+ node.style.display = display;
934
+ hiddenCompareAtNodes = new Map();
935
+ renderedDisplayAssignment = null;
936
+ current = null;
937
+ pendingActivation = null;
938
+ applying = false;
939
+ cancelMetaRetry();
940
+ cancelStampCheck();
941
+ }