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