@12-apps/payments-frontend 3.22.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/checkout/checkout-actions.ts +55 -9
- package/src/components/checkout/checkout-flow.tsx +15 -7
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/en-US.ts +8 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-store.ts +23 -1
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/pt-BR.ts +9 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/use-card-checkout.ts +3 -2
- package/src/components/checkout/use-checkout-controller.ts +19 -5
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +38 -0
- package/src/components/checkout/wallet-pane.tsx +6 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +12 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +37 -4
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +7 -19
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -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
|
+
}
|