@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,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
+ }