@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- 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
|
+
}
|