@nextcommerce/campaigns-os 1.54.0 → 1.57.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/CHANGELOG.md +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +6 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- package/src/stage-record.mjs +53 -16
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkout-flow — which page takes payment, and the pages that lead to it.
|
|
3
|
+
*
|
|
4
|
+
* From CampaignSpec v5 a page's `type` says which page takes payment
|
|
5
|
+
* (campaigns-os#641). Three types carry the shopper into a payment:
|
|
6
|
+
*
|
|
7
|
+
* | Type | Checkout form | Places order | Forward field |
|
|
8
|
+
* |-----------------|---------------|--------------|---------------|
|
|
9
|
+
* | `select` | no | no | `next_page` |
|
|
10
|
+
* | `checkout_step` | yes | no | `next_page` |
|
|
11
|
+
* | `checkout` | yes | yes | `success_url` |
|
|
12
|
+
*
|
|
13
|
+
* All three render as SDK page type `checkout`. A campaign may hold several
|
|
14
|
+
* Checkouts (one per split-test path); each path reaches exactly one.
|
|
15
|
+
*
|
|
16
|
+
* Every consumer that asks "which page on this path takes payment" asks here,
|
|
17
|
+
* never "the first page typed checkout". The one place the payment page is
|
|
18
|
+
* worked out from links rather than read from `type` is `upgradeCampaignSpec`,
|
|
19
|
+
* which retypes a v4 spec on read.
|
|
20
|
+
*/
|
|
21
|
+
import { forwardRouteTarget, outgoingEdgeIds } from "./routing.js";
|
|
22
|
+
/** The CampaignSpec lineage this module writes when it upgrades a v4 spec. */
|
|
23
|
+
export const CURRENT_SCHEMA_VERSION = '5.0';
|
|
24
|
+
/** The page that takes payment. */
|
|
25
|
+
export const PAYMENT_PAGE_TYPE = 'checkout';
|
|
26
|
+
/** A checkout form that collects details and moves on without placing an order. */
|
|
27
|
+
export const CHECKOUT_STEP_PAGE_TYPE = 'checkout_step';
|
|
28
|
+
/** Pages that lead into a Checkout without taking payment themselves. */
|
|
29
|
+
export const PRE_PAYMENT_PAGE_TYPES = Object.freeze(['select', 'checkout_step']);
|
|
30
|
+
/**
|
|
31
|
+
* Every page type the SDK renders as `checkout`: the pre-payment pages and the
|
|
32
|
+
* Checkout itself. Payment-page features (exit intent, promo code input,
|
|
33
|
+
* checkout theme gates) apply to all of them.
|
|
34
|
+
*/
|
|
35
|
+
export const CHECKOUT_FLOW_PAGE_TYPES = Object.freeze(['select', 'checkout_step', 'checkout']);
|
|
36
|
+
/** Where a Checkout may send the shopper once payment succeeds. */
|
|
37
|
+
export const POST_PAYMENT_PAGE_TYPES = Object.freeze(['upsell', 'downsell', 'thankyou']);
|
|
38
|
+
/** Post-purchase offer pages: reaching one means an order was already placed. */
|
|
39
|
+
export const POST_PURCHASE_OFFER_PAGE_TYPES = Object.freeze(['upsell', 'downsell']);
|
|
40
|
+
function typeOf(page) {
|
|
41
|
+
const type = page?.type;
|
|
42
|
+
return typeof type === 'string' ? type.trim().toLowerCase() : '';
|
|
43
|
+
}
|
|
44
|
+
export function isPaymentPage(page) {
|
|
45
|
+
return typeOf(page) === PAYMENT_PAGE_TYPE;
|
|
46
|
+
}
|
|
47
|
+
export function isCheckoutStepPage(page) {
|
|
48
|
+
return typeOf(page) === CHECKOUT_STEP_PAGE_TYPE;
|
|
49
|
+
}
|
|
50
|
+
export function isPrePaymentPage(page) {
|
|
51
|
+
return PRE_PAYMENT_PAGE_TYPES.includes(typeOf(page));
|
|
52
|
+
}
|
|
53
|
+
export function isCheckoutFlowPage(page) {
|
|
54
|
+
return CHECKOUT_FLOW_PAGE_TYPES.includes(typeOf(page));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Strip surrounding slashes and a `.html` suffix so a route target, a page ID
|
|
58
|
+
* and a `page_url` compare on equal terms. The corpus writes targets in all
|
|
59
|
+
* three dialects (`shipping`, `shipping.html`, `/shipping/`). Case is kept, as
|
|
60
|
+
* in RouteTargetResolves: intake's lookup is case-sensitive.
|
|
61
|
+
*/
|
|
62
|
+
function routeish(value) {
|
|
63
|
+
const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
|
|
64
|
+
return trimmed.toLowerCase().endsWith('.html') ? trimmed.slice(0, -'.html'.length) : trimmed;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The page a route target names within `pages`, or null when it names none
|
|
68
|
+
* (an absolute URL, a fragment, a route outside this funnel, or a typo that
|
|
69
|
+
* RouteTargetResolves reports). Page IDs win over `page_url` matches.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveRouteTargetPage(pages, target) {
|
|
72
|
+
if (typeof target !== 'string' || !target.trim())
|
|
73
|
+
return null;
|
|
74
|
+
const raw = target.trim();
|
|
75
|
+
if (raw.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(raw))
|
|
76
|
+
return null;
|
|
77
|
+
const key = routeish(raw);
|
|
78
|
+
if (!key)
|
|
79
|
+
return null;
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
if (page?.id === raw || (typeof page?.id === 'string' && routeish(page.id) === key))
|
|
82
|
+
return page;
|
|
83
|
+
}
|
|
84
|
+
for (const page of pages) {
|
|
85
|
+
const route = page?.page_url;
|
|
86
|
+
if (typeof route === 'string' && route.trim() && routeish(route) === key)
|
|
87
|
+
return page;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** The page this page's forward link resolves to, within `pages`. */
|
|
92
|
+
export function forwardTargetPage(pages, page) {
|
|
93
|
+
return resolveRouteTargetPage(pages, forwardRouteTarget(page));
|
|
94
|
+
}
|
|
95
|
+
function enabledPages(funnel) {
|
|
96
|
+
return (funnel?.pages ?? []).filter((page) => page && page.enabled !== false);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The pages from `start` forward to the Checkout that takes payment, both
|
|
100
|
+
* included, following each page's forward link. Null when the walk leaves the
|
|
101
|
+
* funnel, loops, ends, or meets a post-payment page before any Checkout.
|
|
102
|
+
*
|
|
103
|
+
* `start` may itself be the Checkout (the path is then that page alone).
|
|
104
|
+
*/
|
|
105
|
+
export function checkoutPathFrom(pages, start) {
|
|
106
|
+
const path = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
let page = start;
|
|
109
|
+
while (page && !seen.has(page)) {
|
|
110
|
+
seen.add(page);
|
|
111
|
+
path.push(page);
|
|
112
|
+
if (isPaymentPage(page))
|
|
113
|
+
return path;
|
|
114
|
+
if (POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)))
|
|
115
|
+
return null;
|
|
116
|
+
page = forwardTargetPage(pages, page);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/** The Checkout reached by walking forward from `start`, or null. */
|
|
121
|
+
export function paymentPageFrom(pages, start) {
|
|
122
|
+
const path = checkoutPathFrom(pages, start);
|
|
123
|
+
return path ? path[path.length - 1] : null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The funnel's entry pages: those flagged `is_entry`, or when none is flagged,
|
|
127
|
+
* those no other page routes to apart from orphaned upsell, downsell and
|
|
128
|
+
* thank-you pages (falling back to the lowest `order` when that leaves none). Order and names are never consulted beyond that
|
|
129
|
+
* last-resort tie-break.
|
|
130
|
+
*/
|
|
131
|
+
export function entryPages(funnel) {
|
|
132
|
+
const pages = enabledPages(funnel);
|
|
133
|
+
const flagged = pages.filter((page) => page.is_entry === true);
|
|
134
|
+
if (flagged.length)
|
|
135
|
+
return flagged;
|
|
136
|
+
const targeted = new Set();
|
|
137
|
+
for (const page of pages) {
|
|
138
|
+
for (const target of outgoingEdgeIds(page)) {
|
|
139
|
+
const hit = resolveRouteTargetPage(pages, target);
|
|
140
|
+
if (hit && hit !== page)
|
|
141
|
+
targeted.add(hit);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// A post-payment page nothing routes to is an orphan, not where a shopper
|
|
145
|
+
// starts: no unflagged journey begins on an upsell or a receipt.
|
|
146
|
+
const roots = pages.filter((page) => !targeted.has(page) && !POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)));
|
|
147
|
+
if (roots.length)
|
|
148
|
+
return roots;
|
|
149
|
+
const first = [...pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0];
|
|
150
|
+
return first ? [first] : [];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The Checkout on the path through `page`'s funnel that starts at an entry
|
|
154
|
+
* page, for callers that hold a funnel rather than a starting page. When the
|
|
155
|
+
* funnel has several entry paths with different Checkouts, returns the first
|
|
156
|
+
* entry's; use `paymentPagesForFunnel` to see all of them.
|
|
157
|
+
*/
|
|
158
|
+
export function paymentPageForFunnel(funnel) {
|
|
159
|
+
return paymentPagesForFunnel(funnel)[0] ?? null;
|
|
160
|
+
}
|
|
161
|
+
/** Every distinct Checkout reached from the funnel's entry pages, in entry order. */
|
|
162
|
+
export function paymentPagesForFunnel(funnel) {
|
|
163
|
+
const pages = enabledPages(funnel);
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const entry of entryPages(funnel)) {
|
|
166
|
+
const checkout = paymentPageFrom(pages, entry);
|
|
167
|
+
if (checkout && !out.includes(checkout))
|
|
168
|
+
out.push(checkout);
|
|
169
|
+
}
|
|
170
|
+
// A funnel whose entry is a landing page that links into checkout through a
|
|
171
|
+
// CTA (no forward field) still has a Checkout; fall back to every Checkout
|
|
172
|
+
// the funnel declares, in declaration order (funnel.pages), so a lookup never
|
|
173
|
+
// reports "none" for a real one. No entry path reaches these, so there is no
|
|
174
|
+
// path order to follow; QA's topology walker falls back the same way.
|
|
175
|
+
if (!out.length)
|
|
176
|
+
for (const page of pages)
|
|
177
|
+
if (isPaymentPage(page))
|
|
178
|
+
out.push(page);
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Upgrade a CampaignSpec to the v5 page vocabulary. Returns a new object; the
|
|
183
|
+
* input is never mutated, so callers that hash or persist the raw bytes keep
|
|
184
|
+
* the bytes they read.
|
|
185
|
+
*
|
|
186
|
+
* v5 specs pass through unchanged. For any other lineage, each `checkout` page
|
|
187
|
+
* whose resolved forward target is also a `checkout` is retyped
|
|
188
|
+
* `checkout_step` — the v4 `checkout → checkout` chain was the only way to say
|
|
189
|
+
* "this form moves on without taking payment". A supported v4 `schema_version`
|
|
190
|
+
* becomes `5.0`; a missing or unsupported one is left for SchemaVersion to
|
|
191
|
+
* report.
|
|
192
|
+
*/
|
|
193
|
+
export function upgradeCampaignSpec(input) {
|
|
194
|
+
if (input == null || typeof input !== 'object' || Array.isArray(input))
|
|
195
|
+
return input;
|
|
196
|
+
const spec = input;
|
|
197
|
+
const version = typeof spec.schema_version === 'string' ? spec.schema_version.trim() : '';
|
|
198
|
+
if (version.startsWith('5.') || version === '5')
|
|
199
|
+
return input;
|
|
200
|
+
if (!Array.isArray(spec.funnels))
|
|
201
|
+
return input;
|
|
202
|
+
let changed = false;
|
|
203
|
+
const funnels = spec.funnels.map((funnel) => {
|
|
204
|
+
if (!funnel || !Array.isArray(funnel.pages))
|
|
205
|
+
return funnel;
|
|
206
|
+
const pages = funnel.pages;
|
|
207
|
+
const retype = new Set();
|
|
208
|
+
for (const page of pages) {
|
|
209
|
+
if (!isPaymentPage(page))
|
|
210
|
+
continue;
|
|
211
|
+
if (isPaymentPage(forwardTargetPage(pages, page)))
|
|
212
|
+
retype.add(page);
|
|
213
|
+
}
|
|
214
|
+
if (!retype.size)
|
|
215
|
+
return funnel;
|
|
216
|
+
changed = true;
|
|
217
|
+
return {
|
|
218
|
+
...funnel,
|
|
219
|
+
pages: pages.map((page) => (retype.has(page) ? { ...page, type: CHECKOUT_STEP_PAGE_TYPE } : page)),
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
const upgradeVersion = version === '4.2' || version === '4.3';
|
|
223
|
+
if (!changed && !upgradeVersion)
|
|
224
|
+
return input;
|
|
225
|
+
const out = { ...spec, funnels };
|
|
226
|
+
if (upgradeVersion)
|
|
227
|
+
out.schema_version = CURRENT_SCHEMA_VERSION;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
@@ -15,6 +15,7 @@ export type { CampaignSpec, Rule, RuleSet, Violation, Tag, Severity, Fixture, Pa
|
|
|
15
15
|
export { normalize, NormalizeError } from './normalize.ts';
|
|
16
16
|
export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from './routing.ts';
|
|
17
17
|
export type { ForwardFieldApplicability } from './routing.ts';
|
|
18
|
+
export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from './checkout-flow.ts';
|
|
18
19
|
export { allRules, fastRules, specOnlyRules } from './rules/index.ts';
|
|
19
20
|
export { SUPPORTED_SCHEMA_VERSIONS } from './rules/schema-version.ts';
|
|
20
21
|
export { RELEASED_SDK_VERSION_PATTERN, parseSdkVersion, isReleasedSdkVersion, describeSdkVersionRejection, } from './sdk-version-parse.ts';
|
|
@@ -17,10 +17,15 @@ export { normalize, NormalizeError } from "./normalize.js";
|
|
|
17
17
|
// page go". Source intake, cycle detection and the QA topology extractor all
|
|
18
18
|
// consume these rather than keeping their own page-type tables.
|
|
19
19
|
export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from "./routing.js";
|
|
20
|
+
// Which page takes payment, and the pages leading to it (campaigns-os#641).
|
|
21
|
+
// Every "the Checkout on this path" lookup asks here; upgradeCampaignSpec is
|
|
22
|
+
// the v4 → v5 read-time upgrade normalize() applies.
|
|
23
|
+
export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from "./checkout-flow.js";
|
|
20
24
|
export { allRules, fastRules, specOnlyRules } from "./rules/index.js";
|
|
21
25
|
// Supported schema_version matrix — single source for the SchemaVersion rule
|
|
22
26
|
// and any consumer that needs to present or gate on the supported lineages.
|
|
23
|
-
// A sync test pins it to the
|
|
27
|
+
// A sync test pins it to the schema_version enums of
|
|
28
|
+
// schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json.
|
|
24
29
|
export { SUPPORTED_SCHEMA_VERSIONS } from "./rules/schema-version.js";
|
|
25
30
|
// Strict released-SDK-version parser — shared with the downstream Page Kit
|
|
26
31
|
// SDK-version checkpoint (src/page-kit-sdk-version.mjs) so authoring-time
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* normalize — the single phase that takes an authoring CampaignSpec and emits
|
|
3
3
|
* the canonical v4.2 funnels[] shape that rules operate on.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
|
|
6
|
+
* that forwards to another `checkout` becomes `checkout_step`), so every rule
|
|
7
|
+
* sees one vocabulary. Future authoring evolutions land here too.
|
|
7
8
|
*
|
|
8
9
|
* v4.1 (funnel_pages[]) is intentionally NOT supported — see
|
|
9
10
|
* ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* normalize — the single phase that takes an authoring CampaignSpec and emits
|
|
3
3
|
* the canonical v4.2 funnels[] shape that rules operate on.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
|
|
6
|
+
* that forwards to another `checkout` becomes `checkout_step`), so every rule
|
|
7
|
+
* sees one vocabulary. Future authoring evolutions land here too.
|
|
7
8
|
*
|
|
8
9
|
* v4.1 (funnel_pages[]) is intentionally NOT supported — see
|
|
9
10
|
* ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
|
|
10
11
|
* the structural assertion below.
|
|
11
12
|
*/
|
|
13
|
+
import { upgradeCampaignSpec } from "./checkout-flow.js";
|
|
12
14
|
/**
|
|
13
15
|
* Thrown when input is structurally unrecognizable as a CampaignSpec.
|
|
14
16
|
* Distinct from rule violations: a violation means "this spec is wrong";
|
|
@@ -35,7 +37,7 @@ export function normalize(input) {
|
|
|
35
37
|
if (!Array.isArray(obj.funnels)) {
|
|
36
38
|
throw new NormalizeError('CampaignSpec is missing funnels[]. Expected canonical v4.2+ topology.');
|
|
37
39
|
}
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
return obj;
|
|
40
|
+
// v4 → v5: retype each `checkout` that forwards to another `checkout` as
|
|
41
|
+
// `checkout_step` (campaigns-os#641). Returns a copy; the input is untouched.
|
|
42
|
+
return upgradeCampaignSpec(obj);
|
|
41
43
|
}
|
|
@@ -73,9 +73,12 @@ export declare const DECLINE_ROUTE_FIELD: "on_decline";
|
|
|
73
73
|
* is about one field's MEANING: reading a payment-shaped field on a page with
|
|
74
74
|
* no payment is not respecting the author's intent, it is inventing one.
|
|
75
75
|
*
|
|
76
|
-
* Membership is deliberately narrow.
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
76
|
+
* Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
|
|
77
|
+
* `checkout` means exactly the page that takes payment; the earlier steps of a
|
|
78
|
+
* split checkout (the three-step shop family's `information` and `shipping`)
|
|
79
|
+
* are `checkout_step`, which take no payment and advance through `next_page`.
|
|
80
|
+
* A v4 spec that chained `checkout → checkout` is retyped on read
|
|
81
|
+
* (checkout-flow.ts), so the billing page alone stays payment-bearing here.
|
|
79
82
|
* An `upsell` takes a one-click payment but expresses its post-purchase branch
|
|
80
83
|
* through `on_accept`, which has its own applicability rule below.
|
|
81
84
|
* Ratified on campaigns-os#234, 2026-08-25.
|
|
@@ -76,9 +76,12 @@ export const DECLINE_ROUTE_FIELD = 'on_decline';
|
|
|
76
76
|
* is about one field's MEANING: reading a payment-shaped field on a page with
|
|
77
77
|
* no payment is not respecting the author's intent, it is inventing one.
|
|
78
78
|
*
|
|
79
|
-
* Membership is deliberately narrow.
|
|
80
|
-
* `
|
|
81
|
-
*
|
|
79
|
+
* Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
|
|
80
|
+
* `checkout` means exactly the page that takes payment; the earlier steps of a
|
|
81
|
+
* split checkout (the three-step shop family's `information` and `shipping`)
|
|
82
|
+
* are `checkout_step`, which take no payment and advance through `next_page`.
|
|
83
|
+
* A v4 spec that chained `checkout → checkout` is retyped on read
|
|
84
|
+
* (checkout-flow.ts), so the billing page alone stays payment-bearing here.
|
|
82
85
|
* An `upsell` takes a one-click payment but expresses its post-purchase branch
|
|
83
86
|
* through `on_accept`, which has its own applicability rule below.
|
|
84
87
|
* Ratified on campaigns-os#234, 2026-08-25.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutForwardTarget — a Checkout takes payment, so where it sends the
|
|
3
|
+
* shopper next must be a post-payment page: an upsell, a downsell or a
|
|
4
|
+
* thank-you page (campaigns-os#641).
|
|
5
|
+
*
|
|
6
|
+
* A `checkout` forwarding to another checkout-shaped page, a landing page or a
|
|
7
|
+
* select page means one of two things, both wrong: the page is really a step
|
|
8
|
+
* that collects details and moves on (type it `checkout_step`), or the order
|
|
9
|
+
* would be placed before the shopper reaches the page meant to take payment.
|
|
10
|
+
*
|
|
11
|
+
* Only a target this funnel resolves is judged. An off-graph destination (a
|
|
12
|
+
* rooted path into an existing downstream campaign, an absolute URL) is the
|
|
13
|
+
* partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
|
|
14
|
+
* a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
|
|
15
|
+
*/
|
|
16
|
+
import type { Rule } from '../types.ts';
|
|
17
|
+
export declare const CheckoutForwardTarget: Rule;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutForwardTarget — a Checkout takes payment, so where it sends the
|
|
3
|
+
* shopper next must be a post-payment page: an upsell, a downsell or a
|
|
4
|
+
* thank-you page (campaigns-os#641).
|
|
5
|
+
*
|
|
6
|
+
* A `checkout` forwarding to another checkout-shaped page, a landing page or a
|
|
7
|
+
* select page means one of two things, both wrong: the page is really a step
|
|
8
|
+
* that collects details and moves on (type it `checkout_step`), or the order
|
|
9
|
+
* would be placed before the shopper reaches the page meant to take payment.
|
|
10
|
+
*
|
|
11
|
+
* Only a target this funnel resolves is judged. An off-graph destination (a
|
|
12
|
+
* rooted path into an existing downstream campaign, an absolute URL) is the
|
|
13
|
+
* partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
|
|
14
|
+
* a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
|
|
15
|
+
*/
|
|
16
|
+
import { forwardRouteTarget } from "../routing.js";
|
|
17
|
+
import { POST_PAYMENT_PAGE_TYPES, forwardTargetPage, isPaymentPage } from "../checkout-flow.js";
|
|
18
|
+
export const CheckoutForwardTarget = {
|
|
19
|
+
id: 'CheckoutForwardTarget',
|
|
20
|
+
severity: 'error',
|
|
21
|
+
tags: ['fast', 'structure', 'spec-only'],
|
|
22
|
+
check(spec) {
|
|
23
|
+
const violations = [];
|
|
24
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
25
|
+
const pages = funnel.pages ?? [];
|
|
26
|
+
pages.forEach((page, pageIdx) => {
|
|
27
|
+
if (!isPaymentPage(page))
|
|
28
|
+
return;
|
|
29
|
+
const target = forwardTargetPage(pages, page);
|
|
30
|
+
if (!target)
|
|
31
|
+
return;
|
|
32
|
+
const targetType = typeof target.type === 'string' ? target.type : '';
|
|
33
|
+
if (POST_PAYMENT_PAGE_TYPES.includes(targetType))
|
|
34
|
+
return;
|
|
35
|
+
violations.push({
|
|
36
|
+
ruleId: 'CheckoutForwardTarget',
|
|
37
|
+
severity: 'error',
|
|
38
|
+
message: `Checkout "${page.id}" forwards to "${target.id}" (type "${targetType || 'none'}"). ` +
|
|
39
|
+
'A Checkout takes payment, so it must forward to an upsell, downsell or thank-you page. ' +
|
|
40
|
+
'If this page collects details and moves on without placing an order, type it "checkout_step".',
|
|
41
|
+
path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
|
|
42
|
+
data: { pageId: page.id, target: forwardRouteTarget(page), targetPageId: target.id, targetType },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return violations;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
|
|
3
|
+
* to a Checkout. Following its forward links has to arrive at a `checkout`
|
|
4
|
+
* page; a step that dead-ends, loops, leaves the funnel or reaches an offer
|
|
5
|
+
* first collects the shopper's details and never takes payment
|
|
6
|
+
* (campaigns-os#641).
|
|
7
|
+
*
|
|
8
|
+
* Forward links only: a step has no decline branch that could carry the
|
|
9
|
+
* shopper to payment instead.
|
|
10
|
+
*/
|
|
11
|
+
import type { Rule } from '../types.ts';
|
|
12
|
+
export declare const CheckoutStepReachesCheckout: Rule;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
|
|
3
|
+
* to a Checkout. Following its forward links has to arrive at a `checkout`
|
|
4
|
+
* page; a step that dead-ends, loops, leaves the funnel or reaches an offer
|
|
5
|
+
* first collects the shopper's details and never takes payment
|
|
6
|
+
* (campaigns-os#641).
|
|
7
|
+
*
|
|
8
|
+
* Forward links only: a step has no decline branch that could carry the
|
|
9
|
+
* shopper to payment instead.
|
|
10
|
+
*/
|
|
11
|
+
import { isCheckoutStepPage, paymentPageFrom } from "../checkout-flow.js";
|
|
12
|
+
export const CheckoutStepReachesCheckout = {
|
|
13
|
+
id: 'CheckoutStepReachesCheckout',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
tags: ['structure', 'spec-only'],
|
|
16
|
+
check(spec) {
|
|
17
|
+
const violations = [];
|
|
18
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
19
|
+
const pages = funnel.pages ?? [];
|
|
20
|
+
pages.forEach((page, pageIdx) => {
|
|
21
|
+
if (!isCheckoutStepPage(page))
|
|
22
|
+
return;
|
|
23
|
+
if (paymentPageFrom(pages, page))
|
|
24
|
+
return;
|
|
25
|
+
violations.push({
|
|
26
|
+
ruleId: 'CheckoutStepReachesCheckout',
|
|
27
|
+
severity: 'error',
|
|
28
|
+
message: `Checkout step "${page.id}" is not on a path to a Checkout. ` +
|
|
29
|
+
'Follow its next_page links to the page that takes payment (type "checkout").',
|
|
30
|
+
path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
|
|
31
|
+
data: { pageId: page.id },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
return violations;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Message text inherited verbatim from the pre-#110 validator at migration time.
|
|
21
21
|
*/
|
|
22
|
+
import { isCheckoutFlowPage } from "../checkout-flow.js";
|
|
22
23
|
function isTruthyString(value) {
|
|
23
24
|
return typeof value === 'string' && value.trim().length > 0;
|
|
24
25
|
}
|
|
@@ -55,7 +56,7 @@ export const ExitIntentValidation = {
|
|
|
55
56
|
const refOffer = ref ? index.byRefId.get(String(ref)) : null;
|
|
56
57
|
const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
|
|
57
58
|
// 1. Placement
|
|
58
|
-
if (page
|
|
59
|
+
if (!isCheckoutFlowPage(page)) {
|
|
59
60
|
violations.push({
|
|
60
61
|
ruleId: 'ExitIntentValidation',
|
|
61
62
|
severity: 'warning',
|
|
@@ -31,6 +31,10 @@ export { UpsellHasPackages } from './upsell-has-packages.ts';
|
|
|
31
31
|
export { UpsellRoutingComplete } from './upsell-routing-complete.ts';
|
|
32
32
|
export { RouteFieldIgnoredForPageType } from './route-field-ignored-for-page-type.ts';
|
|
33
33
|
export { CheckoutHasSuccessUrl } from './checkout-has-success-url.ts';
|
|
34
|
+
export { CheckoutForwardTarget } from './checkout-forward-target.ts';
|
|
35
|
+
export { PrePaymentForwardTarget } from './pre-payment-forward-target.ts';
|
|
36
|
+
export { CheckoutStepReachesCheckout } from './checkout-step-reaches-checkout.ts';
|
|
37
|
+
export { OneCheckoutPerPath } from './one-checkout-per-path.ts';
|
|
34
38
|
export { UpsellWithoutCheckout } from './upsell-without-checkout.ts';
|
|
35
39
|
export { DownsellWithoutUpsell } from './downsell-without-upsell.ts';
|
|
36
40
|
export { PackagePricingSanity } from './package-pricing-sanity.ts';
|
|
@@ -30,6 +30,10 @@ import { UpsellRoutingComplete } from "./upsell-routing-complete.js";
|
|
|
30
30
|
import { RouteTargetResolves } from "./route-target-resolves.js";
|
|
31
31
|
import { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
|
|
32
32
|
import { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
|
|
33
|
+
import { CheckoutForwardTarget } from "./checkout-forward-target.js";
|
|
34
|
+
import { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
|
|
35
|
+
import { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
|
|
36
|
+
import { OneCheckoutPerPath } from "./one-checkout-per-path.js";
|
|
33
37
|
import { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
|
|
34
38
|
import { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
|
|
35
39
|
import { CampaignMetadata } from "./campaign-metadata.js";
|
|
@@ -61,6 +65,10 @@ export { UpsellHasPackages } from "./upsell-has-packages.js";
|
|
|
61
65
|
export { UpsellRoutingComplete } from "./upsell-routing-complete.js";
|
|
62
66
|
export { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
|
|
63
67
|
export { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
|
|
68
|
+
export { CheckoutForwardTarget } from "./checkout-forward-target.js";
|
|
69
|
+
export { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
|
|
70
|
+
export { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
|
|
71
|
+
export { OneCheckoutPerPath } from "./one-checkout-per-path.js";
|
|
64
72
|
export { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
|
|
65
73
|
export { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
|
|
66
74
|
export { PackagePricingSanity } from "./package-pricing-sanity.js";
|
|
@@ -102,6 +110,10 @@ export const allRules = [
|
|
|
102
110
|
RouteTargetResolves,
|
|
103
111
|
RouteFieldIgnoredForPageType,
|
|
104
112
|
CheckoutHasSuccessUrl,
|
|
113
|
+
CheckoutForwardTarget,
|
|
114
|
+
PrePaymentForwardTarget,
|
|
115
|
+
CheckoutStepReachesCheckout,
|
|
116
|
+
OneCheckoutPerPath,
|
|
105
117
|
UpsellWithoutCheckout,
|
|
106
118
|
DownsellWithoutUpsell,
|
|
107
119
|
PackagePricingSanity,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneCheckoutPerPath — every path from an entry page to an upsell or downsell
|
|
3
|
+
* passes exactly one Checkout (campaigns-os#641).
|
|
4
|
+
*
|
|
5
|
+
* None means the shopper reaches a post-purchase offer with no order placed;
|
|
6
|
+
* two means the path takes payment twice. A campaign may hold several
|
|
7
|
+
* Checkouts — split-test paths each reach their own — so the rule counts per
|
|
8
|
+
* path, never per spec.
|
|
9
|
+
*
|
|
10
|
+
* Paths follow both edges a page can traverse (forward and decline, from
|
|
11
|
+
* routing.ts) and start at the funnel's entry pages (checkout-flow.ts
|
|
12
|
+
* `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
|
|
13
|
+
* page order or name is consulted. The search runs, per entry page, over
|
|
14
|
+
* (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
|
|
15
|
+
* reported once per entry and count however many of that entry's paths reach it.
|
|
16
|
+
*
|
|
17
|
+
* A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
|
|
18
|
+
* two never report one defect twice.
|
|
19
|
+
*
|
|
20
|
+
* With build_scope.mode === 'partial' a path that reaches an offer with no
|
|
21
|
+
* Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
|
|
22
|
+
* may ship an offer an existing checkout elsewhere routes into.
|
|
23
|
+
*/
|
|
24
|
+
import type { Rule } from '../types.ts';
|
|
25
|
+
export declare const OneCheckoutPerPath: Rule;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneCheckoutPerPath — every path from an entry page to an upsell or downsell
|
|
3
|
+
* passes exactly one Checkout (campaigns-os#641).
|
|
4
|
+
*
|
|
5
|
+
* None means the shopper reaches a post-purchase offer with no order placed;
|
|
6
|
+
* two means the path takes payment twice. A campaign may hold several
|
|
7
|
+
* Checkouts — split-test paths each reach their own — so the rule counts per
|
|
8
|
+
* path, never per spec.
|
|
9
|
+
*
|
|
10
|
+
* Paths follow both edges a page can traverse (forward and decline, from
|
|
11
|
+
* routing.ts) and start at the funnel's entry pages (checkout-flow.ts
|
|
12
|
+
* `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
|
|
13
|
+
* page order or name is consulted. The search runs, per entry page, over
|
|
14
|
+
* (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
|
|
15
|
+
* reported once per entry and count however many of that entry's paths reach it.
|
|
16
|
+
*
|
|
17
|
+
* A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
|
|
18
|
+
* two never report one defect twice.
|
|
19
|
+
*
|
|
20
|
+
* With build_scope.mode === 'partial' a path that reaches an offer with no
|
|
21
|
+
* Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
|
|
22
|
+
* may ship an offer an existing checkout elsewhere routes into.
|
|
23
|
+
*/
|
|
24
|
+
import { outgoingEdgeIds } from "../routing.js";
|
|
25
|
+
import { POST_PURCHASE_OFFER_PAGE_TYPES, entryPages, isPaymentPage, resolveRouteTargetPage } from "../checkout-flow.js";
|
|
26
|
+
function isOffer(page) {
|
|
27
|
+
const type = typeof page.type === 'string' ? page.type.trim().toLowerCase() : '';
|
|
28
|
+
return POST_PURCHASE_OFFER_PAGE_TYPES.includes(type);
|
|
29
|
+
}
|
|
30
|
+
export const OneCheckoutPerPath = {
|
|
31
|
+
id: 'OneCheckoutPerPath',
|
|
32
|
+
severity: 'error',
|
|
33
|
+
tags: ['structure', 'spec-only'],
|
|
34
|
+
check(spec) {
|
|
35
|
+
const violations = [];
|
|
36
|
+
const partial = spec.build_scope?.mode === 'partial';
|
|
37
|
+
// A spec with no Checkout at all is UpsellWithoutCheckout's to report; this
|
|
38
|
+
// rule speaks only when Checkouts exist and some path misses or doubles one.
|
|
39
|
+
const anyCheckout = spec.funnels.some((funnel) => (funnel.pages ?? []).some((page) => isPaymentPage(page)));
|
|
40
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
41
|
+
const pages = (funnel.pages ?? []).filter((page) => page && page.enabled !== false);
|
|
42
|
+
const indexOf = new Map();
|
|
43
|
+
(funnel.pages ?? []).forEach((page, idx) => indexOf.set(page, idx));
|
|
44
|
+
// Searched once per entry page, so every entry whose path misses or
|
|
45
|
+
// doubles the Checkout is named, not only the first one to reach the page.
|
|
46
|
+
for (const entry of entryPages(funnel)) {
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const reported = new Set();
|
|
49
|
+
const stack = [[entry, 0]];
|
|
50
|
+
while (stack.length) {
|
|
51
|
+
const [page, before] = stack.pop();
|
|
52
|
+
const passed = Math.min(before + (isPaymentPage(page) ? 1 : 0), 2);
|
|
53
|
+
const key = `${indexOf.get(page)}:${passed}`;
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
if (isOffer(page) && passed !== 1 && (passed > 1 || anyCheckout)) {
|
|
58
|
+
const reportKey = `${indexOf.get(page)}:${passed === 0 ? 0 : 2}`;
|
|
59
|
+
if (!reported.has(reportKey)) {
|
|
60
|
+
reported.add(reportKey);
|
|
61
|
+
const none = passed === 0;
|
|
62
|
+
violations.push({
|
|
63
|
+
ruleId: 'OneCheckoutPerPath',
|
|
64
|
+
severity: none && partial ? 'warning' : 'error',
|
|
65
|
+
message: none
|
|
66
|
+
? `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) without passing a Checkout, so no order has been placed.` +
|
|
67
|
+
(partial ? ' Valid for partial upsell builds when an existing checkout routes into this page.' : '')
|
|
68
|
+
: `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) after passing more than one Checkout, so it takes payment twice.`,
|
|
69
|
+
path: `/funnels/${funnelIdx}/pages/${indexOf.get(page)}`,
|
|
70
|
+
data: { pageId: page.id, entryPageId: entry.id, checkoutsPassed: none ? 0 : 2, isPartialScope: partial },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const target of outgoingEdgeIds(page)) {
|
|
75
|
+
const next = resolveRouteTargetPage(pages, target);
|
|
76
|
+
if (next)
|
|
77
|
+
stack.push([next, passed]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return violations;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrePaymentForwardTarget — a `select` or `checkout_step` page takes no
|
|
3
|
+
* payment, so it must not forward straight to a post-payment page (an upsell,
|
|
4
|
+
* a downsell or a thank-you page). Doing so skips the Checkout: the shopper
|
|
5
|
+
* reaches an offer or a receipt with no order placed (campaigns-os#641).
|
|
6
|
+
*
|
|
7
|
+
* Only the resolved forward link is judged — routing.ts already ignores a
|
|
8
|
+
* `success_url` or `on_accept` on these types, and RouteFieldIgnoredForPageType
|
|
9
|
+
* warns about those separately.
|
|
10
|
+
*/
|
|
11
|
+
import type { Rule } from '../types.ts';
|
|
12
|
+
export declare const PrePaymentForwardTarget: Rule;
|