@nextcommerce/campaigns-os 1.55.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 +64 -0
- package/CONTEXT.md +11 -0
- 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 +705 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +8 -4
- package/docs/build-packet.md +52 -8
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +78 -18
- 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 +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -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/cli-helpers.mjs +129 -0
- package/src/cli.mjs +121 -53
- 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 +196 -13
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +31 -11
- 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 +3 -3
- package/src/qa-browser.mjs +226 -34
- package/src/qa-cart-entry.mjs +78 -14
- package/src/qa-node.mjs +35 -33
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
Notable supported-surface changes are recorded here.
|
|
4
4
|
|
|
5
|
+
## [1.57.0] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Publishes the untagged `1.56.0` changes: CampaignSpec 5.0, checkout steps, and the updated build and QA contracts.
|
|
10
|
+
- Publishes `1.56.0+agent.1`: `--version` and `-v`, and the Git ignore entry for runtime progress records.
|
|
11
|
+
- Publishes `1.56.0+agent.2`: hosted all-template previews carry missing Polish and page-load evidence forward as warnings.
|
|
12
|
+
- Publishes `1.56.0+agent.3`: doctor and QA find an entry page at Page Kit's emitted route when the declared root is absent.
|
|
13
|
+
- Publishes `1.56.0+agent.5`: doctor warns when a variant and quantity checkout does not fit the selected template family.
|
|
14
|
+
- Publishes `1.56.0+agent.6`: committed handoff artifacts retain repository-relative paths.
|
|
15
|
+
|
|
16
|
+
## [1.56.0+agent.6] - 2026-10-08
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
Committed Build Packet and `.campaign-runtime/` handoff artifacts now retain filesystem paths in relative form after `doctor --write`, `next`, and stage updates. Packet input fields resolve from the packet file's directory, output paths from the target repository, source page paths from the source root, package references from their context or report file, and normalized brief input paths from the brief file. Other committed sidecar paths and stored commands resolve from the target repository root. Outside inputs use ordinary relative paths from those bases, including source manifest paths in decision evidence. Machine-local ignored run state keeps its existing path behavior. `doctor --strip-paths` remains available for older packets. QA verdict discovery resolves recorded outputs from the target root.
|
|
21
|
+
|
|
22
|
+
## [1.56.0+agent.5] - 2026-10-08
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
Doctor warns once per checkout path when selectable packages form a variant and quantity matrix but the selected certified template family declares one main package and no configurable variant-slot checkout surface. The matrix requires at least two variants, with at least one offered at multiple quantities. Variant identity comes from attributes shared by every selectable row carrying a quantity, or from variant names shared by those rows; rows with missing or blank quantities are ignored when detecting the matrix. SKUs, order bumps, upsells, pack-size rows, and quantity tiers of one variant do not trigger the warning. Paths follow CampaignSpec route targets, including `.html` and `page_url` routes. If any checkout path has a `select` or `checkout_step` page, every warning ranks multi-step families first. Each warning names the matching certified variant-slot families in ranked order, preferring the selected family's base. It says the family is chosen at intake with `--template-family`; after stage evidence is recorded, it explains that changing the family requires destructive intake with `--force`. The warning does not block intake or change the selected family.
|
|
27
|
+
|
|
28
|
+
## [1.56.0+agent.3] - 2026-10-08
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
Doctor recognizes an entry page declared at the campaign root when Page Kit emitted its recorded page file at a different route and the root output is absent. Root declarations include `page_url` empty, `/`, `/<slug>/`, and `<slug>`. A template-stock entry counts as built instead of "not built yet"; one warning names the unserved root, the emitted route, and the concrete redirect or `permalink: /<slug>/` repair. The warning uses the public route (`/` or `/<slug>/`), while built-page and previewable routes retain their relative Page Kit format. `qa resolve` prints the locally emitted entry URL under its existing slug-prefixed `base_url`; `base_url` and non-entry page URLs keep their previous resolution, including a `--base-url` subpath. An explicit non-root permalink on the entry source wins over a root output or stale file-route output for QA, and doctor warns if the root output is absent. When files named for both the page ID and page type exist, doctor and QA prefer the page ID file's permalink or built file route, then the page type file's. Without a permalink, a present root output keeps the entry at `base_url` and suppresses the warning. Neither doctor nor QA substitutes an unrelated built page.
|
|
33
|
+
|
|
34
|
+
## [1.56.0+agent.2] - 2026-10-08
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
When every mapped page is template stock, a non-loopback hosted preview at `deploy.preview_url` carries missing Polish and hidden eager-media page-load evidence forward as warnings if its origin or campaign route path differs from `deploy.production_url` after QA composes the campaign slug onto each URL; a query or fragment alone does not qualify. Doctor and `next` move past Polish, and QA records warning rows with a verdict no better than `ready_with_exceptions`. QA against the production campaign page and hosted campaigns with a capturable design route retain strict evidence gates. If the preview URL is missing or resolves to the production campaign page, `next` names `qa policy set --packet <packet> --preview-url <url>` even when a production URL is set. Once recorded, `next` names browser and typed-card QA with `qa run --packet <packet> --base-url <preview-url> --browser --test-order common`. The hosted preview satisfies the deploy handoff without a deploy record; after a current-build QA verdict, `next` reaches closeout. `record deploy` remains local-only. The Polish prompt and actions do not suggest a capture when no design route exists. Bundled skills advance to `1.56.0+skills.2`.
|
|
39
|
+
|
|
40
|
+
## [1.56.0+agent.1] - 2026-10-08
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `campaigns-os --version` and `campaigns-os -v` print only the installed package version, with no campaign runtime writes (campaigns-os#644).
|
|
45
|
+
- Campaign runtime progress snapshots and their remit records are ignored by Git. A target with an older managed `.gitignore` block gains `.campaign-runtime/progress/` on its next `start`, `prepare-build`, `install-agent-context` or `run start`. A rule does not untrack files already committed; remove those with `git rm -r --cached .campaign-runtime/progress/` (campaigns-os#645).
|
|
46
|
+
|
|
47
|
+
## [1.56.0] - 2026-10-08
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
CampaignSpec 5.0 (`schemas/campaign-spec.v5.schema.json`, also a package export) makes `checkout` mean the page that takes payment and adds the `checkout_step` page type: a page with a checkout form that collects details and moves on through `next_page` without placing an order. `select` stays the bundle picker before checkout. All three render as SDK page type `checkout`. A 4.2 or 4.3 spec, including a Map saved before this release, still validates against the v4 schema and is upgraded when read: each `checkout` page whose resolved forward target is another `checkout` becomes `checkout_step`. The input is never rewritten, so spec hashes still cover the bytes as written. A campaign may hold several Checkouts, one per split-test path (campaigns-os#641).
|
|
52
|
+
|
|
53
|
+
Four new spec rules run in doctor through `spec.validation`. Each has a fixture under `campaign-spec/fixtures/`, and none of them reads page order or page names:
|
|
54
|
+
|
|
55
|
+
- `CheckoutForwardTarget` (error): a Checkout's resolved forward target is not an upsell, downsell or thank-you page.
|
|
56
|
+
- `PrePaymentForwardTarget` (error): a `select` or `checkout_step` page forwards straight to an upsell, downsell or thank-you page.
|
|
57
|
+
- `OneCheckoutPerPath` (error): a path from an entry page reaches an upsell or downsell without passing exactly one Checkout. In partial build scope, the case with no Checkout is a warning.
|
|
58
|
+
- `CheckoutStepReachesCheckout` (error): a `checkout_step` page is not on a path to a Checkout.
|
|
59
|
+
|
|
60
|
+
`RouteFieldIgnoredForPageType` now also warns about a `success_url` or `on_accept` on a `checkout_step`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Build: `prepare-build` projects `checkout_step` pages to Page Kit `page_type: checkout`, with the default route `checkout-step/`. Page Kit frontmatter in the Build Packet and Build Context gains two keys. `success_url` goes on every page that feeds a Checkout and holds that Checkout's post-payment destination, for the `next-success-url` meta tag the SDK reads for express orders. `step_number` goes on each `checkout_step` and is its position on its path, for the step form's `data-next-step-number`. The build prompt and the `next-campaigns-build` skill now describe the select-page handoff the templates use: the swap-mode bundle selector writes the cart, the SDK persists it, and a checkout button or link moves on. `forcePackageId` is only for a page with no selector. Step forms carry `data-next-checkout-step` and `data-next-step-number`.
|
|
65
|
+
- QA: test orders drive the Checkout on the tested path instead of the first page typed `checkout`. On a multi-step path the runner enters the cart on the select page or the first step. It submits each `checkout_step` form and checks the page lands on the step's declared next page, recording one `checkout_step_submitted` ladder rung per step. The order is submitted only on the Checkout. On a page with bundle cards, the cards plus a checkout button now count as a cart-entry control (`control_kind: checkout_button`), and `--select-package` clicks the matching `data-next-bundle-card`. `tiers` plans read tiers and bumps from every page of each Checkout's path. The legacy API test order takes its success URL from the Checkout. The theme gate covers `select` and `checkout_step` pages.
|
|
66
|
+
- Doctor and the Build Brief treat exit intent, promo-code input and order bumps on `select` and `checkout_step` pages like those on a Checkout. The payment-method residue scan also reads built `select` and `checkout_step` pages, where express buttons render. The commercial journey summary prices the cart on the checkout-flow page that carries it.
|
|
67
|
+
- The runtime recipe enumerates the five new `campaign-spec` sources (revision `1.0.3`).
|
|
68
|
+
|
|
5
69
|
## [1.55.0] - 2026-10-07
|
|
6
70
|
|
|
7
71
|
### Changed
|
package/CONTEXT.md
CHANGED
|
@@ -37,6 +37,17 @@ applied brand tokens, and pricing-surface modes. QA reads it to fail "still
|
|
|
37
37
|
visually the starter template" deterministically.
|
|
38
38
|
_Avoid_: style guide, design tokens doc, theme report
|
|
39
39
|
|
|
40
|
+
**Checkout (payment page)**:
|
|
41
|
+
The page on a checkout path that takes payment: CampaignSpec `type: "checkout"`
|
|
42
|
+
(campaigns-os#641). It forwards to an upsell, downsell or thank-you page, and
|
|
43
|
+
every path from an entry page to an upsell passes exactly one. Pages ahead of
|
|
44
|
+
it render the same SDK checkout surface without placing an order: a `select`
|
|
45
|
+
page (the bundle picker) and `checkout_step` pages (a split checkout's
|
|
46
|
+
information and shipping forms). A campaign may hold several Checkouts, one
|
|
47
|
+
per split-test path; every consumer asks for "the Checkout on this path"
|
|
48
|
+
(`campaign-spec/checkout-flow.ts`), never for the first page typed checkout.
|
|
49
|
+
_Avoid_: first checkout, checkout page (for a step), final step
|
|
50
|
+
|
|
40
51
|
**Template Reference**:
|
|
41
52
|
The canonical baseline for a template family: its safe runtime structure,
|
|
42
53
|
expected screenshots or render references, default assets, starter residue
|
package/campaign-spec/README.md
CHANGED
|
@@ -16,7 +16,9 @@ rules are pure TypeScript over a normalized spec with no heavy dependencies.
|
|
|
16
16
|
campaign-spec/
|
|
17
17
|
index.ts # public interface
|
|
18
18
|
types.ts # CampaignSpec, Rule, Violation, Tag, Severity
|
|
19
|
-
normalize.ts #
|
|
19
|
+
normalize.ts # authoring → canonical funnels[] shape; upgrades v4.2/v4.3 to v5
|
|
20
|
+
checkout-flow.ts # which page takes payment; v4 → v5 upgrade; path helpers
|
|
21
|
+
routing.ts # a page's outgoing edges
|
|
20
22
|
rules/
|
|
21
23
|
index.ts # preset RuleSet constants
|
|
22
24
|
cycle-detection.ts # one file per rule
|
|
@@ -131,6 +133,18 @@ IIFE) and expose the public interface on `window` to run export-time validation
|
|
|
131
133
|
client-side. The rules have no Node-only or live-data dependencies, so the same
|
|
132
134
|
registry that backs the CLI doctor runs unchanged in the browser.
|
|
133
135
|
|
|
136
|
+
## CampaignSpec 5.0: the page that takes payment
|
|
137
|
+
|
|
138
|
+
`checkout` is the page that takes payment; `checkout_step` is a checkout form
|
|
139
|
+
that collects details and moves on through `next_page`; `select` is the bundle
|
|
140
|
+
picker before them. `normalize()` upgrades a 4.2/4.3 spec to that vocabulary
|
|
141
|
+
on read (each `checkout` whose resolved forward target is another `checkout`
|
|
142
|
+
becomes `checkout_step`) and never mutates its input. Four rules hold the
|
|
143
|
+
shape — `CheckoutForwardTarget`, `PrePaymentForwardTarget`,
|
|
144
|
+
`CheckoutStepReachesCheckout` and `OneCheckoutPerPath` — and none of them
|
|
145
|
+
reads page order or page names. `paymentPageFrom`, `checkoutPathFrom` and
|
|
146
|
+
`paymentPagesForFunnel` answer "which Checkout is on this path".
|
|
147
|
+
|
|
134
148
|
## See also
|
|
135
149
|
|
|
136
150
|
- `../CONTEXT.md` — domain vocabulary.
|
|
@@ -0,0 +1,90 @@
|
|
|
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 type { Funnel, Page } from './types.ts';
|
|
22
|
+
/** The CampaignSpec lineage this module writes when it upgrades a v4 spec. */
|
|
23
|
+
export declare const CURRENT_SCHEMA_VERSION: "5.0";
|
|
24
|
+
/** The page that takes payment. */
|
|
25
|
+
export declare const PAYMENT_PAGE_TYPE: "checkout";
|
|
26
|
+
/** A checkout form that collects details and moves on without placing an order. */
|
|
27
|
+
export declare const CHECKOUT_STEP_PAGE_TYPE: "checkout_step";
|
|
28
|
+
/** Pages that lead into a Checkout without taking payment themselves. */
|
|
29
|
+
export declare const PRE_PAYMENT_PAGE_TYPES: readonly ["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 declare const CHECKOUT_FLOW_PAGE_TYPES: readonly ["select", "checkout_step", "checkout"];
|
|
36
|
+
/** Where a Checkout may send the shopper once payment succeeds. */
|
|
37
|
+
export declare const POST_PAYMENT_PAGE_TYPES: readonly ["upsell", "downsell", "thankyou"];
|
|
38
|
+
/** Post-purchase offer pages: reaching one means an order was already placed. */
|
|
39
|
+
export declare const POST_PURCHASE_OFFER_PAGE_TYPES: readonly ["upsell", "downsell"];
|
|
40
|
+
export declare function isPaymentPage(page: Page | null | undefined): boolean;
|
|
41
|
+
export declare function isCheckoutStepPage(page: Page | null | undefined): boolean;
|
|
42
|
+
export declare function isPrePaymentPage(page: Page | null | undefined): boolean;
|
|
43
|
+
export declare function isCheckoutFlowPage(page: Page | null | undefined): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The page a route target names within `pages`, or null when it names none
|
|
46
|
+
* (an absolute URL, a fragment, a route outside this funnel, or a typo that
|
|
47
|
+
* RouteTargetResolves reports). Page IDs win over `page_url` matches.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveRouteTargetPage(pages: readonly Page[], target: string | null | undefined): Page | null;
|
|
50
|
+
/** The page this page's forward link resolves to, within `pages`. */
|
|
51
|
+
export declare function forwardTargetPage(pages: readonly Page[], page: Page | null | undefined): Page | null;
|
|
52
|
+
/**
|
|
53
|
+
* The pages from `start` forward to the Checkout that takes payment, both
|
|
54
|
+
* included, following each page's forward link. Null when the walk leaves the
|
|
55
|
+
* funnel, loops, ends, or meets a post-payment page before any Checkout.
|
|
56
|
+
*
|
|
57
|
+
* `start` may itself be the Checkout (the path is then that page alone).
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkoutPathFrom(pages: readonly Page[], start: Page | null | undefined): Page[] | null;
|
|
60
|
+
/** The Checkout reached by walking forward from `start`, or null. */
|
|
61
|
+
export declare function paymentPageFrom(pages: readonly Page[], start: Page | null | undefined): Page | null;
|
|
62
|
+
/**
|
|
63
|
+
* The funnel's entry pages: those flagged `is_entry`, or when none is flagged,
|
|
64
|
+
* those no other page routes to apart from orphaned upsell, downsell and
|
|
65
|
+
* thank-you pages (falling back to the lowest `order` when that leaves none). Order and names are never consulted beyond that
|
|
66
|
+
* last-resort tie-break.
|
|
67
|
+
*/
|
|
68
|
+
export declare function entryPages(funnel: Funnel | null | undefined): Page[];
|
|
69
|
+
/**
|
|
70
|
+
* The Checkout on the path through `page`'s funnel that starts at an entry
|
|
71
|
+
* page, for callers that hold a funnel rather than a starting page. When the
|
|
72
|
+
* funnel has several entry paths with different Checkouts, returns the first
|
|
73
|
+
* entry's; use `paymentPagesForFunnel` to see all of them.
|
|
74
|
+
*/
|
|
75
|
+
export declare function paymentPageForFunnel(funnel: Funnel | null | undefined): Page | null;
|
|
76
|
+
/** Every distinct Checkout reached from the funnel's entry pages, in entry order. */
|
|
77
|
+
export declare function paymentPagesForFunnel(funnel: Funnel | null | undefined): Page[];
|
|
78
|
+
/**
|
|
79
|
+
* Upgrade a CampaignSpec to the v5 page vocabulary. Returns a new object; the
|
|
80
|
+
* input is never mutated, so callers that hash or persist the raw bytes keep
|
|
81
|
+
* the bytes they read.
|
|
82
|
+
*
|
|
83
|
+
* v5 specs pass through unchanged. For any other lineage, each `checkout` page
|
|
84
|
+
* whose resolved forward target is also a `checkout` is retyped
|
|
85
|
+
* `checkout_step` — the v4 `checkout → checkout` chain was the only way to say
|
|
86
|
+
* "this form moves on without taking payment". A supported v4 `schema_version`
|
|
87
|
+
* becomes `5.0`; a missing or unsupported one is left for SchemaVersion to
|
|
88
|
+
* report.
|
|
89
|
+
*/
|
|
90
|
+
export declare function upgradeCampaignSpec<T>(input: T): T;
|
|
@@ -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;
|