@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
package/src/targeting.ts
ADDED
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AttributeRule,
|
|
3
|
+
ActivationGateDiagnostic,
|
|
4
|
+
Experiment,
|
|
5
|
+
PageTargetingRule,
|
|
6
|
+
SegmentCondition,
|
|
7
|
+
SegmentTargetingRule,
|
|
8
|
+
Targeting,
|
|
9
|
+
TargetingDiagnostics,
|
|
10
|
+
UrlRule,
|
|
11
|
+
} from "./types";
|
|
12
|
+
import { createApexRuntimeTools } from "./runtime-tools";
|
|
13
|
+
import { safeRegexTest } from "./safe-regex";
|
|
14
|
+
import { getSignals, rememberProduct } from "./signals";
|
|
15
|
+
import { evaluateTargetingGatesWithConditionDiagnostics } from "./targeting-diagnostics";
|
|
16
|
+
|
|
17
|
+
declare const __DRIP_QA_BUILD__: boolean;
|
|
18
|
+
|
|
19
|
+
function evalSimpleUrlPart(actual: string, pattern: string, isPath: boolean): boolean {
|
|
20
|
+
try {
|
|
21
|
+
let escaped = pattern
|
|
22
|
+
.replace(/[*.+?^${}()|[\]\\]/g, "\\$&")
|
|
23
|
+
.replace(/_____/g, ".*");
|
|
24
|
+
if (isPath) {
|
|
25
|
+
escaped = "\\/?" + escaped.replace(/(^\/|\/$)/g, "") + "\\/?";
|
|
26
|
+
}
|
|
27
|
+
const regex = new RegExp("^" + escaped + "$", "i");
|
|
28
|
+
return regex.test(actual);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function evalSimpleUrlTarget(actual: URL, pattern: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const expected = new URL(
|
|
37
|
+
pattern.replace(/^([^:/?]*)\./i, "https://$1.").replace(/\*/g, "_____"),
|
|
38
|
+
"https://_____",
|
|
39
|
+
);
|
|
40
|
+
const comps: Array<[string, string, boolean]> = [
|
|
41
|
+
[actual.host, expected.host, false],
|
|
42
|
+
[actual.pathname, expected.pathname, true],
|
|
43
|
+
];
|
|
44
|
+
if (expected.hash) comps.push([actual.hash, expected.hash, false]);
|
|
45
|
+
expected.searchParams.forEach((v, k) => {
|
|
46
|
+
comps.push([actual.searchParams.get(k) || "", v, false]);
|
|
47
|
+
});
|
|
48
|
+
return !comps.some((data) => !evalSimpleUrlPart(data[0], data[1], data[2]));
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unescapeRegexLiteral(value: string): string {
|
|
55
|
+
return value.replace(/\\([.*+?^${}()|[\]\\\/])/g, "$1");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function evalUrlRule(url: string, rule: UrlRule): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(url, "https://_");
|
|
61
|
+
const matchType = rule.matchType ?? rule.match_type;
|
|
62
|
+
if (matchType === "contains") {
|
|
63
|
+
const actual = parsed.href.toLowerCase();
|
|
64
|
+
const pattern = rule.pattern.toLowerCase();
|
|
65
|
+
return actual.includes(pattern) || actual.includes(unescapeRegexLiteral(pattern));
|
|
66
|
+
}
|
|
67
|
+
if (rule.type === "regex") {
|
|
68
|
+
const escaped = rule.pattern.replace(/([^\\])\//g, "$1\\/");
|
|
69
|
+
return safeRegexTest(escaped, parsed.href) || safeRegexTest(escaped, parsed.href.substring(parsed.origin.length));
|
|
70
|
+
}
|
|
71
|
+
return evalSimpleUrlTarget(parsed, rule.pattern);
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeRuntimeUrlRule(value: unknown): UrlRule | null {
|
|
78
|
+
if (typeof value === "string") {
|
|
79
|
+
const pattern = value.trim();
|
|
80
|
+
return pattern ? { type: "simple", pattern, include: true } : null;
|
|
81
|
+
}
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
83
|
+
|
|
84
|
+
const raw = value as Record<string, unknown>;
|
|
85
|
+
const rawPattern = raw.pattern ?? raw.value ?? raw.url ?? raw.path;
|
|
86
|
+
const pattern = typeof rawPattern === "string" ? rawPattern.trim() : "";
|
|
87
|
+
if (!pattern) return null;
|
|
88
|
+
|
|
89
|
+
const include = typeof raw.include === "boolean" ? raw.include : true;
|
|
90
|
+
const matchType =
|
|
91
|
+
raw.matchType === "exact" ||
|
|
92
|
+
raw.matchType === "contains" ||
|
|
93
|
+
raw.matchType === "wildcard" ||
|
|
94
|
+
raw.matchType === "regex"
|
|
95
|
+
? raw.matchType
|
|
96
|
+
: raw.match_type === "exact" ||
|
|
97
|
+
raw.match_type === "contains" ||
|
|
98
|
+
raw.match_type === "wildcard" ||
|
|
99
|
+
raw.match_type === "regex"
|
|
100
|
+
? raw.match_type
|
|
101
|
+
: raw.type === "url_contains"
|
|
102
|
+
? "contains"
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
if (raw.type === "url_contains") {
|
|
106
|
+
return { type: "simple", pattern, include, matchType: "contains" };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
type: raw.type === "regex" || raw.type === "url_regex" ? "regex" : "simple",
|
|
110
|
+
pattern,
|
|
111
|
+
include,
|
|
112
|
+
...(matchType ? { matchType } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function normalizeRuntimeUrlRules(targeting: Targeting | undefined): UrlRule[] {
|
|
117
|
+
const raw = targeting as unknown as Record<string, unknown> | undefined;
|
|
118
|
+
if (!raw || typeof raw !== "object") return [];
|
|
119
|
+
|
|
120
|
+
const urlSource = Array.isArray(raw.url)
|
|
121
|
+
? raw.url
|
|
122
|
+
: raw.url === undefined || raw.url === null
|
|
123
|
+
? []
|
|
124
|
+
: [raw.url];
|
|
125
|
+
const legacyRules = Array.isArray(raw.rules) ? raw.rules : [];
|
|
126
|
+
|
|
127
|
+
return urlSource
|
|
128
|
+
.concat(legacyRules)
|
|
129
|
+
.map(normalizeRuntimeUrlRule)
|
|
130
|
+
.filter((rule): rule is UrlRule => Boolean(rule));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function matchUrlRules(url: string, rules: UrlRule[]): boolean {
|
|
134
|
+
if (!rules.length) return true;
|
|
135
|
+
let hasInclude = false;
|
|
136
|
+
let included = false;
|
|
137
|
+
for (const rule of rules) {
|
|
138
|
+
const match = evalUrlRule(url, rule);
|
|
139
|
+
if (rule.include === false) {
|
|
140
|
+
if (match) return false;
|
|
141
|
+
} else {
|
|
142
|
+
hasInclude = true;
|
|
143
|
+
if (match) included = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return included || !hasInclude;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function errorMessage(error: unknown): string {
|
|
150
|
+
return error instanceof Error ? error.message : String(error);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function jsErrorReason(error: unknown): string {
|
|
154
|
+
const message = errorMessage(error);
|
|
155
|
+
return message ? `js_error: ${message}` : "js_error";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type BooleanDiagnosticResult = {
|
|
159
|
+
matched: boolean;
|
|
160
|
+
reason?: string;
|
|
161
|
+
error?: unknown;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export function evaluatePageAdvancedConditionResult(
|
|
165
|
+
rule: PageTargetingRule,
|
|
166
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
167
|
+
): BooleanDiagnosticResult {
|
|
168
|
+
const code = rule.advancedCondition?.code?.trim();
|
|
169
|
+
if (!code) return { matched: true };
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(
|
|
172
|
+
ctx.url,
|
|
173
|
+
typeof location !== "undefined" ? location.origin : "https://_",
|
|
174
|
+
);
|
|
175
|
+
const fn = new Function(
|
|
176
|
+
"context",
|
|
177
|
+
`"use strict";` +
|
|
178
|
+
`const attributes=context.attributes;` +
|
|
179
|
+
`const document=context.document;` +
|
|
180
|
+
`const location=context.location;` +
|
|
181
|
+
`const url=context.url;` +
|
|
182
|
+
`const window=context.window;` +
|
|
183
|
+
`return Boolean((function(){${/\breturn\b|;\s*$|\bthrow\b/.test(code) ? code : `return (${code});`}}).call(window));`,
|
|
184
|
+
);
|
|
185
|
+
const matched = Boolean(fn({
|
|
186
|
+
attributes: ctx.attributes,
|
|
187
|
+
document: typeof document !== "undefined" ? document : undefined,
|
|
188
|
+
location: typeof location !== "undefined" ? location : undefined,
|
|
189
|
+
url,
|
|
190
|
+
window: typeof window !== "undefined" ? window : undefined,
|
|
191
|
+
}));
|
|
192
|
+
return { matched, reason: matched ? undefined : "js_false" };
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { matched: false, reason: jsErrorReason(error), error };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function evaluatePageAdvancedCondition(
|
|
199
|
+
rule: PageTargetingRule,
|
|
200
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
201
|
+
): boolean {
|
|
202
|
+
const result = evaluatePageAdvancedConditionResult(rule, ctx);
|
|
203
|
+
if (!result.matched && result.reason?.startsWith("js_error")) {
|
|
204
|
+
try {
|
|
205
|
+
console.warn("[drip] Page trigger failed closed", {
|
|
206
|
+
pageId: rule.id,
|
|
207
|
+
pageName: rule.name,
|
|
208
|
+
error: result.error,
|
|
209
|
+
});
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
return result.matched;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function pageManualTriggerMatches(
|
|
216
|
+
rule: PageTargetingRule,
|
|
217
|
+
ctx: { attributes: Record<string, unknown> },
|
|
218
|
+
): boolean {
|
|
219
|
+
const mode = rule.trigger?.mode;
|
|
220
|
+
if (mode !== "manual-callback" && mode !== "manual-api") return true;
|
|
221
|
+
const signal = rule.trigger?.signal;
|
|
222
|
+
if (!signal) return false;
|
|
223
|
+
return ctx.attributes[signal] === true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function matchPageTargetingRule(
|
|
227
|
+
rule: PageTargetingRule,
|
|
228
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
229
|
+
): boolean {
|
|
230
|
+
if (rule.disabled || rule.trigger?.disabled) return false;
|
|
231
|
+
const includeRules = rule.includeRules ?? [];
|
|
232
|
+
const excludeRules = (rule.excludeRules ?? []).map((item) => ({ ...item, include: false }));
|
|
233
|
+
const urlMatched = matchUrlRules(ctx.url, [...includeRules, ...excludeRules]);
|
|
234
|
+
return urlMatched && pageManualTriggerMatches(rule, ctx) && evaluatePageAdvancedCondition(rule, ctx);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function matchPageRules(
|
|
238
|
+
rules: PageTargetingRule[],
|
|
239
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
240
|
+
): boolean {
|
|
241
|
+
if (!rules.length) return true;
|
|
242
|
+
let hasInclude = false;
|
|
243
|
+
let included = false;
|
|
244
|
+
|
|
245
|
+
for (const rule of rules) {
|
|
246
|
+
const matched = matchPageTargetingRule(rule, ctx);
|
|
247
|
+
if (rule.include === false) {
|
|
248
|
+
if (matched) return false;
|
|
249
|
+
} else {
|
|
250
|
+
hasInclude = true;
|
|
251
|
+
if (matched) included = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return included || !hasInclude;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function matchAttributeRule(
|
|
259
|
+
attrs: Record<string, unknown>,
|
|
260
|
+
rule: AttributeRule,
|
|
261
|
+
): boolean {
|
|
262
|
+
const value = attrs[rule.attribute];
|
|
263
|
+
switch (rule.operator) {
|
|
264
|
+
case "exists":
|
|
265
|
+
return value !== undefined && value !== null;
|
|
266
|
+
case "equals":
|
|
267
|
+
return value === rule.value;
|
|
268
|
+
case "contains":
|
|
269
|
+
return typeof value === "string" && typeof rule.value === "string"
|
|
270
|
+
? value.includes(rule.value)
|
|
271
|
+
: false;
|
|
272
|
+
case "in":
|
|
273
|
+
return Array.isArray(rule.value) ? rule.value.includes(String(value)) : false;
|
|
274
|
+
default:
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function wildcardToRegex(pattern: string): string {
|
|
280
|
+
return `^${pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type RuntimeNavigator = Navigator & {
|
|
284
|
+
userAgentData?: {
|
|
285
|
+
mobile?: boolean;
|
|
286
|
+
platform?: string;
|
|
287
|
+
brands?: Array<{ brand: string; version: string }>;
|
|
288
|
+
fullVersionList?: Array<{ brand: string; version: string }>;
|
|
289
|
+
getHighEntropyValues?: (hints: string[]) => Promise<unknown>;
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
type BrowserInfo = {
|
|
294
|
+
name: string;
|
|
295
|
+
version: string;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
type OsInfo = {
|
|
299
|
+
name: string;
|
|
300
|
+
version: string;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const EMPTY_BROWSER_INFO: BrowserInfo = { name: "", version: "" };
|
|
304
|
+
const EMPTY_OS_INFO: OsInfo = { name: "", version: "" };
|
|
305
|
+
let highEntropyBrowserInfo: BrowserInfo | null = null;
|
|
306
|
+
let highEntropyBrowserInfoRequested = false;
|
|
307
|
+
|
|
308
|
+
function runtimeNavigator(): RuntimeNavigator | undefined {
|
|
309
|
+
return typeof navigator === "undefined" ? undefined : navigator as RuntimeNavigator;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function lower(value: unknown): string {
|
|
313
|
+
return typeof value === "string" ? value.toLowerCase() : "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeVersion(value: unknown): string {
|
|
317
|
+
if (typeof value !== "string") return "";
|
|
318
|
+
const match = value.match(/\d+(?:[._]\d+)*/);
|
|
319
|
+
return match ? match[0].replace(/_/g, ".") : "";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function firstVersionMatch(ua: string, patterns: RegExp[]): string {
|
|
323
|
+
for (const pattern of patterns) {
|
|
324
|
+
const match = ua.match(pattern);
|
|
325
|
+
const version = normalizeVersion(match?.[1]);
|
|
326
|
+
if (version) return version;
|
|
327
|
+
}
|
|
328
|
+
return "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getUserAgent(): string {
|
|
332
|
+
return runtimeNavigator()?.userAgent ?? "";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getPlatform(): string {
|
|
336
|
+
const nav = runtimeNavigator();
|
|
337
|
+
return nav?.userAgentData?.platform || nav?.platform || "";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getDeviceTypeFromUserAgent(ua: string, platform: string): string {
|
|
341
|
+
const normalizedUa = ua.toLowerCase();
|
|
342
|
+
const normalizedPlatform = platform.toLowerCase();
|
|
343
|
+
if (!normalizedUa && !normalizedPlatform) return "";
|
|
344
|
+
|
|
345
|
+
const isIpad =
|
|
346
|
+
/\bipad\b/.test(normalizedUa) ||
|
|
347
|
+
(normalizedPlatform === "macintel" &&
|
|
348
|
+
typeof navigator !== "undefined" &&
|
|
349
|
+
Number((navigator as Navigator).maxTouchPoints || 0) > 1);
|
|
350
|
+
if (isIpad || /\btablet\b|playbook|silk|kindle|nexus 7|nexus 9|sm-t|gt-p/i.test(ua)) {
|
|
351
|
+
return "tablet";
|
|
352
|
+
}
|
|
353
|
+
if (/\bmobi\b|iphone|ipod|android.*mobile|windows phone|blackberry|bb10/i.test(ua)) {
|
|
354
|
+
return "mobile";
|
|
355
|
+
}
|
|
356
|
+
if (/android/i.test(ua)) return "tablet";
|
|
357
|
+
// Client-hints platform "Android" with mobile=false and no UA string is a
|
|
358
|
+
// tablet, not a desktop.
|
|
359
|
+
if (/android/i.test(normalizedPlatform)) return "tablet";
|
|
360
|
+
if (/windows|macintosh|mac os x|linux|x11|cros/i.test(ua) || normalizedPlatform) {
|
|
361
|
+
return "desktop";
|
|
362
|
+
}
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getDeviceType(): string {
|
|
367
|
+
const nav = runtimeNavigator();
|
|
368
|
+
if (nav?.userAgentData?.mobile === true) return "mobile";
|
|
369
|
+
|
|
370
|
+
const fromUa = getDeviceTypeFromUserAgent(getUserAgent(), getPlatform());
|
|
371
|
+
if (fromUa) return fromUa;
|
|
372
|
+
|
|
373
|
+
if (typeof window === "undefined") return "";
|
|
374
|
+
const width = window.innerWidth || 0;
|
|
375
|
+
if (width > 0 && width < 768) return "mobile";
|
|
376
|
+
if (width >= 768 && width < 1024) return "tablet";
|
|
377
|
+
return "desktop";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function browserInfoFromBrands(brands: unknown): BrowserInfo {
|
|
381
|
+
if (!Array.isArray(brands)) return EMPTY_BROWSER_INFO;
|
|
382
|
+
|
|
383
|
+
for (const item of brands) {
|
|
384
|
+
if (!item || typeof item !== "object") continue;
|
|
385
|
+
const record = item as Record<string, unknown>;
|
|
386
|
+
const brand = lower(record.brand);
|
|
387
|
+
const version = normalizeVersion(record.version);
|
|
388
|
+
if (!brand || brand.includes("not") || brand.includes("brand")) continue;
|
|
389
|
+
if (brand.includes("edge")) return { name: "edge", version };
|
|
390
|
+
if (brand.includes("chrome") || brand.includes("chromium")) return { name: "chrome", version };
|
|
391
|
+
if (brand.includes("firefox")) return { name: "firefox", version };
|
|
392
|
+
if (brand.includes("safari")) return { name: "safari", version };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return EMPTY_BROWSER_INFO;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function requestHighEntropyBrowserInfo(): void {
|
|
399
|
+
const userAgentData = runtimeNavigator()?.userAgentData;
|
|
400
|
+
if (!userAgentData?.getHighEntropyValues || highEntropyBrowserInfoRequested) return;
|
|
401
|
+
highEntropyBrowserInfoRequested = true;
|
|
402
|
+
try {
|
|
403
|
+
userAgentData
|
|
404
|
+
.getHighEntropyValues(["brands", "fullVersionList"])
|
|
405
|
+
.then((values) => {
|
|
406
|
+
if (!values || typeof values !== "object") return;
|
|
407
|
+
const record = values as Record<string, unknown>;
|
|
408
|
+
const info = browserInfoFromBrands(record.fullVersionList ?? record.brands);
|
|
409
|
+
if (info.name) highEntropyBrowserInfo = info;
|
|
410
|
+
})
|
|
411
|
+
.catch(() => {});
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getBrowserInfoFromClientHints(): BrowserInfo {
|
|
416
|
+
const userAgentData = runtimeNavigator()?.userAgentData;
|
|
417
|
+
const fullVersionInfo = browserInfoFromBrands(userAgentData?.fullVersionList);
|
|
418
|
+
if (fullVersionInfo.name) return fullVersionInfo;
|
|
419
|
+
if (highEntropyBrowserInfo?.name) return highEntropyBrowserInfo;
|
|
420
|
+
requestHighEntropyBrowserInfo();
|
|
421
|
+
return browserInfoFromBrands(userAgentData?.brands);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function browserWithVersionPriority(name: string, uaVersion: string, hinted: BrowserInfo): BrowserInfo {
|
|
425
|
+
return {
|
|
426
|
+
name,
|
|
427
|
+
version: hinted.name === name && hinted.version ? hinted.version : uaVersion || hinted.version,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function getBrowserInfo(): BrowserInfo {
|
|
432
|
+
const hinted = getBrowserInfoFromClientHints();
|
|
433
|
+
const ua = getUserAgent();
|
|
434
|
+
if (!ua) return hinted;
|
|
435
|
+
|
|
436
|
+
if (/Edg\//.test(ua)) {
|
|
437
|
+
return browserWithVersionPriority("edge", firstVersionMatch(ua, [/Edg\/([\d.]+)/]), hinted);
|
|
438
|
+
}
|
|
439
|
+
if (/Firefox\//.test(ua)) {
|
|
440
|
+
return browserWithVersionPriority("firefox", firstVersionMatch(ua, [/Firefox\/([\d.]+)/]), hinted);
|
|
441
|
+
}
|
|
442
|
+
if (/CriOS\//.test(ua)) {
|
|
443
|
+
return browserWithVersionPriority("chrome", firstVersionMatch(ua, [/CriOS\/([\d.]+)/]), hinted);
|
|
444
|
+
}
|
|
445
|
+
if (/Chrome\//.test(ua) && !/Chromium|OPR\/|Edg\//.test(ua)) {
|
|
446
|
+
return browserWithVersionPriority("chrome", firstVersionMatch(ua, [/Chrome\/([\d.]+)/]), hinted);
|
|
447
|
+
}
|
|
448
|
+
if (/Version\/[\d.]+.*Safari\//.test(ua) && !/Chrome|CriOS|FxiOS|Edg\//.test(ua)) {
|
|
449
|
+
return browserWithVersionPriority("safari", firstVersionMatch(ua, [/Version\/([\d.]+)/]), hinted);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return hinted;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function getOsInfo(): OsInfo {
|
|
456
|
+
const ua = getUserAgent();
|
|
457
|
+
const platform = getPlatform();
|
|
458
|
+
const normalized = `${ua} ${platform}`.toLowerCase();
|
|
459
|
+
if (!normalized.trim()) return EMPTY_OS_INFO;
|
|
460
|
+
|
|
461
|
+
if (/iphone|ipad|ipod/.test(normalized) || (platform === "MacIntel" && typeof navigator !== "undefined" && Number(navigator.maxTouchPoints || 0) > 1)) {
|
|
462
|
+
return { name: "ios", version: firstVersionMatch(ua, [/OS ([\d_]+)/]) };
|
|
463
|
+
}
|
|
464
|
+
if (/android/.test(normalized)) {
|
|
465
|
+
return { name: "android", version: firstVersionMatch(ua, [/Android ([\d.]+)/]) };
|
|
466
|
+
}
|
|
467
|
+
if (/windows/.test(normalized)) {
|
|
468
|
+
return { name: "windows", version: firstVersionMatch(ua, [/Windows NT ([\d.]+)/]) };
|
|
469
|
+
}
|
|
470
|
+
if (/mac os x|macintosh|macintel/.test(normalized)) {
|
|
471
|
+
return { name: "macos", version: firstVersionMatch(ua, [/Mac OS X ([\d_]+)/]) };
|
|
472
|
+
}
|
|
473
|
+
if (/linux|x11/.test(normalized)) {
|
|
474
|
+
return { name: "linux", version: "" };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return EMPTY_OS_INFO;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function getLanguage(): string {
|
|
481
|
+
const navLanguage = runtimeNavigator()?.language;
|
|
482
|
+
if (typeof navLanguage === "string" && navLanguage.trim()) return navLanguage;
|
|
483
|
+
if (typeof document !== "undefined") return document.documentElement.lang || "";
|
|
484
|
+
return "";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function decodeCookieComponent(value: string): string {
|
|
488
|
+
try {
|
|
489
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
490
|
+
} catch {
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function getCookieValue(name: string): string | undefined {
|
|
496
|
+
if (typeof document === "undefined") return undefined;
|
|
497
|
+
const needle = name.trim();
|
|
498
|
+
if (!needle) return undefined;
|
|
499
|
+
const pairs = document.cookie ? document.cookie.split(";") : [];
|
|
500
|
+
for (const pair of pairs) {
|
|
501
|
+
const index = pair.indexOf("=");
|
|
502
|
+
const rawName = (index >= 0 ? pair.slice(0, index) : pair).trim();
|
|
503
|
+
if (decodeCookieComponent(rawName) !== needle) continue;
|
|
504
|
+
return decodeCookieComponent(index >= 0 ? pair.slice(index + 1) : "");
|
|
505
|
+
}
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function parseNamedValue(value: unknown): { name: string; expected: string } {
|
|
510
|
+
const raw = String(value ?? "").trim();
|
|
511
|
+
const index = raw.indexOf("=");
|
|
512
|
+
if (index < 0) return { name: raw, expected: "" };
|
|
513
|
+
return {
|
|
514
|
+
name: raw.slice(0, index).trim(),
|
|
515
|
+
expected: raw.slice(index + 1),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function getPersistedUtmValue(key: string): string {
|
|
520
|
+
try {
|
|
521
|
+
const params = new URLSearchParams(location.search);
|
|
522
|
+
const direct = params.get(key);
|
|
523
|
+
if (direct) return direct;
|
|
524
|
+
} catch {}
|
|
525
|
+
try {
|
|
526
|
+
const stored = sessionStorage.getItem("drip_utm");
|
|
527
|
+
if (!stored) return "";
|
|
528
|
+
const parsed = JSON.parse(stored) as Record<string, unknown>;
|
|
529
|
+
return typeof parsed[key] === "string" ? parsed[key] : "";
|
|
530
|
+
} catch {
|
|
531
|
+
return "";
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizeReferrer(): string {
|
|
536
|
+
if (typeof document === "undefined") return "";
|
|
537
|
+
const referrer = document.referrer;
|
|
538
|
+
if (!referrer) return "(direct)";
|
|
539
|
+
try {
|
|
540
|
+
const host = new URL(referrer).hostname;
|
|
541
|
+
return host === location.hostname ? "(internal)" : host;
|
|
542
|
+
} catch {
|
|
543
|
+
return "(direct)";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function normalizeTrafficSource(utmSource: unknown, referrer: unknown): string {
|
|
548
|
+
const source = typeof utmSource === "string" ? utmSource.toLowerCase() : "";
|
|
549
|
+
const ref = typeof referrer === "string" ? referrer.toLowerCase() : "";
|
|
550
|
+
if (source.includes("facebook") || source.includes("instagram") || source.includes("meta") || source.includes("tiktok")) {
|
|
551
|
+
return "paid-social";
|
|
552
|
+
}
|
|
553
|
+
if (source.includes("google") || source.includes("bing") || ref.includes("google") || ref.includes("bing")) {
|
|
554
|
+
return "search";
|
|
555
|
+
}
|
|
556
|
+
if (source.includes("email") || source.includes("newsletter")) return "email";
|
|
557
|
+
if (source) return source;
|
|
558
|
+
if (ref && ref !== "(direct)" && ref !== "(internal)") return "referral";
|
|
559
|
+
return "direct";
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function readShopifySignal(signal: string): unknown {
|
|
563
|
+
if (typeof window === "undefined") return undefined;
|
|
564
|
+
const root = (window as any).ShopifyAnalytics?.meta ?? (window as any).Shopify?.analytics?.meta;
|
|
565
|
+
if (!root || typeof root !== "object") return undefined;
|
|
566
|
+
|
|
567
|
+
if (signal === "shopify.page_type") {
|
|
568
|
+
return root.page?.pageType ?? root.page?.page_type ?? root.pageType;
|
|
569
|
+
}
|
|
570
|
+
if (signal === "shopify.product_type") {
|
|
571
|
+
return root.product?.type ?? root.product?.product_type;
|
|
572
|
+
}
|
|
573
|
+
if (signal === "shopify.product_tag") {
|
|
574
|
+
return root.product?.tags;
|
|
575
|
+
}
|
|
576
|
+
if (signal === "shopify.customer_tag") {
|
|
577
|
+
return root.customer?.tags;
|
|
578
|
+
}
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function buildTargetingAttributes(
|
|
583
|
+
baseAttributes: Record<string, unknown> = {},
|
|
584
|
+
currentUrl?: string,
|
|
585
|
+
options: { persistSignals?: boolean } = {},
|
|
586
|
+
): Record<string, unknown> {
|
|
587
|
+
const attrs: Record<string, unknown> = { ...baseAttributes };
|
|
588
|
+
const href = currentUrl || (typeof location !== "undefined" ? location.href : "");
|
|
589
|
+
const persistSignals = options.persistSignals !== false;
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const parsed = new URL(href, typeof location !== "undefined" ? location.origin : "https://_");
|
|
593
|
+
attrs["url.full"] ??= parsed.href;
|
|
594
|
+
attrs["url.path"] ??= parsed.pathname;
|
|
595
|
+
attrs["url.query"] ??= parsed.search;
|
|
596
|
+
} catch {}
|
|
597
|
+
|
|
598
|
+
attrs["utm.source"] ??= baseAttributes.utm_source ?? getPersistedUtmValue("utm_source");
|
|
599
|
+
attrs["utm.medium"] ??= baseAttributes.utm_medium ?? getPersistedUtmValue("utm_medium");
|
|
600
|
+
attrs["utm.campaign"] ??= baseAttributes.utm_campaign ?? getPersistedUtmValue("utm_campaign");
|
|
601
|
+
attrs.referrer ??= normalizeReferrer();
|
|
602
|
+
attrs["device.type"] ??= baseAttributes.device ?? getDeviceType();
|
|
603
|
+
const browser = getBrowserInfo();
|
|
604
|
+
attrs["device.browser"] ??= browser.name;
|
|
605
|
+
attrs["device.browser.version"] ??= browser.version;
|
|
606
|
+
const os = getOsInfo();
|
|
607
|
+
attrs["device.os"] ??= os.name;
|
|
608
|
+
attrs["device.os.version"] ??= os.version;
|
|
609
|
+
attrs["navigator.language"] ??= getLanguage();
|
|
610
|
+
attrs["traffic.source"] ??= normalizeTrafficSource(attrs["utm.source"], attrs.referrer);
|
|
611
|
+
|
|
612
|
+
for (const signal of [
|
|
613
|
+
"shopify.page_type",
|
|
614
|
+
"shopify.product_type",
|
|
615
|
+
"shopify.product_tag",
|
|
616
|
+
"shopify.customer_tag",
|
|
617
|
+
]) {
|
|
618
|
+
attrs[signal] ??= readShopifySignal(signal);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (persistSignals) {
|
|
622
|
+
const productMeta = readShopifyProductMeta();
|
|
623
|
+
if (productMeta) rememberProduct(productMeta);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const [signal, value] of Object.entries(getSignals({ markVisitorSeen: persistSignals }))) {
|
|
627
|
+
attrs[signal] ??= value;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return attrs;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function resolveUrlQueryValue(url: string, conditionValue: unknown): string | undefined {
|
|
634
|
+
const { name } = parseNamedValue(conditionValue);
|
|
635
|
+
if (!name) return undefined;
|
|
636
|
+
try {
|
|
637
|
+
const parsed = new URL(url, typeof location !== "undefined" ? location.origin : "https://_");
|
|
638
|
+
return parsed.searchParams.get(name) ?? undefined;
|
|
639
|
+
} catch {
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function resolveNamedConditionActual(
|
|
645
|
+
condition: SegmentCondition,
|
|
646
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
647
|
+
): unknown {
|
|
648
|
+
if (condition.signal === "cookie.value") {
|
|
649
|
+
const { name } = parseNamedValue(condition.value);
|
|
650
|
+
return getCookieValue(name);
|
|
651
|
+
}
|
|
652
|
+
if (condition.signal === "url.query") {
|
|
653
|
+
if (isLegacyRawQueryCondition(condition)) {
|
|
654
|
+
return rawQueryString(ctx.url);
|
|
655
|
+
}
|
|
656
|
+
return resolveUrlQueryValue(ctx.url, condition.value);
|
|
657
|
+
}
|
|
658
|
+
return condition.js
|
|
659
|
+
? evaluateJsTrigger(condition, ctx)
|
|
660
|
+
: resolveSignalValue(ctx.attributes, ctx.url, condition.signal);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function resolveNamedConditionExpected(condition: SegmentCondition): SegmentCondition["value"] {
|
|
664
|
+
if (condition.signal !== "cookie.value" && condition.signal !== "url.query") return condition.value;
|
|
665
|
+
if (condition.operator === "is_set" || condition.operator === "is_empty") return condition.value;
|
|
666
|
+
if (condition.signal === "url.query" && isLegacyRawQueryCondition(condition)) {
|
|
667
|
+
return condition.value;
|
|
668
|
+
}
|
|
669
|
+
return parseNamedValue(condition.value).expected;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Pre-fix url.query rules matched against the raw query string. Substring
|
|
673
|
+
// operators without a name=value form keep that raw semantics so existing
|
|
674
|
+
// audiences like `url.query contains "spring"` continue to match; presence
|
|
675
|
+
// and equality operators use the named-parameter semantics from APX-59.
|
|
676
|
+
const LEGACY_RAW_QUERY_OPERATORS = new Set(["contains", "not_contains", "starts_with", "ends_with"]);
|
|
677
|
+
|
|
678
|
+
function isLegacyRawQueryCondition(condition: SegmentCondition): boolean {
|
|
679
|
+
if (!LEGACY_RAW_QUERY_OPERATORS.has(String(condition.operator))) return false;
|
|
680
|
+
return !String(condition.value ?? "").includes("=");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function rawQueryString(url: string): string {
|
|
684
|
+
try {
|
|
685
|
+
const parsed = new URL(url, typeof location !== "undefined" ? location.origin : "https://_");
|
|
686
|
+
return parsed.search || "";
|
|
687
|
+
} catch {
|
|
688
|
+
return "";
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function readShopifyProductMeta(): Parameters<typeof rememberProduct>[0] | null {
|
|
693
|
+
if (typeof window === "undefined") return null;
|
|
694
|
+
const root = (window as any).ShopifyAnalytics?.meta ?? (window as any).Shopify?.analytics?.meta;
|
|
695
|
+
const product = root?.product;
|
|
696
|
+
if (!product || typeof product !== "object") return null;
|
|
697
|
+
const id = product.id ?? product.gid ?? product.product_id;
|
|
698
|
+
if (!id && !product.handle) return null;
|
|
699
|
+
return {
|
|
700
|
+
id,
|
|
701
|
+
handle: product.handle,
|
|
702
|
+
title: product.title,
|
|
703
|
+
type: product.type ?? product.product_type,
|
|
704
|
+
vendor: product.vendor,
|
|
705
|
+
tags: Array.isArray(product.tags) ? product.tags : undefined,
|
|
706
|
+
price: product.price,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function resolveSignalValue(
|
|
711
|
+
attrs: Record<string, unknown>,
|
|
712
|
+
url: string,
|
|
713
|
+
signal: string,
|
|
714
|
+
): unknown {
|
|
715
|
+
if (attrs[signal] !== undefined) return attrs[signal];
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const parsed = new URL(url, "https://_");
|
|
719
|
+
if (signal === "url.full") return parsed.href;
|
|
720
|
+
if (signal === "url.path") return parsed.pathname;
|
|
721
|
+
if (signal === "url.query") return parsed.search;
|
|
722
|
+
} catch {}
|
|
723
|
+
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function buildJsTriggerSource(code: string): string {
|
|
728
|
+
const trimmed = code.trim();
|
|
729
|
+
if (!trimmed) return "return false;";
|
|
730
|
+
if (/\breturn\b/.test(trimmed)) return trimmed;
|
|
731
|
+
return `return (${trimmed});`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function isActivateJsTrigger(condition: SegmentCondition): boolean {
|
|
735
|
+
const code = condition.js?.code ?? "";
|
|
736
|
+
return condition.js?.mode === "activate" ||
|
|
737
|
+
/\bactivate\s*\(/.test(code) ||
|
|
738
|
+
/\bcallbackFn\b/.test(code);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function evaluateJsTrigger(
|
|
742
|
+
condition: SegmentCondition,
|
|
743
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
744
|
+
): unknown {
|
|
745
|
+
return evaluateJsTriggerResult(condition, ctx).actual;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function evaluateJsTriggerResult(
|
|
749
|
+
condition: SegmentCondition,
|
|
750
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
751
|
+
): { actual: unknown; reason?: string } {
|
|
752
|
+
const code = condition.js?.code?.trim();
|
|
753
|
+
if (!code || code.length > 4000) return { actual: undefined };
|
|
754
|
+
if (isActivateJsTrigger(condition)) {
|
|
755
|
+
return { actual: resolveSignalValue(ctx.attributes, ctx.url, condition.signal) };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
const url = new URL(
|
|
760
|
+
ctx.url,
|
|
761
|
+
typeof location !== "undefined" ? location.origin : "https://_",
|
|
762
|
+
);
|
|
763
|
+
const apexTools = createApexRuntimeTools();
|
|
764
|
+
const context = {
|
|
765
|
+
apexTools,
|
|
766
|
+
attributes: ctx.attributes,
|
|
767
|
+
document: typeof document !== "undefined" ? document : undefined,
|
|
768
|
+
location: typeof location !== "undefined" ? location : undefined,
|
|
769
|
+
signal: condition.signal,
|
|
770
|
+
url,
|
|
771
|
+
value: condition.value,
|
|
772
|
+
window: typeof window !== "undefined" ? window : undefined,
|
|
773
|
+
};
|
|
774
|
+
const fn = new Function(
|
|
775
|
+
"context",
|
|
776
|
+
`"use strict";` +
|
|
777
|
+
`const attributes=context.attributes;` +
|
|
778
|
+
`const apexTools=context.apexTools;` +
|
|
779
|
+
`const document=context.document;` +
|
|
780
|
+
`const location=context.location;` +
|
|
781
|
+
`const signal=context.signal;` +
|
|
782
|
+
`const url=context.url;` +
|
|
783
|
+
`const value=context.value;` +
|
|
784
|
+
`const window=context.window;` +
|
|
785
|
+
`return Boolean((function(){${buildJsTriggerSource(code)}}).call(window));`,
|
|
786
|
+
);
|
|
787
|
+
const actual = Boolean(fn(context));
|
|
788
|
+
return { actual, reason: actual ? undefined : "js_false" };
|
|
789
|
+
} catch (error) {
|
|
790
|
+
return { actual: false, reason: jsErrorReason(error) };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function stringValues(value: unknown): string[] {
|
|
795
|
+
if (Array.isArray(value)) {
|
|
796
|
+
return value
|
|
797
|
+
.map((item) => String(item).trim())
|
|
798
|
+
.filter(Boolean);
|
|
799
|
+
}
|
|
800
|
+
if (value === undefined || value === null) return [];
|
|
801
|
+
return [String(value)];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function hasMeaningfulValue(value: unknown): boolean {
|
|
805
|
+
if (value === undefined || value === null) return false;
|
|
806
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
807
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function compareNumber(actual: unknown, expected: unknown, predicate: (a: number, b: number) => boolean): boolean {
|
|
812
|
+
const actualNumber = Number(Array.isArray(actual) ? actual[0] : actual);
|
|
813
|
+
const expectedNumber = Number(Array.isArray(expected) ? expected[0] : expected);
|
|
814
|
+
return Number.isFinite(actualNumber) &&
|
|
815
|
+
Number.isFinite(expectedNumber) &&
|
|
816
|
+
predicate(actualNumber, expectedNumber);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function matchSegmentCondition(
|
|
820
|
+
condition: SegmentCondition,
|
|
821
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
822
|
+
): boolean {
|
|
823
|
+
const actual = resolveNamedConditionActual(condition, ctx);
|
|
824
|
+
const expected = resolveNamedConditionExpected(condition);
|
|
825
|
+
return matchSegmentConditionValues(condition, actual, expected);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function matchSegmentConditionValues(
|
|
829
|
+
condition: SegmentCondition,
|
|
830
|
+
actual: unknown,
|
|
831
|
+
expected: SegmentCondition["value"],
|
|
832
|
+
): boolean {
|
|
833
|
+
const actualValues = stringValues(actual);
|
|
834
|
+
const expectedValues = stringValues(expected);
|
|
835
|
+
const firstExpected = expectedValues[0] ?? "";
|
|
836
|
+
|
|
837
|
+
switch (condition.operator) {
|
|
838
|
+
case "equals":
|
|
839
|
+
return actualValues.some((value) => value === String(expected ?? ""));
|
|
840
|
+
case "not_equals":
|
|
841
|
+
return !actualValues.some((value) => value === String(expected ?? ""));
|
|
842
|
+
case "contains":
|
|
843
|
+
return actualValues.some((value) =>
|
|
844
|
+
expectedValues.length > 1
|
|
845
|
+
? expectedValues.includes(value)
|
|
846
|
+
: value.includes(firstExpected),
|
|
847
|
+
);
|
|
848
|
+
case "not_contains":
|
|
849
|
+
return !actualValues.some((value) =>
|
|
850
|
+
expectedValues.length > 1
|
|
851
|
+
? expectedValues.includes(value)
|
|
852
|
+
: value.includes(firstExpected),
|
|
853
|
+
);
|
|
854
|
+
case "starts_with":
|
|
855
|
+
return actualValues.some((value) => value.startsWith(firstExpected));
|
|
856
|
+
case "ends_with":
|
|
857
|
+
return actualValues.some((value) => value.endsWith(firstExpected));
|
|
858
|
+
case "is_set":
|
|
859
|
+
return hasMeaningfulValue(actual);
|
|
860
|
+
case "is_empty":
|
|
861
|
+
return !hasMeaningfulValue(actual);
|
|
862
|
+
case "in":
|
|
863
|
+
return actualValues.some((value) => expectedValues.includes(value));
|
|
864
|
+
case "not_in":
|
|
865
|
+
return !actualValues.some((value) => expectedValues.includes(value));
|
|
866
|
+
case "matches_wildcard":
|
|
867
|
+
return actualValues.some((value) => safeRegexTest(wildcardToRegex(firstExpected), value));
|
|
868
|
+
case "matches_regex":
|
|
869
|
+
return actualValues.some((value) => safeRegexTest(firstExpected, value));
|
|
870
|
+
case "greater_than":
|
|
871
|
+
return compareNumber(actual, expected, (a, b) => a > b);
|
|
872
|
+
case "greater_than_or_equal":
|
|
873
|
+
return compareNumber(actual, expected, (a, b) => a >= b);
|
|
874
|
+
case "less_than":
|
|
875
|
+
return compareNumber(actual, expected, (a, b) => a < b);
|
|
876
|
+
case "less_than_or_equal":
|
|
877
|
+
return compareNumber(actual, expected, (a, b) => a <= b);
|
|
878
|
+
default:
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function matchSegmentRule(
|
|
884
|
+
rule: SegmentTargetingRule,
|
|
885
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
886
|
+
): boolean {
|
|
887
|
+
const groups = Array.isArray(rule.rules?.groups)
|
|
888
|
+
? rule.rules.groups.filter((group) => Array.isArray(group) && group.length > 0)
|
|
889
|
+
: [];
|
|
890
|
+
if (groups.length) {
|
|
891
|
+
return groups.every((group) =>
|
|
892
|
+
group.some((condition) => matchSegmentCondition(condition, ctx)),
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const conditions = rule.rules?.conditions ?? [];
|
|
897
|
+
if (!conditions.length) return true;
|
|
898
|
+
if (rule.rules.operator === "or") {
|
|
899
|
+
return conditions.some((condition) => matchSegmentCondition(condition, ctx));
|
|
900
|
+
}
|
|
901
|
+
return conditions.every((condition) => matchSegmentCondition(condition, ctx));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function matchSegmentRules(
|
|
905
|
+
rules: SegmentTargetingRule[],
|
|
906
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
907
|
+
): boolean {
|
|
908
|
+
if (!rules.length) return true;
|
|
909
|
+
let hasInclude = false;
|
|
910
|
+
let included = false;
|
|
911
|
+
|
|
912
|
+
for (const rule of rules) {
|
|
913
|
+
const matched = matchSegmentRule(rule, ctx);
|
|
914
|
+
if (rule.include === false) {
|
|
915
|
+
if (matched) return false;
|
|
916
|
+
} else {
|
|
917
|
+
hasInclude = true;
|
|
918
|
+
if (matched) included = true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return included || !hasInclude;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function matchesTargeting(
|
|
926
|
+
targeting: Targeting | undefined,
|
|
927
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
928
|
+
): boolean {
|
|
929
|
+
return evaluateTargetingGates(targeting, ctx).matched;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export function targetingHasJsTriggers(experiments: Experiment[]): boolean {
|
|
933
|
+
return experiments.some((experiment) =>
|
|
934
|
+
(experiment.targeting?.pageRules ?? []).some((rule) =>
|
|
935
|
+
Boolean(rule.advancedCondition?.code?.trim()) ||
|
|
936
|
+
Boolean(rule.trigger?.triggerJs?.trim()),
|
|
937
|
+
) ||
|
|
938
|
+
(experiment.targeting?.segmentRules ?? []).some((rule) =>
|
|
939
|
+
(rule.rules?.conditions ?? []).some((condition) =>
|
|
940
|
+
Boolean(condition.js?.code?.trim()),
|
|
941
|
+
),
|
|
942
|
+
),
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function evaluateTargetingGatesDetailed(
|
|
947
|
+
targeting: Targeting | undefined,
|
|
948
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
949
|
+
): TargetingDiagnostics {
|
|
950
|
+
if (!__DRIP_QA_BUILD__) {
|
|
951
|
+
const aggregate = evaluateTargetingGates(targeting, ctx);
|
|
952
|
+
return { ...aggregate, conditions: [] };
|
|
953
|
+
}
|
|
954
|
+
return evaluateTargetingGatesWithConditionDiagnostics(targeting, ctx);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export function evaluateTargetingGates(
|
|
958
|
+
targeting: Targeting | undefined,
|
|
959
|
+
ctx: { url: string; attributes: Record<string, unknown> },
|
|
960
|
+
): { matched: boolean; gates: ActivationGateDiagnostic[] } {
|
|
961
|
+
const gates: ActivationGateDiagnostic[] = [];
|
|
962
|
+
if (!targeting) {
|
|
963
|
+
return {
|
|
964
|
+
matched: true,
|
|
965
|
+
gates: [
|
|
966
|
+
{ id: "page", label: "Page check", status: "skipped", reason: "no_page_rules" },
|
|
967
|
+
{ id: "audience", label: "Audience check", status: "skipped", reason: "no_audience_rules" },
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const urlRules = normalizeRuntimeUrlRules(targeting);
|
|
973
|
+
const pageRules = targeting.pageRules ?? [];
|
|
974
|
+
const pageHasRules = urlRules.length > 0 || pageRules.length > 0;
|
|
975
|
+
const pageMatched = pageHasRules
|
|
976
|
+
? matchUrlRules(ctx.url, urlRules) && matchPageRules(pageRules, ctx)
|
|
977
|
+
: true;
|
|
978
|
+
gates.push({
|
|
979
|
+
id: "page",
|
|
980
|
+
label: "Page check",
|
|
981
|
+
status: pageHasRules ? (pageMatched ? "passed" : "failed") : "skipped",
|
|
982
|
+
reason: pageHasRules ? (pageMatched ? "url_matched" : "url_mismatch") : "no_page_rules",
|
|
983
|
+
detail: pageHasRules ? { url: ctx.url, rules: urlRules.length, pageRules: pageRules.length } : undefined,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
if (!pageMatched) {
|
|
987
|
+
gates.push({
|
|
988
|
+
id: "audience",
|
|
989
|
+
label: "Audience check",
|
|
990
|
+
status: "skipped",
|
|
991
|
+
reason: "page_mismatch",
|
|
992
|
+
detail: { skipped: true },
|
|
993
|
+
});
|
|
994
|
+
return { matched: false, gates };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let attributesMatched = true;
|
|
998
|
+
const attributeRules = targeting.attributes ?? [];
|
|
999
|
+
for (const rule of attributeRules) {
|
|
1000
|
+
if (!matchAttributeRule(ctx.attributes, rule)) {
|
|
1001
|
+
attributesMatched = false;
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const segmentRules = targeting.segmentRules ?? [];
|
|
1007
|
+
const segmentRulesMatched = matchSegmentRules(segmentRules, ctx);
|
|
1008
|
+
const audienceHasRules = attributeRules.length > 0 || segmentRules.length > 0;
|
|
1009
|
+
const audienceMatched = attributesMatched && segmentRulesMatched;
|
|
1010
|
+
gates.push({
|
|
1011
|
+
id: "audience",
|
|
1012
|
+
label: "Audience check",
|
|
1013
|
+
status: audienceHasRules ? (audienceMatched ? "passed" : "failed") : "skipped",
|
|
1014
|
+
reason: audienceHasRules
|
|
1015
|
+
? (audienceMatched ? "audience_matched" : !attributesMatched ? "attribute_mismatch" : "segment_mismatch")
|
|
1016
|
+
: "no_audience_rules",
|
|
1017
|
+
detail: audienceHasRules ? { attributeRules: attributeRules.length, segmentRules: segmentRules.length } : undefined,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
matched: pageMatched && audienceMatched,
|
|
1022
|
+
gates,
|
|
1023
|
+
};
|
|
1024
|
+
}
|