@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.
Files changed (43) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/checkout-actions.ts +55 -9
  3. package/src/components/checkout/checkout-flow.tsx +15 -7
  4. package/src/components/checkout/confirmation-wait.ts +97 -0
  5. package/src/components/checkout/en-US.ts +8 -0
  6. package/src/components/checkout/failure-codes.ts +82 -0
  7. package/src/components/checkout/hosted-store.ts +23 -1
  8. package/src/components/checkout/payment-error-panel.tsx +9 -3
  9. package/src/components/checkout/pix-view.tsx +97 -8
  10. package/src/components/checkout/poll-loop.ts +5 -3
  11. package/src/components/checkout/pt-BR.ts +9 -0
  12. package/src/components/checkout/resolution-actions.ts +43 -0
  13. package/src/components/checkout/types.ts +11 -0
  14. package/src/components/checkout/use-card-checkout.ts +3 -2
  15. package/src/components/checkout/use-checkout-controller.ts +23 -16
  16. package/src/components/checkout/use-payment-polling.ts +58 -7
  17. package/src/components/checkout/use-wallet-charge.ts +24 -1
  18. package/src/components/checkout/view-copy.ts +38 -0
  19. package/src/components/checkout/wallet-pane.tsx +6 -1
  20. package/src/flows/catalog-exit.ts +33 -0
  21. package/src/flows/create-payment-flows.tsx +12 -1
  22. package/src/flows/pipeline/actions.tsx +104 -0
  23. package/src/flows/pipeline/admission.ts +55 -0
  24. package/src/flows/pipeline/context.ts +140 -0
  25. package/src/flows/pipeline/derive-step.ts +234 -0
  26. package/src/flows/pipeline/engine-actions.ts +257 -0
  27. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  28. package/src/flows/pipeline/engine-state.ts +107 -0
  29. package/src/flows/pipeline/engine.tsx +377 -0
  30. package/src/flows/pipeline/methods.ts +71 -0
  31. package/src/flows/pipeline/refusal-routing.ts +106 -0
  32. package/src/flows/pipeline/slices.ts +110 -0
  33. package/src/flows/pipeline/stable-plugins.ts +72 -0
  34. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  35. package/src/flows/pipeline/steps/index.ts +54 -0
  36. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  37. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  38. package/src/flows/pipeline/types.ts +232 -0
  39. package/src/flows/public.ts +78 -0
  40. package/src/flows/screens-hosted.tsx +37 -4
  41. package/src/flows/screens-pay.tsx +6 -1
  42. package/src/flows/types.ts +25 -2
  43. package/src/index.ts +7 -19
