@nextcommerce/campaigns-os 1.55.0 → 1.59.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 +124 -0
- package/CONTEXT.md +11 -0
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +97 -0
- package/campaign-spec/dist/checkout-flow.js +245 -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/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- 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 +1137 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +68 -10
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +124 -22
- 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/package.json +2 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- 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 +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/built-site-scope.mjs +91 -0
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +117 -57
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +269 -16
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +18 -9
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +8 -7
- package/src/qa-browser.mjs +363 -36
- package/src/qa-cart-entry.mjs +183 -14
- package/src/qa-node.mjs +38 -34
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict.mjs +24 -1
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +58 -7
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +203 -36
|
@@ -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;
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { forwardRouteTarget } from "../routing.js";
|
|
12
|
+
import { POST_PAYMENT_PAGE_TYPES, forwardTargetPage, isPrePaymentPage } from "../checkout-flow.js";
|
|
13
|
+
export const PrePaymentForwardTarget = {
|
|
14
|
+
id: 'PrePaymentForwardTarget',
|
|
15
|
+
severity: 'error',
|
|
16
|
+
tags: ['fast', 'structure', 'spec-only'],
|
|
17
|
+
check(spec) {
|
|
18
|
+
const violations = [];
|
|
19
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
20
|
+
const pages = funnel.pages ?? [];
|
|
21
|
+
pages.forEach((page, pageIdx) => {
|
|
22
|
+
if (!isPrePaymentPage(page))
|
|
23
|
+
return;
|
|
24
|
+
const target = forwardTargetPage(pages, page);
|
|
25
|
+
if (!target)
|
|
26
|
+
return;
|
|
27
|
+
const targetType = typeof target.type === 'string' ? target.type : '';
|
|
28
|
+
if (!POST_PAYMENT_PAGE_TYPES.includes(targetType))
|
|
29
|
+
return;
|
|
30
|
+
violations.push({
|
|
31
|
+
ruleId: 'PrePaymentForwardTarget',
|
|
32
|
+
severity: 'error',
|
|
33
|
+
message: `"${page.type}" page "${page.id}" forwards to "${target.id}" (type "${targetType}") without passing a Checkout. ` +
|
|
34
|
+
'Route it (via next_page) to the next checkout step or to the Checkout that takes payment.',
|
|
35
|
+
path: `/funnels/${funnelIdx}/pages/${pageIdx}/next_page`,
|
|
36
|
+
data: { pageId: page.id, pageType: page.type, target: forwardRouteTarget(page), targetPageId: target.id, targetType },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return violations;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* intent because both surfaces apply a voucher/code-backed Offer through the
|
|
5
5
|
* same Campaign Cart SDK/Campaigns API pricing path.
|
|
6
6
|
*/
|
|
7
|
+
import { isCheckoutFlowPage } from "../checkout-flow.js";
|
|
7
8
|
function isTruthyString(value) {
|
|
8
9
|
return typeof value === 'string' && value.trim().length > 0;
|
|
9
10
|
}
|
|
@@ -39,7 +40,7 @@ export const PromoCodeInputValidation = {
|
|
|
39
40
|
const code = promo.offer_code;
|
|
40
41
|
const refOffer = ref ? index.byRefId.get(String(ref)) : null;
|
|
41
42
|
const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
|
|
42
|
-
if (page
|
|
43
|
+
if (!isCheckoutFlowPage(page)) {
|
|
43
44
|
violations.push({
|
|
44
45
|
ruleId: 'PromoCodeInputValidation',
|
|
45
46
|
severity: 'warning',
|
|
@@ -9,13 +9,18 @@
|
|
|
9
9
|
* lineage errors here at authoring/export time instead of surfacing as
|
|
10
10
|
* downstream shape drift.
|
|
11
11
|
*
|
|
12
|
-
* SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version
|
|
13
|
-
* schemas/campaign-spec.v4.schema.json
|
|
14
|
-
* range). The rule stays pure (no filesystem), so the constant duplicates the
|
|
12
|
+
* SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enums of
|
|
13
|
+
* schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json,
|
|
14
|
+
* concatenated (and compatibility.json's campaign_spec range). The rule stays pure (no filesystem), so the constant duplicates the
|
|
15
15
|
* schema enum — the sync test in ../test/rules/schema-version.test.ts is the
|
|
16
16
|
* drift gate, following the UnknownTopLevelFields pattern.
|
|
17
17
|
*/
|
|
18
18
|
import type { Rule } from '../types.ts';
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Supported CampaignSpec lineage versions — the v4 JSON Schema enum followed by
|
|
21
|
+
* the v5 one, in that order. A 4.2/4.3 spec is upgraded to v5 on read
|
|
22
|
+
* (checkout-flow.ts), so this rule sees `5.0` for it after normalize(); the v4
|
|
23
|
+
* entries stay so a caller running the rule on an un-normalized spec agrees.
|
|
24
|
+
*/
|
|
20
25
|
export declare const SUPPORTED_SCHEMA_VERSIONS: readonly string[];
|
|
21
26
|
export declare const SchemaVersion: Rule;
|
|
@@ -9,14 +9,19 @@
|
|
|
9
9
|
* lineage errors here at authoring/export time instead of surfacing as
|
|
10
10
|
* downstream shape drift.
|
|
11
11
|
*
|
|
12
|
-
* SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version
|
|
13
|
-
* schemas/campaign-spec.v4.schema.json
|
|
14
|
-
* range). The rule stays pure (no filesystem), so the constant duplicates the
|
|
12
|
+
* SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enums of
|
|
13
|
+
* schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json,
|
|
14
|
+
* concatenated (and compatibility.json's campaign_spec range). The rule stays pure (no filesystem), so the constant duplicates the
|
|
15
15
|
* schema enum — the sync test in ../test/rules/schema-version.test.ts is the
|
|
16
16
|
* drift gate, following the UnknownTopLevelFields pattern.
|
|
17
17
|
*/
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Supported CampaignSpec lineage versions — the v4 JSON Schema enum followed by
|
|
20
|
+
* the v5 one, in that order. A 4.2/4.3 spec is upgraded to v5 on read
|
|
21
|
+
* (checkout-flow.ts), so this rule sees `5.0` for it after normalize(); the v4
|
|
22
|
+
* entries stay so a caller running the rule on an un-normalized spec agrees.
|
|
23
|
+
*/
|
|
24
|
+
export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze(['4.2', '4.3', '5.0']);
|
|
20
25
|
export const SchemaVersion = {
|
|
21
26
|
id: 'SchemaVersion',
|
|
22
27
|
severity: 'error',
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Type surface for the CampaignSpec contract layer.
|
|
3
3
|
*
|
|
4
4
|
* The CampaignSpec is the central authoring contract of this repo (see
|
|
5
|
-
* ../CONTEXT.md).
|
|
6
|
-
* internal shape that rules operate on (after normalize()
|
|
5
|
+
* ../CONTEXT.md). v5.0 is the authoring shape; funnels[] is the canonical
|
|
6
|
+
* internal shape that rules operate on (after normalize(), which upgrades a
|
|
7
|
+
* v4.2/v4.3 spec to the v5 page vocabulary — see checkout-flow.ts).
|
|
7
8
|
*
|
|
8
9
|
* Types here are intentionally permissive on optional fields — the rule
|
|
9
10
|
* registry catches missing/malformed fields, rather than the type system
|
|
@@ -63,7 +64,23 @@ export type PageType = 'presell' | 'landing'
|
|
|
63
64
|
* Not to be confused with the page ID "select", which several family
|
|
64
65
|
* contracts use for this page and which is a separate namespace.
|
|
65
66
|
*/
|
|
66
|
-
| 'select'
|
|
67
|
+
| 'select'
|
|
68
|
+
/**
|
|
69
|
+
* A page with a checkout form that collects details and moves on without
|
|
70
|
+
* placing an order: the information and shipping steps of a split checkout
|
|
71
|
+
* (campaigns-os#641). Routes forward through `next_page`; it takes no
|
|
72
|
+
* payment, so `success_url` and `on_accept` are ignored here as on `select`.
|
|
73
|
+
* Renders as SDK page type `checkout`; its form carries
|
|
74
|
+
* `data-next-checkout-step` and `data-next-step-number`.
|
|
75
|
+
*/
|
|
76
|
+
| 'checkout_step'
|
|
77
|
+
/**
|
|
78
|
+
* The page that takes payment. Exactly one on every path from an entry page
|
|
79
|
+
* to an upsell. Routes forward through `success_url` (or `next_page`) to an
|
|
80
|
+
* upsell, downsell or thank-you page. A campaign may hold several, one per
|
|
81
|
+
* path.
|
|
82
|
+
*/
|
|
83
|
+
| 'checkout' | 'upsell' | 'downsell' | 'thankyou';
|
|
67
84
|
/**
|
|
68
85
|
* Offer condition/benefit blocks, as exported by the Map Builder. Both share
|
|
69
86
|
* the { type, value, description } shape (e.g. condition
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Type surface for the CampaignSpec contract layer.
|
|
3
3
|
*
|
|
4
4
|
* The CampaignSpec is the central authoring contract of this repo (see
|
|
5
|
-
* ../CONTEXT.md).
|
|
6
|
-
* internal shape that rules operate on (after normalize()
|
|
5
|
+
* ../CONTEXT.md). v5.0 is the authoring shape; funnels[] is the canonical
|
|
6
|
+
* internal shape that rules operate on (after normalize(), which upgrades a
|
|
7
|
+
* v4.2/v4.3 spec to the v5 page vocabulary — see checkout-flow.ts).
|
|
7
8
|
*
|
|
8
9
|
* Types here are intentionally permissive on optional fields — the rule
|
|
9
10
|
* registry catches missing/malformed fields, rather than the type system
|
package/compatibility.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@nextcommerce/campaigns-os",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
4
4
|
"status": "developer-preview",
|
|
5
5
|
"contracts": {
|
|
6
|
-
"campaign_spec": "4.2-
|
|
6
|
+
"campaign_spec": "4.2-5.0",
|
|
7
7
|
"build_packet": "campaign-runtime-build-packet/v0",
|
|
8
8
|
"build_context": "campaign-runtime-build-context/v0",
|
|
9
9
|
"assembly_report": "campaign-runtime-assembly-report/v0",
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"schema_version": "campaign-cart-sdk-support-policy/v0",
|
|
3
3
|
"provenance": {
|
|
4
4
|
"source": "NextCommerceCo/campaign-cart release tags",
|
|
5
|
-
"latest_known_release": "0.4.
|
|
6
|
-
"captured_at": "2026-10-
|
|
5
|
+
"latest_known_release": "0.4.42",
|
|
6
|
+
"captured_at": "2026-10-09"
|
|
7
7
|
},
|
|
8
8
|
"source": "contracts/campaign-cart-sdk-support-policy.v0.json",
|
|
9
9
|
"minimum_supported": "0.4.20",
|
|
@@ -848,9 +848,9 @@
|
|
|
848
848
|
}
|
|
849
849
|
},
|
|
850
850
|
"verification": {
|
|
851
|
-
"sdk_version": "0.4.
|
|
852
|
-
"verified_at": "2026-10-
|
|
853
|
-
"evidence": "sdk-0.4.
|
|
851
|
+
"sdk_version": "0.4.42",
|
|
852
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
853
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
854
854
|
"status": "certified",
|
|
855
855
|
"source": "template-verification.json"
|
|
856
856
|
}
|
|
@@ -1272,9 +1272,9 @@
|
|
|
1272
1272
|
}
|
|
1273
1273
|
},
|
|
1274
1274
|
"verification": {
|
|
1275
|
-
"sdk_version": "0.4.
|
|
1276
|
-
"verified_at": "2026-10-
|
|
1277
|
-
"evidence": "sdk-0.4.
|
|
1275
|
+
"sdk_version": "0.4.42",
|
|
1276
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
1277
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
1278
1278
|
"status": "certified",
|
|
1279
1279
|
"source": "template-verification.json"
|
|
1280
1280
|
}
|
|
@@ -1583,9 +1583,9 @@
|
|
|
1583
1583
|
}
|
|
1584
1584
|
},
|
|
1585
1585
|
"verification": {
|
|
1586
|
-
"sdk_version": "0.4.
|
|
1587
|
-
"verified_at": "2026-10-
|
|
1588
|
-
"evidence": "sdk-0.4.
|
|
1586
|
+
"sdk_version": "0.4.42",
|
|
1587
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
1588
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
1589
1589
|
"status": "certified",
|
|
1590
1590
|
"source": "template-verification.json"
|
|
1591
1591
|
}
|
|
@@ -1886,9 +1886,9 @@
|
|
|
1886
1886
|
}
|
|
1887
1887
|
},
|
|
1888
1888
|
"verification": {
|
|
1889
|
-
"sdk_version": "0.4.
|
|
1890
|
-
"verified_at": "2026-10-
|
|
1891
|
-
"evidence": "sdk-0.4.
|
|
1889
|
+
"sdk_version": "0.4.42",
|
|
1890
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
1891
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
1892
1892
|
"status": "certified",
|
|
1893
1893
|
"source": "template-verification.json"
|
|
1894
1894
|
}
|
|
@@ -2227,9 +2227,9 @@
|
|
|
2227
2227
|
}
|
|
2228
2228
|
},
|
|
2229
2229
|
"verification": {
|
|
2230
|
-
"sdk_version": "0.4.
|
|
2231
|
-
"verified_at": "2026-10-
|
|
2232
|
-
"evidence": "sdk-0.4.
|
|
2230
|
+
"sdk_version": "0.4.42",
|
|
2231
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
2232
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
2233
2233
|
"status": "certified",
|
|
2234
2234
|
"source": "template-verification.json"
|
|
2235
2235
|
}
|
|
@@ -2666,9 +2666,9 @@
|
|
|
2666
2666
|
}
|
|
2667
2667
|
},
|
|
2668
2668
|
"verification": {
|
|
2669
|
-
"sdk_version": "0.4.
|
|
2670
|
-
"verified_at": "2026-10-
|
|
2671
|
-
"evidence": "sdk-0.4.
|
|
2669
|
+
"sdk_version": "0.4.42",
|
|
2670
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
2671
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
2672
2672
|
"status": "certified",
|
|
2673
2673
|
"source": "template-verification.json"
|
|
2674
2674
|
}
|
|
@@ -2686,7 +2686,6 @@
|
|
|
2686
2686
|
],
|
|
2687
2687
|
"canonicalSurfaces": {
|
|
2688
2688
|
"selectStep": "mv-selection-step",
|
|
2689
|
-
"checkoutSelector": "mv-selected-bundle-review",
|
|
2690
2689
|
"variantSlots": "bundle-slot-selector",
|
|
2691
2690
|
"personalization": "per-slot picker (data-next-property per slot, toggled by slot_personalization); order-wide default via personalization-field (ships unwired)",
|
|
2692
2691
|
"orderSummary": "cart-summary03|cart-summary04",
|
|
@@ -3023,9 +3022,9 @@
|
|
|
3023
3022
|
}
|
|
3024
3023
|
},
|
|
3025
3024
|
"verification": {
|
|
3026
|
-
"sdk_version": "0.4.
|
|
3027
|
-
"verified_at": "2026-10-
|
|
3028
|
-
"evidence": "sdk-0.4.
|
|
3025
|
+
"sdk_version": "0.4.42",
|
|
3026
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
3027
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
3029
3028
|
"status": "certified",
|
|
3030
3029
|
"source": "template-verification.json"
|
|
3031
3030
|
}
|
|
@@ -3313,9 +3312,9 @@
|
|
|
3313
3312
|
}
|
|
3314
3313
|
},
|
|
3315
3314
|
"verification": {
|
|
3316
|
-
"sdk_version": "0.4.
|
|
3317
|
-
"verified_at": "2026-10-
|
|
3318
|
-
"evidence": "sdk-0.4.
|
|
3315
|
+
"sdk_version": "0.4.42",
|
|
3316
|
+
"verified_at": "2026-10-09T01:35:51Z",
|
|
3317
|
+
"evidence": "sdk-0.4.42-2026-10-09",
|
|
3319
3318
|
"status": "certified",
|
|
3320
3319
|
"source": "template-verification.json"
|
|
3321
3320
|
}
|
|
@@ -3504,6 +3503,6 @@
|
|
|
3504
3503
|
}
|
|
3505
3504
|
},
|
|
3506
3505
|
"_synced_from_repo": "NextCommerceCo/campaign-cart-starter-templates",
|
|
3507
|
-
"_synced_from_ref": "
|
|
3508
|
-
"_synced_from_sha": "
|
|
3506
|
+
"_synced_from_ref": "main",
|
|
3507
|
+
"_synced_from_sha": "d5ff501ab57aef160a7108341de8a952006a28a4"
|
|
3509
3508
|
}
|