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