@12-apps/payments-frontend 3.22.0 → 3.23.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/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/types.ts +11 -0
- package/src/components/checkout/use-card-checkout.ts +3 -2
- package/src/components/checkout/use-checkout-controller.ts +19 -5
- 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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A STEP'S OWN SCRAP OF STATE LIVES (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* A step DECLARES its slice — the initial value, whether it survives a reload,
|
|
5
|
+
* and how to read one back — and the engine owns the storage. That split is
|
|
6
|
+
* what stops each step growing its own `sessionStorage` key with its own
|
|
7
|
+
* spelling and its own idea of who it belongs to, which is how three of the
|
|
8
|
+
* four hosted-order parks ended up unscoped.
|
|
9
|
+
*
|
|
10
|
+
* ## Scoped to the store, always
|
|
11
|
+
*
|
|
12
|
+
* The key is `payments.checkout.<slug>.<stepId>`. On a multi-tenant storefront
|
|
13
|
+
* every store shares one origin, so an unscoped key is one slot every store
|
|
14
|
+
* writes over: the shape that let store A's abandoned hand-off resume on store
|
|
15
|
+
* B's checkout. A host with no slug gets `-`, which is the single-tenant case
|
|
16
|
+
* and the only place there is nothing to confuse it with.
|
|
17
|
+
*
|
|
18
|
+
* ## Trust nothing that came back out
|
|
19
|
+
*
|
|
20
|
+
* Storage is the one input that did not come from this render. It is a string
|
|
21
|
+
* a shopper can edit, a half-written value a killed tab left behind, or an
|
|
22
|
+
* older bundle's shape. So a slice is rehydrated ONLY when the step declared a
|
|
23
|
+
* `parse` and that parser accepts what was there; everything else falls back
|
|
24
|
+
* to `initial(ctx)`. Same rule as `hosted-return.ts`'s `isCheckoutOrder`.
|
|
25
|
+
*/
|
|
26
|
+
import type { AnyCheckoutStep, CheckoutContext } from "./types";
|
|
27
|
+
|
|
28
|
+
/** The one place the key is spelled. */
|
|
29
|
+
export function sliceKey(tenantSlug: string | undefined, stepId: string): string {
|
|
30
|
+
return `payments.checkout.${tenantSlug ?? "-"}.${stepId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The parked value, or `null` — never a throw, and never an unparsed one. */
|
|
34
|
+
export function readSlice(
|
|
35
|
+
step: AnyCheckoutStep,
|
|
36
|
+
tenantSlug: string | undefined,
|
|
37
|
+
): unknown {
|
|
38
|
+
const slice = step.slice;
|
|
39
|
+
if (!slice || slice.persist !== "session" || !slice.parse) return null;
|
|
40
|
+
let raw: string | null = null;
|
|
41
|
+
try {
|
|
42
|
+
raw = window.sessionStorage?.getItem(sliceKey(tenantSlug, step.id)) ?? null;
|
|
43
|
+
} catch {
|
|
44
|
+
// Storage disabled or unavailable — the same "nothing parked" as an empty
|
|
45
|
+
// slot. A checkout that cannot remember a step still works; one that
|
|
46
|
+
// throws on a private-mode browser does not.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (raw === null) return null;
|
|
50
|
+
try {
|
|
51
|
+
return slice.parse(JSON.parse(raw) as unknown);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Park a slice, or drop it when the step does not persist. */
|
|
58
|
+
export function writeSlice(
|
|
59
|
+
step: AnyCheckoutStep,
|
|
60
|
+
tenantSlug: string | undefined,
|
|
61
|
+
value: unknown,
|
|
62
|
+
): void {
|
|
63
|
+
if (step.slice?.persist !== "session") return;
|
|
64
|
+
try {
|
|
65
|
+
window.sessionStorage?.setItem(
|
|
66
|
+
sliceKey(tenantSlug, step.id),
|
|
67
|
+
JSON.stringify(value),
|
|
68
|
+
);
|
|
69
|
+
} catch {
|
|
70
|
+
// Storage full or disabled. The slice still lives in React for this visit;
|
|
71
|
+
// only its survival across a reload is lost, and refusing to advance the
|
|
72
|
+
// checkout over that would be the worse failure.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Drop every registered step's parked slice — the end of one checkout. */
|
|
77
|
+
export function clearSlices(
|
|
78
|
+
steps: readonly AnyCheckoutStep[],
|
|
79
|
+
tenantSlug: string | undefined,
|
|
80
|
+
): void {
|
|
81
|
+
for (const step of steps) {
|
|
82
|
+
if (step.slice?.persist !== "session") continue;
|
|
83
|
+
try {
|
|
84
|
+
window.sessionStorage?.removeItem(sliceKey(tenantSlug, step.id));
|
|
85
|
+
} catch {
|
|
86
|
+
// Nothing to clear on a browser that refuses storage.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every step's slice at mount: what was parked when it can be trusted, and the
|
|
93
|
+
* step's own `initial(ctx)` when it cannot.
|
|
94
|
+
*
|
|
95
|
+
* `ctx` here is the context BEFORE any slice is known, which is why
|
|
96
|
+
* `CheckoutContext.slices` is empty on this pass — an `initial` that read its
|
|
97
|
+
* own slice would be asking what it is about to answer.
|
|
98
|
+
*/
|
|
99
|
+
export function initialSlices(
|
|
100
|
+
steps: readonly AnyCheckoutStep[],
|
|
101
|
+
ctx: CheckoutContext,
|
|
102
|
+
): Record<string, unknown> {
|
|
103
|
+
const slices: Record<string, unknown> = {};
|
|
104
|
+
for (const step of steps) {
|
|
105
|
+
if (!step.slice) continue;
|
|
106
|
+
const parked = readSlice(step, ctx.tenantSlug);
|
|
107
|
+
slices[step.id] = parked === null ? step.slice.initial(ctx) : parked;
|
|
108
|
+
}
|
|
109
|
+
return slices;
|
|
110
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DEV-MODE ASSERTION THAT KEEPS HOOK ORDER HONEST (FUT-1216 risk 1).
|
|
3
|
+
*
|
|
4
|
+
* Every registered step, gate and settlement method may declare a `useFacts()`,
|
|
5
|
+
* and the engine calls them in ARRAY ORDER on every render. React's hook
|
|
6
|
+
* identity is positional, so the order — and therefore the membership — of
|
|
7
|
+
* those arrays is part of the component's hook signature.
|
|
8
|
+
*
|
|
9
|
+
* A host that builds them inline (`steps={[scheduleStep, addressStep]}` in
|
|
10
|
+
* JSX, or `const GATES = gates.filter(...)` in a component body) hands the
|
|
11
|
+
* engine a NEW array every render. Today that usually still works, which is
|
|
12
|
+
* the danger: it works right up until a filter's answer changes and a
|
|
13
|
+
* `useFacts` disappears from the middle of the list, at which point React
|
|
14
|
+
* silently pairs the wrong state with the wrong plugin. There is no error, no
|
|
15
|
+
* warning, and the symptom is a checkout that reads another plugin's facts.
|
|
16
|
+
*
|
|
17
|
+
* So this refuses the shape rather than the symptom. In a development build a
|
|
18
|
+
* plugin array that is not identity-stable across renders THROWS, naming the
|
|
19
|
+
* array and the fix: hoist it to module scope, or memoise it. In production it
|
|
20
|
+
* does nothing at all — a live checkout must never be taken down by a wiring
|
|
21
|
+
* complaint, and by then the shape has been provable for the host's whole dev
|
|
22
|
+
* and test cycle.
|
|
23
|
+
*/
|
|
24
|
+
import { useRef } from "react";
|
|
25
|
+
|
|
26
|
+
/** Whether this bundle is a development one. */
|
|
27
|
+
function inDevelopment(): boolean {
|
|
28
|
+
// `process` is absent in a plain browser and replaced at build time by every
|
|
29
|
+
// bundler a host is likely to use; both readings are guarded so neither can
|
|
30
|
+
// throw inside a render.
|
|
31
|
+
try {
|
|
32
|
+
return typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a host is told when it rebuilds an array per render. */
|
|
39
|
+
function unstablePluginMessage(name: string): string {
|
|
40
|
+
return (
|
|
41
|
+
`createPaymentFlows: \`${name}\` must be the SAME array on every render. ` +
|
|
42
|
+
"Every registered plugin's `useFacts()` runs in array order, so React's hook " +
|
|
43
|
+
"order is a function of this array's membership — a fresh array per render " +
|
|
44
|
+
"will eventually pair one plugin's state with another's, silently. Hoist it " +
|
|
45
|
+
"to module scope (`const STEPS = [...]`) or memoise it. This check runs in " +
|
|
46
|
+
"development builds only."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Assert one plugin array is the same object it was last render.
|
|
52
|
+
*
|
|
53
|
+
* `undefined` is stable by definition — a host that registers nothing has
|
|
54
|
+
* nothing to keep still — and the FIRST render has nothing to compare with.
|
|
55
|
+
*/
|
|
56
|
+
export function useStablePluginArray(
|
|
57
|
+
name: string,
|
|
58
|
+
array: readonly unknown[] | undefined,
|
|
59
|
+
): void {
|
|
60
|
+
const seen = useRef<readonly unknown[] | undefined>(array);
|
|
61
|
+
if (array === undefined || seen.current === undefined) return;
|
|
62
|
+
if (seen.current === array) return;
|
|
63
|
+
// THROWN BEFORE THE REF MOVES, and that ordering is the whole assertion.
|
|
64
|
+
// React answers a failed concurrent render by retrying the root
|
|
65
|
+
// synchronously; a check that had already recorded the new array would pass
|
|
66
|
+
// on that retry, React would report itself recovered, and the misconfigured
|
|
67
|
+
// host would see nothing at all. Refusing again is what puts the complaint
|
|
68
|
+
// in front of an error boundary — and what keeps it there until the array is
|
|
69
|
+
// actually hoisted.
|
|
70
|
+
if (inDevelopment()) throw new Error(unstablePluginMessage(name));
|
|
71
|
+
seen.current = array;
|
|
72
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TWO STEPS BEFORE ANY MONEY MOVES (FUT-1240): who is paying, and how.
|
|
3
|
+
*
|
|
4
|
+
* Both are thin wrappers over screens this package already ships — exactly as
|
|
5
|
+
* `flows/screens-*.tsx` wrap the components today. What is new is that their
|
|
6
|
+
* PLACE in the walk is declared rather than switched on, and that the
|
|
7
|
+
* buyer-details step's "was it skipped, and has it been opened since" lives in
|
|
8
|
+
* a slice the engine parks instead of in a `useState` a reload forgets.
|
|
9
|
+
*/
|
|
10
|
+
import { Box } from "@mui/material";
|
|
11
|
+
import type { JSX } from "react";
|
|
12
|
+
|
|
13
|
+
import { PaymentErrorPanel } from "../../../components/checkout/payment-error-panel";
|
|
14
|
+
import { useCheckoutComponents } from "../../../components/checkout/ui";
|
|
15
|
+
import type { PaymentMethod } from "../../../components/checkout/types";
|
|
16
|
+
import { usePipelineActions } from "../actions";
|
|
17
|
+
import { isPackageMethod } from "../methods";
|
|
18
|
+
import type {
|
|
19
|
+
AnySettlementMethod,
|
|
20
|
+
CheckoutContext,
|
|
21
|
+
CheckoutStep,
|
|
22
|
+
CheckoutStepRender,
|
|
23
|
+
} from "../types";
|
|
24
|
+
|
|
25
|
+
export const DADOS_STEP_ID = "dados";
|
|
26
|
+
export const METHOD_STEP_ID = "method";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The server's own code for "the chain asked for a field this buyer has not
|
|
30
|
+
* given" — and the code the local gate raises, so a refusal the browser can
|
|
31
|
+
* see and one the server sends land on the same step, worded the same way.
|
|
32
|
+
*/
|
|
33
|
+
export const BUYER_FIELD_CODE = "MISSING_BUYER_FIELD";
|
|
34
|
+
|
|
35
|
+
/** Whether the shopper has opened the buyer-details step, and finished it. */
|
|
36
|
+
interface DadosSlice {
|
|
37
|
+
/**
|
|
38
|
+
* A shopper whose CPF is on file never sees this step — until they press
|
|
39
|
+
* "alterar", after which it IS part of their flow and back returns to it.
|
|
40
|
+
* That is the whole of `useCheckoutNav`'s `dadosOpened`.
|
|
41
|
+
*/
|
|
42
|
+
opened: boolean;
|
|
43
|
+
done: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DADOS_INITIAL: DadosSlice = Object.freeze({ opened: false, done: false });
|
|
47
|
+
|
|
48
|
+
/** Trust nothing out of storage: two booleans, or nothing at all. */
|
|
49
|
+
export function parseDadosSlice(raw: unknown): DadosSlice | null {
|
|
50
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
51
|
+
const candidate = raw as Partial<DadosSlice>;
|
|
52
|
+
if (typeof candidate.opened !== "boolean") return null;
|
|
53
|
+
if (typeof candidate.done !== "boolean") return null;
|
|
54
|
+
return { opened: candidate.opened, done: candidate.done };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The buyer-details slice as the whole walk reads it. */
|
|
58
|
+
export function dadosSliceOf(ctx: CheckoutContext): DadosSlice {
|
|
59
|
+
return parseDadosSlice(ctx.slices[DADOS_STEP_ID]) ?? DADOS_INITIAL;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function DadosView({ ctx, error }: CheckoutStepRender<DadosSlice>): JSX.Element {
|
|
63
|
+
const actions = usePipelineActions();
|
|
64
|
+
const BuyerDetails = actions.screens.BuyerDetails;
|
|
65
|
+
const field = error?.field ?? null;
|
|
66
|
+
return (
|
|
67
|
+
<BuyerDetails
|
|
68
|
+
value={ctx.buyer}
|
|
69
|
+
onChange={actions.setBuyer}
|
|
70
|
+
method={isPackageMethod(ctx.method) ? ctx.method : null}
|
|
71
|
+
onContinue={actions.continueFromDados}
|
|
72
|
+
error={field && error ? { field, message: error.message } : null}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* WHO IS PAYING.
|
|
79
|
+
*
|
|
80
|
+
* `applies` is the FUT-465 skip, stated as a fact rather than as an initial
|
|
81
|
+
* step: a shopper with a CPF on file has nothing left to answer here, so the
|
|
82
|
+
* step is not part of their walk — which is also why back off the next step
|
|
83
|
+
* takes them to the catalog rather than to a form they never saw.
|
|
84
|
+
*/
|
|
85
|
+
export const dadosStep: CheckoutStep<DadosSlice> = {
|
|
86
|
+
id: DADOS_STEP_ID,
|
|
87
|
+
phase: "details",
|
|
88
|
+
order: 0,
|
|
89
|
+
label: "dados",
|
|
90
|
+
applies(ctx) {
|
|
91
|
+
return !ctx.taxIdOnFile || dadosSliceOf(ctx).opened;
|
|
92
|
+
},
|
|
93
|
+
complete(_ctx, _facts, slice) {
|
|
94
|
+
return slice.done;
|
|
95
|
+
},
|
|
96
|
+
slice: {
|
|
97
|
+
initial: () => DADOS_INITIAL,
|
|
98
|
+
persist: "session",
|
|
99
|
+
parse: parseDadosSlice,
|
|
100
|
+
},
|
|
101
|
+
// The two refusals that are ABOUT a field on this form. Anything else the
|
|
102
|
+
// create can answer is not something retyping a name will fix.
|
|
103
|
+
answersCodes: [BUYER_FIELD_CODE, "EMAIL_EQUALS_MERCHANT"],
|
|
104
|
+
contribute(ctx) {
|
|
105
|
+
return { buyer: ctx.buyer };
|
|
106
|
+
},
|
|
107
|
+
render(props) {
|
|
108
|
+
return <DadosView {...props} />;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Which settlement method the shopper picked. */
|
|
113
|
+
interface MethodSlice {
|
|
114
|
+
chosen: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const METHOD_INITIAL: MethodSlice = Object.freeze({ chosen: null });
|
|
118
|
+
|
|
119
|
+
/** A method id is a string or nothing — never an object a shopper edited in. */
|
|
120
|
+
function parseMethodSlice(raw: unknown): MethodSlice | null {
|
|
121
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
122
|
+
const chosen = (raw as Partial<MethodSlice>).chosen;
|
|
123
|
+
if (chosen === null) return { chosen: null };
|
|
124
|
+
return typeof chosen === "string" ? { chosen } : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The chosen method, as the engine reads it back out of the slices. */
|
|
128
|
+
export function methodSliceOf(slices: Readonly<Record<string, unknown>>): MethodSlice {
|
|
129
|
+
return parseMethodSlice(slices[METHOD_STEP_ID]) ?? METHOD_INITIAL;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One registered method as a tile, for the methods the package cannot draw. */
|
|
133
|
+
function MethodTiles({ ctx }: { ctx: CheckoutContext }): JSX.Element {
|
|
134
|
+
const { Button, Text } = useCheckoutComponents();
|
|
135
|
+
const actions = usePipelineActions();
|
|
136
|
+
return (
|
|
137
|
+
<Box role="radiogroup" data-testid="checkout-method" sx={{ display: "flex", gap: 1 }}>
|
|
138
|
+
{actions.offered.map((method) => {
|
|
139
|
+
const tile = method.tile(actions.copy);
|
|
140
|
+
return (
|
|
141
|
+
<Box key={method.id} sx={{ flex: 1, minWidth: 0 }}>
|
|
142
|
+
<Button
|
|
143
|
+
variant={ctx.method === method.id ? "solid" : "outline"}
|
|
144
|
+
color="primary"
|
|
145
|
+
size="md"
|
|
146
|
+
fullWidth
|
|
147
|
+
onClick={() => actions.choose(method.id)}
|
|
148
|
+
dataTestId={`checkout-method-${method.id}`}
|
|
149
|
+
>
|
|
150
|
+
{tile.label}
|
|
151
|
+
</Button>
|
|
152
|
+
{tile.hint === undefined ? null : (
|
|
153
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
154
|
+
{tile.hint}
|
|
155
|
+
</Text>
|
|
156
|
+
)}
|
|
157
|
+
</Box>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</Box>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* HOW they are paying.
|
|
166
|
+
*
|
|
167
|
+
* The package's own picker renders whenever the offer is the package's own two
|
|
168
|
+
* methods — with its disabled-card caption and its sole-method preselect
|
|
169
|
+
* intact, because those are decisions about PIX and CARD specifically. A host
|
|
170
|
+
* that registers a method of its own gets the generic tiles, which is the only
|
|
171
|
+
* shape that can draw a method this package has never heard of.
|
|
172
|
+
*/
|
|
173
|
+
function MethodView({ ctx, error }: CheckoutStepRender<MethodSlice>): JSX.Element {
|
|
174
|
+
const actions = usePipelineActions();
|
|
175
|
+
const MethodChoice = actions.screens.MethodChoice;
|
|
176
|
+
const packageOnly = actions.offered.every((method) => isPackageMethod(method.id));
|
|
177
|
+
return (
|
|
178
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
179
|
+
<PayerBlock ctx={ctx} />
|
|
180
|
+
{packageOnly ? (
|
|
181
|
+
<MethodChoice
|
|
182
|
+
value={isPackageMethod(ctx.method) ? ctx.method : null}
|
|
183
|
+
onChange={(method: PaymentMethod) => actions.choose(method)}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<MethodTiles ctx={ctx} />
|
|
187
|
+
)}
|
|
188
|
+
<MethodRefusal error={error} />
|
|
189
|
+
</Box>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A REFUSAL THE PICKER HAS TO DRAW, because it is where the shopper is.
|
|
195
|
+
*
|
|
196
|
+
* The routing hands a step every refusal nobody more specific claimed — the
|
|
197
|
+
* unclaimed ones, and the ones whose gate is passing and therefore drawing
|
|
198
|
+
* nothing (`refusal-routing.ts`). Raising the payable fails from HERE, so this
|
|
199
|
+
* is the step those land on, and a picker that ignored the prop would leave the
|
|
200
|
+
* shopper tapping a tile that answers nothing.
|
|
201
|
+
*
|
|
202
|
+
* `PaymentErrorPanel` rather than a bare Alert: it is the same panel the flat
|
|
203
|
+
* flow's Pagamento step draws, and it carries the rule that matters most — an
|
|
204
|
+
* UNRESOLVED charge gets a warning and NO retry, because some provider may be
|
|
205
|
+
* holding the buyer's money and a retry mints a second order.
|
|
206
|
+
*
|
|
207
|
+
* `emailFlagged` is FALSE here and cannot be otherwise: the one refusal that
|
|
208
|
+
* offer answers, `EMAIL_EQUALS_MERCHANT`, is claimed by the buyer-details step
|
|
209
|
+
* (`dadosStep.answersCodes`), so the routing sends that shopper to the form
|
|
210
|
+
* holding the e-mail field instead of offering a second copy of it under a
|
|
211
|
+
* picker. `onUseEmail` is consequently unreachable, and is the buyer-details
|
|
212
|
+
* door rather than a no-op so that it stays true if the claim ever moves.
|
|
213
|
+
*/
|
|
214
|
+
function MethodRefusal({ error }: { error: CheckoutStepRender<MethodSlice>["error"] }): JSX.Element | null {
|
|
215
|
+
const actions = usePipelineActions();
|
|
216
|
+
const openDados = actions.editBuyer;
|
|
217
|
+
if (!error) return null;
|
|
218
|
+
return (
|
|
219
|
+
<PaymentErrorPanel
|
|
220
|
+
message={error.message}
|
|
221
|
+
emailFlagged={false}
|
|
222
|
+
code={error.code}
|
|
223
|
+
onUseEmail={() => openDados?.()}
|
|
224
|
+
onRetry={actions.place}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* WHO IS BEING CHARGED, and the door back to changing it.
|
|
231
|
+
*
|
|
232
|
+
* Drawn only for a shopper whose buyer-details step was SKIPPED — the same
|
|
233
|
+
* rule the flat flow applies, and for the same reason: a shopper who filled
|
|
234
|
+
* the form themselves is looking at what they just typed, and a summary of it
|
|
235
|
+
* is a second copy of the same screen. Its presence is `editBuyer`'s presence,
|
|
236
|
+
* which the engine derives once (`deriveNav`) rather than each caller guessing.
|
|
237
|
+
*/
|
|
238
|
+
function PayerBlock({ ctx }: { ctx: CheckoutContext }): JSX.Element | null {
|
|
239
|
+
const actions = usePipelineActions();
|
|
240
|
+
const PayerSummary = actions.screens.PayerSummary;
|
|
241
|
+
if (!actions.editBuyer) return null;
|
|
242
|
+
return <PayerSummary buyer={ctx.buyer} onEdit={actions.editBuyer} />;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A chosen method whose descriptor wants a review has not finished this step. */
|
|
246
|
+
function methodSettled(ctx: CheckoutContext, methods: readonly AnySettlementMethod[]): boolean {
|
|
247
|
+
if (ctx.method === null) return false;
|
|
248
|
+
const descriptor = methods.find((entry) => entry.id === ctx.method);
|
|
249
|
+
if (descriptor?.Review && ctx.order === null) return false;
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Built over the MERGED descriptor list, which is a factory-scope constant. */
|
|
254
|
+
export function buildMethodStep(
|
|
255
|
+
methods: readonly AnySettlementMethod[],
|
|
256
|
+
): CheckoutStep<MethodSlice> {
|
|
257
|
+
return {
|
|
258
|
+
id: METHOD_STEP_ID,
|
|
259
|
+
phase: "before-pay",
|
|
260
|
+
order: 0,
|
|
261
|
+
label: "payment",
|
|
262
|
+
applies(ctx) {
|
|
263
|
+
return ctx.outcome === null;
|
|
264
|
+
},
|
|
265
|
+
complete(ctx) {
|
|
266
|
+
return methodSettled(ctx, methods);
|
|
267
|
+
},
|
|
268
|
+
slice: {
|
|
269
|
+
initial: () => METHOD_INITIAL,
|
|
270
|
+
persist: "session",
|
|
271
|
+
parse: parseMethodSlice,
|
|
272
|
+
},
|
|
273
|
+
render(props) {
|
|
274
|
+
return <MethodStepBody {...props} methods={methods} />;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The picker, or the chosen descriptor's own review of what is about to happen. */
|
|
280
|
+
function MethodStepBody(
|
|
281
|
+
props: CheckoutStepRender<MethodSlice> & { methods: readonly AnySettlementMethod[] },
|
|
282
|
+
): JSX.Element {
|
|
283
|
+
const actions = usePipelineActions();
|
|
284
|
+
const descriptor = props.methods.find((entry) => entry.id === props.ctx.method);
|
|
285
|
+
const Review = descriptor?.Review;
|
|
286
|
+
if (Review && props.ctx.order === null) {
|
|
287
|
+
return (
|
|
288
|
+
<Review
|
|
289
|
+
ctx={props.ctx}
|
|
290
|
+
place={actions.place}
|
|
291
|
+
placing={actions.placing}
|
|
292
|
+
error={props.error}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return <MethodView {...props} />;
|
|
297
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SEVEN screens this package already shipped, wrapped as steps (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* Buyer details, the method picker, the Pix pane, the card pane, the hand-off
|
|
5
|
+
* interstitial, the return from one, and the confirmation. Each wrapper is
|
|
6
|
+
* thin by design: the behaviour stays in `components/checkout/*` and in the
|
|
7
|
+
* screens `flows/screens-*.tsx` already binds, and what is added here is only
|
|
8
|
+
* WHERE each one sits in a walk that is derived rather than switched on.
|
|
9
|
+
*
|
|
10
|
+
* The RETURN step is the one that is not a bare wrapper, and the reason is
|
|
11
|
+
* worth stating: `screens.HostedReturn` owns its own read of the parked entry,
|
|
12
|
+
* which is right for a host mounting it at a dedicated return route and wrong
|
|
13
|
+
* inside a checkout that already knows the slug and the basket. So the step
|
|
14
|
+
* runs `useHostedResume` — the package's own FUT-1213 rule, deferral and
|
|
15
|
+
* server ASK included — and renders the confirmation while it waits.
|
|
16
|
+
*/
|
|
17
|
+
import type { FlowsRuntime } from "../../runtime";
|
|
18
|
+
import type { AnyCheckoutStep, AnySettlementMethod } from "../types";
|
|
19
|
+
|
|
20
|
+
import { buildMethodStep, dadosStep } from "./buyer-steps";
|
|
21
|
+
import { buildResumeStep, cardStep, handoffStep, pixStep } from "./pay-steps";
|
|
22
|
+
import { statusStep } from "./status-step";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
BUYER_FIELD_CODE,
|
|
26
|
+
DADOS_STEP_ID,
|
|
27
|
+
METHOD_STEP_ID,
|
|
28
|
+
dadosSliceOf,
|
|
29
|
+
methodSliceOf,
|
|
30
|
+
} from "./buyer-steps";
|
|
31
|
+
export { HANDOFF_STEP_ID, RESUME_STEP_ID } from "./pay-steps";
|
|
32
|
+
export { STATUS_STEP_ID } from "./status-step";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The package's own steps, built once per factory.
|
|
36
|
+
*
|
|
37
|
+
* The returned array is a factory-scope constant — never rebuilt — because
|
|
38
|
+
* every registered plugin's `useFacts()` runs in array order and React's hook
|
|
39
|
+
* identity is positional. See `stable-plugins.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export function packageSteps(
|
|
42
|
+
runtime: FlowsRuntime,
|
|
43
|
+
methods: readonly AnySettlementMethod[],
|
|
44
|
+
): readonly AnyCheckoutStep[] {
|
|
45
|
+
return Object.freeze([
|
|
46
|
+
buildResumeStep(runtime),
|
|
47
|
+
dadosStep,
|
|
48
|
+
buildMethodStep(methods),
|
|
49
|
+
pixStep,
|
|
50
|
+
cardStep,
|
|
51
|
+
handoffStep,
|
|
52
|
+
statusStep,
|
|
53
|
+
]);
|
|
54
|
+
}
|