@@ -0,0 +1,106 @@
1
+ /**
2
+ * WHERE A SERVER REFUSAL IS RE-RENDERED (FUT-1240).
3
+ *
4
+ * Every refusal the checkout POST can answer with carries a machine `code`.
5
+ * A code belongs to whichever plugin can do something about it: the step that
6
+ * owns the field, or the gate that owns the fact. `DELIVERY_ADDRESS_REQUIRED`
7
+ * is the address step's; `STORE_CLOSED` is the open-store gate's. Routed that
8
+ * way, the shopper meets the form or the curtain that can actually change the
9
+ * answer.
10
+ *
11
+ * What this replaces is a single generic retry. A refusal rendered as "não foi
12
+ * possível, tente novamente" beside a button that re-sends the same request is
13
+ * a loop with no exit — the shopper presses it, the server refuses for the same
14
+ * reason, and nothing on the screen names the reason or offers the remedy.
15
+ *
16
+ * ## A code nobody claimed is still shown
17
+ *
18
+ * The fallback is deliberate and is the reason a host can adopt this
19
+ * incrementally: an unclaimed code renders on the CURRENT step as today's
20
+ * refusal. A host that wants the compile-time guarantee declares its own
21
+ * exhaustive owner map (`REFUSAL_OWNERS satisfies Record<Code, …>`); the
22
+ * package keeps the runtime answer for every host that has not.
23
+ *
24
+ * The same fallback carries a code whose owning GATE is not refusing — see
25
+ * {@link errorForStep}. Routing is about finding the screen that can change the
26
+ * answer; when there is no such screen up, the shopper still has to be told.
27
+ */
28
+ import type { CheckoutError } from "../../components/checkout/types";
29
+
30
+ import type { AnyCheckoutGate, AnyCheckoutStep } from "./types";
31
+
32
+ /** Who answers a refusal code. */
33
+ export type RefusalOwner =
34
+ | { kind: "step"; id: string }
35
+ | { kind: "gate"; id: string }
36
+ | { kind: "retry" };
37
+
38
+ /** Nothing claimed it — the current step shows it, exactly as today. */
39
+ const RETRY: RefusalOwner = { kind: "retry" };
40
+
41
+ /**
42
+ * The plugin whose `answersCodes` names this code.
43
+ *
44
+ * STEPS are asked first. A gate and a step can both legitimately claim a code
45
+ * — a delivery gate that refuses an unserviceable address and an address step
46
+ * that owns the field — and when they do, the one the shopper can TYPE INTO is
47
+ * the more useful screen.
48
+ */
49
+ export function refusalOwner(
50
+ code: string | null | undefined,
51
+ steps: readonly AnyCheckoutStep[],
52
+ gates: readonly AnyCheckoutGate[],
53
+ ): RefusalOwner {
54
+ if (!code) return RETRY;
55
+ const step = steps.find((entry) => entry.answersCodes?.includes(code));
56
+ if (step) return { kind: "step", id: step.id };
57
+ const gate = gates.find((entry) => entry.answersCodes?.includes(code));
58
+ if (gate) return { kind: "gate", id: gate.id };
59
+ return RETRY;
60
+ }
61
+
62
+ /**
63
+ * The error THIS step should render, or `null`.
64
+ *
65
+ * A step-claimed refusal reaches its claimant and nobody else, so a step never
66
+ * renders a complaint about a field it does not draw. Everything else reaches
67
+ * whichever step the shopper is on.
68
+ *
69
+ * ## A GATE's code reaching a step means that gate is PASSING
70
+ *
71
+ * A gate that would refuse curtains the checkout before any step renders, and
72
+ * its own `Screen` is handed the error there — so the walk is reached only with
73
+ * every gate passing, and the only gate-claimed refusal a step ever sees is one
74
+ * no curtain is going to draw. Dropping it here loses it completely: the
75
+ * shopper presses pay, the server refuses, and nothing on the screen changes or
76
+ * names the reason. So it falls back to the unclaimed behaviour instead, which
77
+ * is the one that always reaches somebody.
78
+ *
79
+ * That is not a weaker routing than "a gate's code re-renders the gate" — it is
80
+ * the same routing, with the case the gate cannot answer handed on rather than
81
+ * swallowed. A gate that DOES want the screen refuses, and then it gets it.
82
+ */
83
+ export function errorForStep(
84
+ error: CheckoutError | null,
85
+ owner: RefusalOwner,
86
+ stepId: string,
87
+ currentStepId: string | null,
88
+ ): CheckoutError | null {
89
+ if (!error) return null;
90
+ if (owner.kind === "step") return owner.id === stepId ? error : null;
91
+ return stepId === currentStepId ? error : null;
92
+ }
93
+
94
+ /**
95
+ * A refusal claimed by a STEP re-opens that step, whichever one the shopper
96
+ * was on. That is the whole of "re-rendered THERE": the answer moves the
97
+ * shopper to the screen that can change it, rather than describing it where
98
+ * they happen to be standing.
99
+ */
100
+ export function refusalStepOverride(
101
+ error: CheckoutError | null,
102
+ owner: RefusalOwner,
103
+ ): string | null {
104
+ if (!error || owner.kind !== "step") return null;
105
+ return owner.id;
106
+ }
@@ -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
+ }