@12-apps/payments-frontend 3.21.4 → 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/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +387 -0
- package/src/components/checkout/checkout-flow.tsx +126 -24
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +41 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +291 -0
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +42 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +35 -0
- package/src/components/checkout/use-card-checkout.ts +34 -33
- package/src/components/checkout/use-checkout-controller.ts +68 -274
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- 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 +70 -0
- package/src/components/checkout/wallet-pane.tsx +9 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +19 -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 +55 -5
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +29 -19
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAY PHASE (FUT-1240): the Pix code, the card form, the hand-off, and the
|
|
3
|
+
* return from one.
|
|
4
|
+
*
|
|
5
|
+
* Every step here is `phase: "pay"` except the resume, and that matters: the
|
|
6
|
+
* engine drops the whole phase for a settlement method whose `raisesCharge` is
|
|
7
|
+
* `false`. So a shopper paying the courier never reaches any of these, and no
|
|
8
|
+
* lane has to remember not to render them.
|
|
9
|
+
*
|
|
10
|
+
* The resume is `before-pay` rather than `pay` deliberately. A shopper coming
|
|
11
|
+
* back from a provider's own page has to be resolved BEFORE they are offered a
|
|
12
|
+
* way to pay again — and the same screen has to work for an in-person method,
|
|
13
|
+
* which has no pay phase at all.
|
|
14
|
+
*/
|
|
15
|
+
import { Box } from "@mui/material";
|
|
16
|
+
import { useEffect, type JSX } from "react";
|
|
17
|
+
|
|
18
|
+
import { useCheckoutComponents } from "../../../components/checkout/ui";
|
|
19
|
+
import { useHostedResume, type HostedResume } from "../../../components/checkout/use-hosted-resume";
|
|
20
|
+
import type { FlowsRuntime } from "../../runtime";
|
|
21
|
+
import { usePipelineActions } from "../actions";
|
|
22
|
+
import { CARD_PANE_STEP, PIX_PANE_STEP } from "../methods";
|
|
23
|
+
import type { CheckoutStep, CheckoutStepRender } from "../types";
|
|
24
|
+
|
|
25
|
+
export const HANDOFF_STEP_ID = "handoff";
|
|
26
|
+
export const RESUME_STEP_ID = "resume";
|
|
27
|
+
|
|
28
|
+
/** A raised order whose provider takes the money on its own page. */
|
|
29
|
+
function handedOver(url: string | undefined): url is string {
|
|
30
|
+
return typeof url === "string" && url.length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function PixView({ ctx }: CheckoutStepRender<void>): JSX.Element | null {
|
|
34
|
+
const actions = usePipelineActions();
|
|
35
|
+
const PixPayment = actions.screens.PixPayment;
|
|
36
|
+
if (!ctx.order) return null;
|
|
37
|
+
return <PixPayment payable={ctx.order} onResolved={actions.resolve} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function CardView({ ctx }: CheckoutStepRender<void>): JSX.Element | null {
|
|
41
|
+
const actions = usePipelineActions();
|
|
42
|
+
const CardEntry = actions.screens.CardEntry;
|
|
43
|
+
if (!ctx.order) return null;
|
|
44
|
+
return <CardEntry payable={ctx.order} onResolved={actions.resolve} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Pix code, once one exists.
|
|
49
|
+
*
|
|
50
|
+
* WHICH method this pane belongs to is not asked here: the engine reads it off
|
|
51
|
+
* the descriptor that named this step as its `pane`, so a method the package
|
|
52
|
+
* has never heard of can reuse the pane by naming it, and no pane states its
|
|
53
|
+
* own method twice.
|
|
54
|
+
*/
|
|
55
|
+
export const pixStep: CheckoutStep = {
|
|
56
|
+
id: PIX_PANE_STEP,
|
|
57
|
+
phase: "pay",
|
|
58
|
+
order: 0,
|
|
59
|
+
applies(ctx) {
|
|
60
|
+
return ctx.order !== null && !handedOver(ctx.order.hostedCheckoutUrl);
|
|
61
|
+
},
|
|
62
|
+
complete(ctx) {
|
|
63
|
+
return ctx.outcome !== null;
|
|
64
|
+
},
|
|
65
|
+
render(props) {
|
|
66
|
+
return <PixView {...props} />;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The card form, once an order exists to charge. Its method is its `pane` owner. */
|
|
71
|
+
export const cardStep: CheckoutStep = {
|
|
72
|
+
id: CARD_PANE_STEP,
|
|
73
|
+
phase: "pay",
|
|
74
|
+
order: 1,
|
|
75
|
+
applies(ctx) {
|
|
76
|
+
return ctx.order !== null && !handedOver(ctx.order.hostedCheckoutUrl);
|
|
77
|
+
},
|
|
78
|
+
complete(ctx) {
|
|
79
|
+
return ctx.outcome !== null;
|
|
80
|
+
},
|
|
81
|
+
render(props) {
|
|
82
|
+
return <CardView {...props} />;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The hand-off interstitial: the order is parked and the tab is leaving.
|
|
88
|
+
*
|
|
89
|
+
* `complete` is `false` forever on purpose — there is no next step on THIS
|
|
90
|
+
* page. What comes back is a fresh mount that meets the resume step.
|
|
91
|
+
*/
|
|
92
|
+
function HandoffView({ ctx, back }: CheckoutStepRender<void>): JSX.Element | null {
|
|
93
|
+
const { Text } = useCheckoutComponents();
|
|
94
|
+
const actions = usePipelineActions();
|
|
95
|
+
const HostedHandoff = actions.screens.HostedHandoff;
|
|
96
|
+
const url = ctx.order?.hostedCheckoutUrl;
|
|
97
|
+
if (!ctx.order || !handedOver(url)) return null;
|
|
98
|
+
const pipeline = actions.copy.pipeline;
|
|
99
|
+
const waiting =
|
|
100
|
+
(ctx.method === null ? undefined : pipeline.awaitingHandover[ctx.method]) ?? pipeline.loading;
|
|
101
|
+
return (
|
|
102
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
103
|
+
<Text variant="body" size="sm" color="secondary" as="p" data-testid="checkout-awaiting-handover">
|
|
104
|
+
{waiting}
|
|
105
|
+
</Text>
|
|
106
|
+
<HostedHandoff url={url} payable={ctx.order} onCancel={back} />
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const handoffStep: CheckoutStep = {
|
|
112
|
+
id: HANDOFF_STEP_ID,
|
|
113
|
+
phase: "pay",
|
|
114
|
+
order: 2,
|
|
115
|
+
applies(ctx) {
|
|
116
|
+
return ctx.order !== null && handedOver(ctx.order.hostedCheckoutUrl);
|
|
117
|
+
},
|
|
118
|
+
complete() {
|
|
119
|
+
return false;
|
|
120
|
+
},
|
|
121
|
+
render(props) {
|
|
122
|
+
return <HandoffView {...props} />;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A shopper coming back from a provider's own page.
|
|
128
|
+
*
|
|
129
|
+
* It runs the package's OWN resume machinery — `useHostedResume`, with this
|
|
130
|
+
* checkout's slug and basket — rather than a second reading of the parked
|
|
131
|
+
* entry. That matters more than the extra hook: `useHostedResume` carries the
|
|
132
|
+
* whole FUT-1213 rule, including the deferral until the cart has answered, the
|
|
133
|
+
* bounded wait when it never does, and the one server ASK for a basket that
|
|
134
|
+
* has changed. A step that re-implemented "is something parked?" would be a
|
|
135
|
+
* second answer to a question with one correct one, on the money path.
|
|
136
|
+
*
|
|
137
|
+
* WHERE a resume lands is `hosted-return.ts`'s decision, honoured here:
|
|
138
|
+
*
|
|
139
|
+
* - a HAND-OFF has nothing left on our page, so it waits on the confirmation
|
|
140
|
+
* until the poll reaches a terminal state;
|
|
141
|
+
* - anything else — a Pix code, a card charge raised on our own page — is
|
|
142
|
+
* ADOPTED as this visit's order, and the walk puts the shopper back in
|
|
143
|
+
* front of the pane that still has what they need.
|
|
144
|
+
*/
|
|
145
|
+
export function buildResumeStep(runtime: FlowsRuntime): CheckoutStep<void, HostedResume> {
|
|
146
|
+
return {
|
|
147
|
+
id: RESUME_STEP_ID,
|
|
148
|
+
phase: "before-pay",
|
|
149
|
+
order: -10,
|
|
150
|
+
useFacts() {
|
|
151
|
+
const tenantSlug = runtime.useTenantSlug();
|
|
152
|
+
const cart = runtime.config.useCart();
|
|
153
|
+
return useHostedResume(tenantSlug, cart.identity);
|
|
154
|
+
},
|
|
155
|
+
applies(ctx, resume) {
|
|
156
|
+
return ctx.outcome === null && ctx.order === null && resume.order !== null;
|
|
157
|
+
},
|
|
158
|
+
complete(ctx) {
|
|
159
|
+
return ctx.outcome !== null || ctx.order !== null;
|
|
160
|
+
},
|
|
161
|
+
render({ facts }) {
|
|
162
|
+
return <ResumeView resume={facts} />;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The resumed order, taken up by whichever half of the walk owns it. */
|
|
168
|
+
function ResumeView({ resume }: { resume: HostedResume }): JSX.Element | null {
|
|
169
|
+
const actions = usePipelineActions();
|
|
170
|
+
const { adoptOrder, resolve } = actions;
|
|
171
|
+
const { order, step, status } = resume;
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!order) return;
|
|
174
|
+
// An on-page charge belongs to the WALK, not to this screen: adopting it
|
|
175
|
+
// makes the pane apply on the very next render.
|
|
176
|
+
if (step === "payment") adoptOrder(order);
|
|
177
|
+
else if (status !== null && status !== "AWAITING_PAYMENT") resolve(status);
|
|
178
|
+
}, [order, step, status, adoptOrder, resolve]);
|
|
179
|
+
if (!order || step === "payment") return null;
|
|
180
|
+
const PaymentStatus = actions.screens.PaymentStatus;
|
|
181
|
+
return <PaymentStatus status={status} payable={order} />;
|
|
182
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONFIRMATION (FUT-1240) — the one step every settlement reaches.
|
|
3
|
+
*
|
|
4
|
+
* `complete` is `false` forever: a walk whose last step is finished has
|
|
5
|
+
* nowhere to put the shopper, and this is where a checkout ends. The
|
|
6
|
+
* registered method may replace the screen entirely (`Confirmation` on its
|
|
7
|
+
* descriptor), which is how a settlement that raises no charge says
|
|
8
|
+
* "recebemos seu pedido" instead of "pagamento aprovado" without the engine
|
|
9
|
+
* knowing either sentence.
|
|
10
|
+
*/
|
|
11
|
+
import type { JSX } from "react";
|
|
12
|
+
|
|
13
|
+
import { descriptorFor, usePipelineActions } from "../actions";
|
|
14
|
+
import type { CheckoutStep, CheckoutStepRender } from "../types";
|
|
15
|
+
|
|
16
|
+
export const STATUS_STEP_ID = "status";
|
|
17
|
+
|
|
18
|
+
function StatusView({ ctx }: CheckoutStepRender<void>): JSX.Element {
|
|
19
|
+
const actions = usePipelineActions();
|
|
20
|
+
const descriptor = descriptorFor(actions.methods, ctx);
|
|
21
|
+
const Confirmation = descriptor?.Confirmation;
|
|
22
|
+
if (Confirmation && ctx.order) return <Confirmation ctx={ctx} order={ctx.order} />;
|
|
23
|
+
const PaymentStatus = actions.screens.PaymentStatus;
|
|
24
|
+
return <PaymentStatus status={ctx.outcome} payable={ctx.order} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const statusStep: CheckoutStep = {
|
|
28
|
+
id: STATUS_STEP_ID,
|
|
29
|
+
phase: "after-pay",
|
|
30
|
+
order: 0,
|
|
31
|
+
label: "status",
|
|
32
|
+
applies(ctx) {
|
|
33
|
+
return ctx.outcome !== null;
|
|
34
|
+
},
|
|
35
|
+
complete() {
|
|
36
|
+
return false;
|
|
37
|
+
},
|
|
38
|
+
render(props) {
|
|
39
|
+
return <StatusView {...props} />;
|
|
40
|
+
},
|
|
41
|
+
};
|