@12-apps/payments-frontend 3.22.0 → 3.23.1
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/package.json +2 -2
- package/src/components/checkout/checkout-actions.ts +55 -9
- package/src/components/checkout/checkout-flow.tsx +15 -7
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/en-US.ts +8 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-store.ts +23 -1
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/pt-BR.ts +9 -0
- package/src/components/checkout/resolution-actions.ts +43 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/use-card-checkout.ts +3 -2
- package/src/components/checkout/use-checkout-controller.ts +23 -16
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +38 -0
- package/src/components/checkout/wallet-pane.tsx +6 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +12 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +37 -4
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +7 -19
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ENGINE'S OWN CHROME (FUT-1240) — the two things that are not a step.
|
|
3
|
+
*
|
|
4
|
+
* A back control and a progress header, at the flat flow's own TEST IDS:
|
|
5
|
+
* `checkout-back` and `checkout-stepper` are the hooks the storefront journeys
|
|
6
|
+
* click, and a walk that renders different ones would be a rewrite wearing a
|
|
7
|
+
* refactor's clothes.
|
|
8
|
+
*
|
|
9
|
+
* ## What is NOT the flat flow's, and deliberately
|
|
10
|
+
*
|
|
11
|
+
* The flat `ProgressHeader` renders three FIXED nodes — Dados, Pagamento,
|
|
12
|
+
* Confirmação — for every shopper. Here the nodes are DERIVED like everything
|
|
13
|
+
* else: the applying steps that named a label, in walk order. So a shopper with
|
|
14
|
+
* a CPF on file, whose Dados step is not part of their walk at all (FUT-465),
|
|
15
|
+
* sees the steps they will actually be asked for rather than one they will
|
|
16
|
+
* never be shown. The same derivation is what lets a host's registered step
|
|
17
|
+
* appear in the stepper without the header knowing it exists.
|
|
18
|
+
*
|
|
19
|
+
* A step with no label is an interstitial — the hand-off and the resume are not
|
|
20
|
+
* places a shopper is asked to be — so those draw no node either.
|
|
21
|
+
*
|
|
22
|
+
* The back link's wording follows the same derivation: it offers "keep
|
|
23
|
+
* shopping" on the FIRST applying step, where back leaves for the catalog,
|
|
24
|
+
* rather than on the id `dados` specifically.
|
|
25
|
+
*/
|
|
26
|
+
import { Box } from "@mui/material";
|
|
27
|
+
import type { JSX } from "react";
|
|
28
|
+
|
|
29
|
+
import { ArrowBackIcon } from "../../components/checkout/icons";
|
|
30
|
+
import { useCheckoutComponents } from "../../components/checkout/ui";
|
|
31
|
+
import type { CheckoutViewCopy } from "../../components/checkout/view-copy";
|
|
32
|
+
|
|
33
|
+
import type { AnyCheckoutStep } from "./types";
|
|
34
|
+
|
|
35
|
+
/** The stepper nodes: applying steps that named a label, already worded. */
|
|
36
|
+
function stepperNodes(
|
|
37
|
+
applying: readonly AnyCheckoutStep[],
|
|
38
|
+
copy: CheckoutViewCopy,
|
|
39
|
+
): { id: string; label: string }[] {
|
|
40
|
+
const nodes: { id: string; label: string }[] = [];
|
|
41
|
+
for (const step of applying) {
|
|
42
|
+
const label = step.label;
|
|
43
|
+
if (typeof label !== "string") continue;
|
|
44
|
+
nodes.push({ id: step.id, label: copy.steps[label] });
|
|
45
|
+
}
|
|
46
|
+
return nodes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The slim header: the walk's only nav, and it is step-aware. */
|
|
50
|
+
export function EngineChrome({
|
|
51
|
+
copy,
|
|
52
|
+
applying,
|
|
53
|
+
currentId,
|
|
54
|
+
first,
|
|
55
|
+
onBack,
|
|
56
|
+
}: {
|
|
57
|
+
copy: CheckoutViewCopy;
|
|
58
|
+
applying: readonly AnyCheckoutStep[];
|
|
59
|
+
currentId: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* The shopper is on the first applying step, which is where the link is
|
|
62
|
+
* worded "continuar comprando" rather than "voltar".
|
|
63
|
+
*
|
|
64
|
+
* It is about the WORDING only. Where back actually goes is `deriveNav`'s,
|
|
65
|
+
* and it leaves for the catalog from a settled confirmation too — where the
|
|
66
|
+
* flat flow also says "voltar" and also goes to the menu.
|
|
67
|
+
*/
|
|
68
|
+
first: boolean;
|
|
69
|
+
onBack(): void;
|
|
70
|
+
}): JSX.Element {
|
|
71
|
+
const { Button, Stepper } = useCheckoutComponents();
|
|
72
|
+
const nodes = stepperNodes(applying, copy);
|
|
73
|
+
const at = nodes.findIndex((node) => node.id === currentId);
|
|
74
|
+
const completed = new Set(nodes.slice(0, Math.max(at, 0)).map((node) => node.id));
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<Box sx={{ minHeight: 36, display: "flex", alignItems: "center", gap: 1 }}>
|
|
78
|
+
<Button
|
|
79
|
+
variant="text"
|
|
80
|
+
color="neutral"
|
|
81
|
+
size="sm"
|
|
82
|
+
icon={<ArrowBackIcon fontSize="small" />}
|
|
83
|
+
iconPosition="left"
|
|
84
|
+
onClick={onBack}
|
|
85
|
+
dataTestId="checkout-back"
|
|
86
|
+
>
|
|
87
|
+
{first ? copy.dados.keepShopping : copy.dados.back}
|
|
88
|
+
</Button>
|
|
89
|
+
</Box>
|
|
90
|
+
{nodes.length === 0 ? null : (
|
|
91
|
+
<Box sx={{ height: 50, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
|
92
|
+
<Stepper
|
|
93
|
+
steps={nodes}
|
|
94
|
+
activeId={currentId ?? ""}
|
|
95
|
+
completed={completed}
|
|
96
|
+
orientation="horizontal"
|
|
97
|
+
size="sm"
|
|
98
|
+
data-testid="checkout-stepper"
|
|
99
|
+
/>
|
|
100
|
+
</Box>
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Nothing to show yet — a gate still deciding, the store's protocol still in
|
|
108
|
+
* flight, or a walk with no applying step.
|
|
109
|
+
*
|
|
110
|
+
* It exists because the alternative is a blank frame, and a shopper who taps
|
|
111
|
+
* "pagar" and gets an empty page taps again.
|
|
112
|
+
*/
|
|
113
|
+
export function EngineLoading({ copy }: { copy: CheckoutViewCopy }): JSX.Element {
|
|
114
|
+
const { LoadingState } = useCheckoutComponents();
|
|
115
|
+
return (
|
|
116
|
+
<LoadingState
|
|
117
|
+
variant="spinner"
|
|
118
|
+
size="md"
|
|
119
|
+
message={copy.pipeline.loading}
|
|
120
|
+
dataTestId="checkout-pipeline-loading"
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ENGINE'S OWN STATE, and the one writer of each fact (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* Everything here is state a STEP reads and only the engine writes. Which step
|
|
5
|
+
* the shopper is on is deliberately NOT among them — that is derived (see
|
|
6
|
+
* `derive-step.ts`), which is the whole point of the pipeline.
|
|
7
|
+
*
|
|
8
|
+
* `slices` lives here rather than inside each step because the engine owns the
|
|
9
|
+
* storage: one `useState` for every step's scrap, so the number of hooks does
|
|
10
|
+
* not depend on how many steps a host registered.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useMemo, useState } from "react";
|
|
13
|
+
|
|
14
|
+
import type { CheckoutDecline } from "../../components/checkout/decline";
|
|
15
|
+
import type {
|
|
16
|
+
BuyerInfo,
|
|
17
|
+
CheckoutError,
|
|
18
|
+
CheckoutOrder,
|
|
19
|
+
OrderStatus,
|
|
20
|
+
} from "../../components/checkout/types";
|
|
21
|
+
|
|
22
|
+
import { initialSlices, writeSlice } from "./slices";
|
|
23
|
+
import type { AnyCheckoutStep, CheckoutContext } from "./types";
|
|
24
|
+
|
|
25
|
+
/** What one visit accumulates. */
|
|
26
|
+
export interface EngineState {
|
|
27
|
+
buyer: BuyerInfo;
|
|
28
|
+
/**
|
|
29
|
+
* The shopper has edited the buyer form, so the host's saved defaults must
|
|
30
|
+
* stop overwriting it. Until they have, the defaults are adopted live —
|
|
31
|
+
* a profile that arrives after mount still prefills the form it is for.
|
|
32
|
+
*/
|
|
33
|
+
buyerTouched: boolean;
|
|
34
|
+
saveProfile: boolean;
|
|
35
|
+
order: CheckoutOrder | null;
|
|
36
|
+
outcome: OrderStatus | null;
|
|
37
|
+
decline: CheckoutDecline | null;
|
|
38
|
+
/** The refusal the engine holds, routed to whoever claimed its code. */
|
|
39
|
+
error: CheckoutError | null;
|
|
40
|
+
placing: boolean;
|
|
41
|
+
slices: Record<string, unknown>;
|
|
42
|
+
/** A step the shopper pressed BACK into; it wins while it still applies. */
|
|
43
|
+
reopened: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The state, plus the only two ways to change it. */
|
|
47
|
+
export interface EngineStore {
|
|
48
|
+
state: EngineState;
|
|
49
|
+
patch(next: Partial<EngineState>): void;
|
|
50
|
+
setSlice(stepId: string, value: unknown): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Everything at rest, with each step's slice rehydrated or freshly built. */
|
|
54
|
+
function freshState(
|
|
55
|
+
steps: readonly AnyCheckoutStep[],
|
|
56
|
+
ctx: CheckoutContext,
|
|
57
|
+
): EngineState {
|
|
58
|
+
return {
|
|
59
|
+
buyer: ctx.buyer,
|
|
60
|
+
buyerTouched: false,
|
|
61
|
+
// The "salvar meus dados" consent starts checked, exactly as the flat
|
|
62
|
+
// controller's does — the checkbox is the shopper's way to say otherwise.
|
|
63
|
+
saveProfile: true,
|
|
64
|
+
order: null,
|
|
65
|
+
outcome: null,
|
|
66
|
+
decline: null,
|
|
67
|
+
error: null,
|
|
68
|
+
placing: false,
|
|
69
|
+
slices: initialSlices(steps, ctx),
|
|
70
|
+
reopened: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The engine's store.
|
|
76
|
+
*
|
|
77
|
+
* `steps` and the initial `ctx` are read ONCE, in the lazy initializer: the
|
|
78
|
+
* slices a step declared are seeded from storage at mount, and re-seeding them
|
|
79
|
+
* on a later render would undo whatever the shopper has since done.
|
|
80
|
+
*/
|
|
81
|
+
export function useEngineStore(
|
|
82
|
+
steps: readonly AnyCheckoutStep[],
|
|
83
|
+
ctx: CheckoutContext,
|
|
84
|
+
): EngineStore {
|
|
85
|
+
const [state, setState] = useState<EngineState>(() => freshState(steps, ctx));
|
|
86
|
+
const tenantSlug = ctx.tenantSlug;
|
|
87
|
+
const patch = useCallback((next: Partial<EngineState>) => {
|
|
88
|
+
setState((current) => ({ ...current, ...next }));
|
|
89
|
+
}, []);
|
|
90
|
+
const setSlice = useCallback(
|
|
91
|
+
(stepId: string, value: unknown) => {
|
|
92
|
+
const step = steps.find((entry) => entry.id === stepId);
|
|
93
|
+
if (step) writeSlice(step, tenantSlug, value);
|
|
94
|
+
setState((current) => ({ ...current, slices: { ...current.slices, [stepId]: value } }));
|
|
95
|
+
},
|
|
96
|
+
[steps, tenantSlug],
|
|
97
|
+
);
|
|
98
|
+
return useMemo(() => ({ state, patch, setSlice }), [state, patch, setSlice]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The buyer the walk should show: the shopper's own edits once they have made
|
|
103
|
+
* any, and the host's saved defaults until then.
|
|
104
|
+
*/
|
|
105
|
+
export function effectiveBuyer(state: EngineState, defaults: BuyerInfo): BuyerInfo {
|
|
106
|
+
return state.buyerTouched ? state.buyer : defaults;
|
|
107
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ENGINE (FUT-1240) — a checkout whose current step is DERIVED rather than
|
|
3
|
+
* remembered.
|
|
4
|
+
*
|
|
5
|
+
* It owns four things and nothing else: the order the plugins run in, the
|
|
6
|
+
* state a step reads, the walk (`derive-step.ts`), and the one rule no lane is
|
|
7
|
+
* allowed to restate — a settlement method that raises no charge mounts no
|
|
8
|
+
* payment surface. Every pixel below belongs to a step, a gate or a registered
|
|
9
|
+
* method.
|
|
10
|
+
*
|
|
11
|
+
* ## Hook order is a property of the ARRAYS
|
|
12
|
+
*
|
|
13
|
+
* Every plugin's `useFacts()` runs here, on every render, in array order —
|
|
14
|
+
* which is what lets a plugin read the host's own hooks at all, and why the
|
|
15
|
+
* arrays must be identity-stable. The `map`s below are hook calls in a loop on
|
|
16
|
+
* purpose; `stable-plugins.ts` is what makes the loop's length a constant, and
|
|
17
|
+
* it says so loudly in a development build when a host makes it one.
|
|
18
|
+
*/
|
|
19
|
+
import { Box } from "@mui/material";
|
|
20
|
+
import { useEffect, useMemo, useState, type JSX, type ReactNode } from "react";
|
|
21
|
+
|
|
22
|
+
import { forgetHostedOrder } from "../../components/checkout/hosted-return";
|
|
23
|
+
import type { SettlementCheckout } from "../../components/checkout/types";
|
|
24
|
+
import type { CheckoutViewCopy } from "../../components/checkout/view-copy";
|
|
25
|
+
import { FlowsProvider, type FlowsRuntime } from "../runtime";
|
|
26
|
+
import { storeCannotCharge } from "../screens-pay";
|
|
27
|
+
import type { CheckoutScreens, PaymentFlows } from "../types";
|
|
28
|
+
|
|
29
|
+
import { PipelineActionsProvider, type PipelineActions } from "./actions";
|
|
30
|
+
import { decideAdmission, resumePending } from "./admission";
|
|
31
|
+
import { useCheckoutBase, withCheckoutState } from "./context";
|
|
32
|
+
import { deriveNav, deriveStep, raisesCharge, sliceFor } from "./derive-step";
|
|
33
|
+
import { useEngineWriters, type PipelineWiring } from "./engine-actions";
|
|
34
|
+
import { EngineChrome, EngineLoading } from "./engine-chrome";
|
|
35
|
+
import { effectiveBuyer, useEngineStore } from "./engine-state";
|
|
36
|
+
import { PACKAGE_METHODS } from "./methods";
|
|
37
|
+
import { errorForStep, refusalOwner, refusalStepOverride } from "./refusal-routing";
|
|
38
|
+
import { clearSlices } from "./slices";
|
|
39
|
+
import { useStablePluginArray } from "./stable-plugins";
|
|
40
|
+
import { methodSliceOf, packageSteps } from "./steps";
|
|
41
|
+
import type { AnyCheckoutGate, CheckoutContext, GateVerdict } from "./types";
|
|
42
|
+
|
|
43
|
+
/** A host that registered no gates admits everyone. Frozen: this array IS hook order. */
|
|
44
|
+
const NO_GATES: readonly AnyCheckoutGate[] = Object.freeze([]);
|
|
45
|
+
|
|
46
|
+
/** One plugin list's facts, in array order. */
|
|
47
|
+
function usePluginFacts(plugins: readonly { useFacts?(): unknown }[]): readonly unknown[] {
|
|
48
|
+
return plugins.map((plugin) => plugin.useFacts?.());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The same facts, addressed by id — how the walk reads them. */
|
|
52
|
+
function byId(
|
|
53
|
+
plugins: readonly { id: string }[],
|
|
54
|
+
facts: readonly unknown[],
|
|
55
|
+
): Record<string, unknown> {
|
|
56
|
+
const table: Record<string, unknown> = {};
|
|
57
|
+
plugins.forEach((plugin, at) => {
|
|
58
|
+
table[plugin.id] = facts[at];
|
|
59
|
+
});
|
|
60
|
+
return table;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The end of a checkout: let the parked entry and the parked slices go.
|
|
65
|
+
*
|
|
66
|
+
* Storage only — the React slices stay for the rest of this visit, so the
|
|
67
|
+
* confirmation still knows which method it is confirming. What must not
|
|
68
|
+
* survive is the NEXT mount finding a finished walk parked and skipping the
|
|
69
|
+
* steps a new purchase needs.
|
|
70
|
+
*/
|
|
71
|
+
function useSettled(input: {
|
|
72
|
+
ctx: CheckoutContext;
|
|
73
|
+
wiring: PipelineWiring;
|
|
74
|
+
runtime: FlowsRuntime;
|
|
75
|
+
}): void {
|
|
76
|
+
const { ctx, wiring, runtime } = input;
|
|
77
|
+
const { outcome, order, tenantSlug } = ctx;
|
|
78
|
+
// AWAITING_PAYMENT is TERMINAL for a settlement that raises no charge:
|
|
79
|
+
// nothing is coming to confirm, because placing the order was the whole of it.
|
|
80
|
+
const charges = raisesCharge(ctx.method, wiring.methods);
|
|
81
|
+
const settled = outcome !== null && (outcome !== "AWAITING_PAYMENT" || !charges);
|
|
82
|
+
const onSettled = runtime.config.onSettled;
|
|
83
|
+
const onPaid = runtime.config.ports.onPaid;
|
|
84
|
+
const steps = wiring.steps;
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!settled || outcome === null) return;
|
|
87
|
+
forgetHostedOrder(tenantSlug);
|
|
88
|
+
clearSlices(steps, tenantSlug);
|
|
89
|
+
// PAID only: a FAILED or EXPIRED shopper still has a basket to retry with,
|
|
90
|
+
// and the host must not be told otherwise.
|
|
91
|
+
if (outcome === "PAID") onPaid?.();
|
|
92
|
+
if (order) onSettled?.(outcome, order);
|
|
93
|
+
}, [settled, outcome, order, tenantSlug, steps, onPaid, onSettled]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The walk, once admission has passed. Split out for the size gate. */
|
|
97
|
+
function EngineWalk(props: {
|
|
98
|
+
runtime: FlowsRuntime;
|
|
99
|
+
screens: CheckoutScreens;
|
|
100
|
+
wiring: PipelineWiring;
|
|
101
|
+
ctx: CheckoutContext;
|
|
102
|
+
store: ReturnType<typeof useEngineStore>;
|
|
103
|
+
stepFacts: Readonly<Record<string, unknown>>;
|
|
104
|
+
offered: PipelineActions["offered"];
|
|
105
|
+
}): ReactNode {
|
|
106
|
+
const { runtime, screens, wiring, ctx, store, stepFacts, offered } = props;
|
|
107
|
+
const owner = refusalOwner(store.state.error?.code, wiring.steps, wiring.gates);
|
|
108
|
+
const derived = deriveStep({
|
|
109
|
+
steps: wiring.steps,
|
|
110
|
+
ctx,
|
|
111
|
+
facts: stepFacts,
|
|
112
|
+
methods: wiring.methods,
|
|
113
|
+
reopened: refusalStepOverride(store.state.error, owner) ?? store.state.reopened,
|
|
114
|
+
});
|
|
115
|
+
const writers = useEngineWriters({ runtime, wiring, store, ctx, stepFacts });
|
|
116
|
+
const nav = deriveNav({
|
|
117
|
+
applying: derived.applying,
|
|
118
|
+
index: derived.index,
|
|
119
|
+
taxIdOnFile: ctx.taxIdOnFile,
|
|
120
|
+
terminal: ctx.outcome !== null,
|
|
121
|
+
ports: writers,
|
|
122
|
+
});
|
|
123
|
+
const copy = runtime.config.copy.views;
|
|
124
|
+
const actions = usePipelineActionsValue({
|
|
125
|
+
screens,
|
|
126
|
+
copy,
|
|
127
|
+
wiring,
|
|
128
|
+
offered,
|
|
129
|
+
store,
|
|
130
|
+
writers,
|
|
131
|
+
editBuyer: nav.editBuyer,
|
|
132
|
+
});
|
|
133
|
+
const step = derived.step;
|
|
134
|
+
return (
|
|
135
|
+
<PipelineActionsProvider actions={actions}>
|
|
136
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 } }}>
|
|
137
|
+
<EngineChrome
|
|
138
|
+
copy={copy}
|
|
139
|
+
applying={derived.applying}
|
|
140
|
+
currentId={step?.id ?? null}
|
|
141
|
+
first={derived.index <= 0}
|
|
142
|
+
onBack={nav.back}
|
|
143
|
+
/>
|
|
144
|
+
{step === null ? (
|
|
145
|
+
<EngineLoading copy={copy} />
|
|
146
|
+
) : (
|
|
147
|
+
step.render({
|
|
148
|
+
ctx,
|
|
149
|
+
facts: stepFacts[step.id],
|
|
150
|
+
slice: sliceFor(step, ctx),
|
|
151
|
+
setSlice: (value: unknown) => store.setSlice(step.id, value),
|
|
152
|
+
advance: () => store.patch({ reopened: null }),
|
|
153
|
+
back: nav.back,
|
|
154
|
+
error: errorForStep(store.state.error, owner, step.id, step.id),
|
|
155
|
+
})
|
|
156
|
+
)}
|
|
157
|
+
</Box>
|
|
158
|
+
</PipelineActionsProvider>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What a step's render reads, assembled once per render. */
|
|
163
|
+
function usePipelineActionsValue(input: {
|
|
164
|
+
screens: CheckoutScreens;
|
|
165
|
+
copy: CheckoutViewCopy;
|
|
166
|
+
wiring: PipelineWiring;
|
|
167
|
+
offered: PipelineActions["offered"];
|
|
168
|
+
store: ReturnType<typeof useEngineStore>;
|
|
169
|
+
writers: ReturnType<typeof useEngineWriters>;
|
|
170
|
+
editBuyer: (() => void) | undefined;
|
|
171
|
+
}): PipelineActions {
|
|
172
|
+
const { screens, copy, wiring, offered, store, writers, editBuyer } = input;
|
|
173
|
+
return useMemo<PipelineActions>(
|
|
174
|
+
() => ({
|
|
175
|
+
screens,
|
|
176
|
+
copy,
|
|
177
|
+
methods: wiring.methods,
|
|
178
|
+
offered,
|
|
179
|
+
placing: store.state.placing,
|
|
180
|
+
saveProfile: store.state.saveProfile,
|
|
181
|
+
error: store.state.error,
|
|
182
|
+
editBuyer,
|
|
183
|
+
choose: writers.choose,
|
|
184
|
+
place: writers.place,
|
|
185
|
+
continueFromDados: writers.continueFromDados,
|
|
186
|
+
setBuyer: writers.setBuyer,
|
|
187
|
+
setSaveProfile: writers.setSaveProfile,
|
|
188
|
+
resolve: writers.resolve,
|
|
189
|
+
adoptOrder: writers.adoptOrder,
|
|
190
|
+
exitToCatalog: writers.exitToCatalog,
|
|
191
|
+
}),
|
|
192
|
+
[screens, copy, wiring.methods, offered, store.state, editBuyer, writers],
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether a hand-off from this tab was still waiting WHEN THIS CHECKOUT
|
|
198
|
+
* MOUNTED.
|
|
199
|
+
*
|
|
200
|
+
* Asked once, and the "once" is the whole of it. The resume CONSUMES the
|
|
201
|
+
* parked entry — that is what makes a resume happen exactly once — so a second
|
|
202
|
+
* read a render later answers "nothing is pending" about the very shopper
|
|
203
|
+
* being resumed. Every caller of this answer would then flip mid-visit: gates
|
|
204
|
+
* that stood aside would close, and the empty-cart screen would land on top of
|
|
205
|
+
* a confirmation for money that already moved.
|
|
206
|
+
*/
|
|
207
|
+
function useResumePending(ctx: CheckoutContext): boolean {
|
|
208
|
+
const [pending] = useState(() => resumePending(ctx));
|
|
209
|
+
return pending;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The engine body: facts, admission, state, then the walk. */
|
|
213
|
+
function EngineBody(props: {
|
|
214
|
+
runtime: FlowsRuntime;
|
|
215
|
+
screens: CheckoutScreens;
|
|
216
|
+
wiring: PipelineWiring;
|
|
217
|
+
settlement?: SettlementCheckout | null;
|
|
218
|
+
}): ReactNode {
|
|
219
|
+
const { runtime, screens, wiring } = props;
|
|
220
|
+
useStablePluginArray("steps", runtime.config.steps);
|
|
221
|
+
useStablePluginArray("gates", runtime.config.gates);
|
|
222
|
+
useStablePluginArray("settlementMethods", runtime.config.settlementMethods);
|
|
223
|
+
const base = useCheckoutBase(runtime, runtime.config);
|
|
224
|
+
const stepFactList = usePluginFacts(wiring.steps);
|
|
225
|
+
const gateFacts = usePluginFacts(wiring.gates);
|
|
226
|
+
const methodFacts = usePluginFacts(wiring.methods);
|
|
227
|
+
const store = useEngineStore(wiring.steps, base.ctx);
|
|
228
|
+
const stepFacts = byId(wiring.steps, stepFactList);
|
|
229
|
+
// Whatever order this visit is about, in the same priority the context
|
|
230
|
+
// resolves: the one just raised, then the one the host's open-payable read
|
|
231
|
+
// or the park answered with.
|
|
232
|
+
const inFlight = store.state.order ?? base.ctx.order;
|
|
233
|
+
const ctx = withCheckoutState(
|
|
234
|
+
{ ...base.ctx, settlement: props.settlement ?? base.ctx.settlement },
|
|
235
|
+
{
|
|
236
|
+
buyer: effectiveBuyer(store.state, base.ctx.buyer),
|
|
237
|
+
// ONE writer: the chosen method IS the method step's slice. An order
|
|
238
|
+
// this shopper never chose for — resumed from a park, or answered by
|
|
239
|
+
// the server's own open payable — speaks for itself, which is what puts
|
|
240
|
+
// them back in front of the pane that still has what they need.
|
|
241
|
+
method: methodSliceOf(store.state.slices).chosen ?? inFlight?.method ?? null,
|
|
242
|
+
order: store.state.order,
|
|
243
|
+
outcome: store.state.outcome,
|
|
244
|
+
slices: store.state.slices,
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
const offered = wiring.methods.filter((method, at) => method.offered(ctx, methodFacts[at]));
|
|
248
|
+
useSettled({ ctx, wiring, runtime });
|
|
249
|
+
const resuming = useResumePending(ctx);
|
|
250
|
+
const admission = decideAdmission({ gates: wiring.gates, facts: gateFacts, ctx, resuming });
|
|
251
|
+
// Read BEFORE the early returns below. `runtime.useAvailability()` wraps the
|
|
252
|
+
// host's `ports.useAvailability` and is therefore a hook: leaving it at its
|
|
253
|
+
// only use site put it after `refused`, so a gate answering `pending` and then
|
|
254
|
+
// `pass` changed this component's hook count between renders and React threw
|
|
255
|
+
// "Rendered more hooks than during the previous render". Nothing caught it —
|
|
256
|
+
// eslint-plugin-react-hooks only reads `X.useFoo()` as a hook when X is
|
|
257
|
+
// PascalCase, and `runtime` is not.
|
|
258
|
+
const payable = runtime.useAvailability().payable;
|
|
259
|
+
const refused = admittedOrScreen(admission, ctx, store, runtime.config.copy.views);
|
|
260
|
+
if (refused !== null) return refused;
|
|
261
|
+
if (storeCannotCharge(ctx.config, ctx.configPending, payable)) {
|
|
262
|
+
return <screens.PaymentsUnavailable />;
|
|
263
|
+
}
|
|
264
|
+
if (nothingToPayFor(ctx, resuming)) return <screens.EmptyCart />;
|
|
265
|
+
return (
|
|
266
|
+
<EngineWalk
|
|
267
|
+
runtime={runtime}
|
|
268
|
+
screens={screens}
|
|
269
|
+
wiring={wiring}
|
|
270
|
+
ctx={ctx}
|
|
271
|
+
store={store}
|
|
272
|
+
stepFacts={stepFacts}
|
|
273
|
+
offered={offered}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A gate's verdict, as something to render — or `null` to carry on. */
|
|
279
|
+
function admittedOrScreen(
|
|
280
|
+
admission: GateVerdict,
|
|
281
|
+
ctx: CheckoutContext,
|
|
282
|
+
store: ReturnType<typeof useEngineStore>,
|
|
283
|
+
copy: CheckoutViewCopy,
|
|
284
|
+
): ReactNode {
|
|
285
|
+
if (admission.kind === "pass") return null;
|
|
286
|
+
// PENDING is not a refusal: a gate that has not heard back yet must not put
|
|
287
|
+
// a curtain in front of a shopper it may be about to admit.
|
|
288
|
+
if (admission.kind === "pending") return <EngineLoading copy={copy} />;
|
|
289
|
+
const Screen = admission.Screen;
|
|
290
|
+
return (
|
|
291
|
+
<Screen
|
|
292
|
+
ctx={ctx}
|
|
293
|
+
error={store.state.error}
|
|
294
|
+
retry={() => store.patch({ error: null, decline: null, reopened: null })}
|
|
295
|
+
/>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Nothing to check out — and the cart has ANSWERED, which is the half FUT-1213
|
|
301
|
+
* added. A settlement pays already-sent items, so its cart is legitimately
|
|
302
|
+
* empty; and the guard holds only until an order exists, after which the
|
|
303
|
+
* order's own lines speak for it.
|
|
304
|
+
*
|
|
305
|
+
* It also stands aside for a shopper coming back from a payment, and that
|
|
306
|
+
* clause is load-bearing rather than defensive: the server empties a paid
|
|
307
|
+
* cart, so an EMPTY basket is exactly what a buyer who paid comes back to.
|
|
308
|
+
* Without it the one shopper whose confirmation matters most would meet "seu
|
|
309
|
+
* carrinho está vazio" instead of the receipt for the money they just sent —
|
|
310
|
+
* the flat flow avoids that only by resuming inside a LAYOUT effect, which a
|
|
311
|
+
* derived walk has no equivalent of.
|
|
312
|
+
*/
|
|
313
|
+
function nothingToPayFor(ctx: CheckoutContext, resuming: boolean): boolean {
|
|
314
|
+
if (resuming || ctx.settlement || !ctx.cart.empty) return false;
|
|
315
|
+
if (ctx.cart.identity?.ready === false) return false;
|
|
316
|
+
return ctx.order === null && ctx.outcome === null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** What `createPaymentFlows` mounts when a host asked for the pipeline. */
|
|
320
|
+
interface PipelineBundle {
|
|
321
|
+
Checkout: PaymentFlows["Checkout"];
|
|
322
|
+
useAdmission(): GateVerdict;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Build one factory's engine.
|
|
327
|
+
*
|
|
328
|
+
* Every array here is a FACTORY-SCOPE constant — the package's own steps and
|
|
329
|
+
* methods merged once with the host's — because hook order is a function of
|
|
330
|
+
* their membership. A host that hands over a fresh array per render is told
|
|
331
|
+
* so, loudly, in a development build.
|
|
332
|
+
*/
|
|
333
|
+
export function buildPipeline(
|
|
334
|
+
runtime: FlowsRuntime,
|
|
335
|
+
screens: CheckoutScreens,
|
|
336
|
+
): PipelineBundle {
|
|
337
|
+
const methods = Object.freeze([
|
|
338
|
+
...PACKAGE_METHODS,
|
|
339
|
+
...(runtime.config.settlementMethods ?? []),
|
|
340
|
+
]);
|
|
341
|
+
const wiring: PipelineWiring = {
|
|
342
|
+
methods,
|
|
343
|
+
steps: Object.freeze([
|
|
344
|
+
...packageSteps(runtime, methods),
|
|
345
|
+
...(runtime.config.steps ?? []),
|
|
346
|
+
]),
|
|
347
|
+
gates: runtime.config.gates ?? NO_GATES,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function Checkout({ settlement }: { settlement?: SettlementCheckout | null }): JSX.Element {
|
|
351
|
+
return (
|
|
352
|
+
<FlowsProvider runtime={runtime}>
|
|
353
|
+
<EngineBody
|
|
354
|
+
runtime={runtime}
|
|
355
|
+
screens={screens}
|
|
356
|
+
wiring={wiring}
|
|
357
|
+
{...(settlement === undefined ? {} : { settlement })}
|
|
358
|
+
/>
|
|
359
|
+
</FlowsProvider>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The same gate list, headless — so the cart drawer's CTA and a buy-now
|
|
365
|
+
* button consume the answer the checkout will give rather than a second
|
|
366
|
+
* one of their own.
|
|
367
|
+
*/
|
|
368
|
+
function useAdmission(): GateVerdict {
|
|
369
|
+
useStablePluginArray("gates", runtime.config.gates);
|
|
370
|
+
const base = useCheckoutBase(runtime, runtime.config);
|
|
371
|
+
const facts = usePluginFacts(wiring.gates);
|
|
372
|
+
const resuming = useResumePending(base.ctx);
|
|
373
|
+
return decideAdmission({ gates: wiring.gates, facts, ctx: base.ctx, resuming });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { Checkout, useAdmission };
|
|
377
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TWO SETTLEMENT METHODS THE PACKAGE ITSELF OWNS (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* Pix and card, as descriptor rows — the same two the picker has always
|
|
5
|
+
* offered, said in the vocabulary a host's own method can be said in. Both
|
|
6
|
+
* raise a charge, so both mount a `pay`-phase pane; a host's in-person method
|
|
7
|
+
* (pay the courier, pay the waiter) declares `raisesCharge: false` and the
|
|
8
|
+
* engine mounts none.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here names a vendor. `offered()` asks the SERVER-published chain
|
|
11
|
+
* what this store can charge, exactly as `offeredMethods` has since FUT-698,
|
|
12
|
+
* and a config still in flight fails OPEN for the picker while the server
|
|
13
|
+
* still fails the charge closed.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
cardPathAvailable,
|
|
17
|
+
offeredMethods,
|
|
18
|
+
} from "../../components/checkout/method-capability";
|
|
19
|
+
import type { PaymentMethod } from "../../components/checkout/types";
|
|
20
|
+
|
|
21
|
+
import type { CheckoutContext, SettlementMethodDescriptor } from "./types";
|
|
22
|
+
|
|
23
|
+
/** The pane step ids the package's own two methods render into. */
|
|
24
|
+
export const PIX_PANE_STEP = "pix";
|
|
25
|
+
export const CARD_PANE_STEP = "card";
|
|
26
|
+
|
|
27
|
+
/** Whether the store's chain declares this method. `null` config ⇒ fail open. */
|
|
28
|
+
function chainOffers(ctx: CheckoutContext, method: PaymentMethod): boolean {
|
|
29
|
+
const offered = offeredMethods(ctx.config);
|
|
30
|
+
return offered === null || offered.includes(method);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const PIX_METHOD: SettlementMethodDescriptor = {
|
|
34
|
+
id: "PIX",
|
|
35
|
+
raisesCharge: true,
|
|
36
|
+
pane: PIX_PANE_STEP,
|
|
37
|
+
offered(ctx) {
|
|
38
|
+
return chainOffers(ctx, "PIX");
|
|
39
|
+
},
|
|
40
|
+
tile(copy) {
|
|
41
|
+
const method = copy.screens.screens.method;
|
|
42
|
+
return { label: method.pixLabel, hint: method.pixDescription };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const CARD_METHOD: SettlementMethodDescriptor = {
|
|
47
|
+
id: "CARD",
|
|
48
|
+
raisesCharge: true,
|
|
49
|
+
pane: CARD_PANE_STEP,
|
|
50
|
+
offered(ctx) {
|
|
51
|
+
// Both halves, the same pair the picker has always asked: the chain
|
|
52
|
+
// DECLARES card, and this browser has some way of producing an instrument
|
|
53
|
+
// for it (or the provider's own page takes it).
|
|
54
|
+
return chainOffers(ctx, "CARD") && cardPathAvailable(ctx.config);
|
|
55
|
+
},
|
|
56
|
+
tile(copy) {
|
|
57
|
+
const method = copy.screens.screens.method;
|
|
58
|
+
return { label: method.cardLabel, hint: method.cardDescription };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The package's rows, in picker order. Frozen: this array IS hook order. */
|
|
63
|
+
export const PACKAGE_METHODS: readonly SettlementMethodDescriptor[] = Object.freeze([
|
|
64
|
+
PIX_METHOD,
|
|
65
|
+
CARD_METHOD,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Whether an id is one the package's own screens can render a pane for. */
|
|
69
|
+
export function isPackageMethod(id: string | null): id is PaymentMethod {
|
|
70
|
+
return id === "PIX" || id === "CARD";
|
|
71
|
+
}
|