@12-apps/payments-frontend 3.21.4 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/checkout/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +387 -0
- package/src/components/checkout/checkout-flow.tsx +126 -24
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +41 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +291 -0
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +42 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +35 -0
- package/src/components/checkout/use-card-checkout.ts +34 -33
- package/src/components/checkout/use-checkout-controller.ts +68 -274
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +70 -0
- package/src/components/checkout/wallet-pane.tsx +9 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +19 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +55 -5
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +29 -19
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE ENGINE DOES WHEN A STEP ASKS (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* One writer per fact, and every one of them here: choosing a method, raising
|
|
5
|
+
* the payable, passing the buyer-details gate, reporting a terminal status,
|
|
6
|
+
* navigating back and leaving. A step calls these; nothing else writes.
|
|
7
|
+
*
|
|
8
|
+
* The payable is raised in ONE place, for every settlement method. What
|
|
9
|
+
* differs for a method that raises no charge is not the call — it is that the
|
|
10
|
+
* engine parks nothing and treats the placement itself as the settlement, so
|
|
11
|
+
* the walk goes straight to the confirmation with no pane in between.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useMemo } from "react";
|
|
14
|
+
|
|
15
|
+
import { buyerFieldsFor } from "../../components/checkout/buyer-fields";
|
|
16
|
+
import { buyerGateError } from "../../components/checkout/buyer-gate";
|
|
17
|
+
import { parkedBasket } from "../../components/checkout/basket";
|
|
18
|
+
import type { CheckoutDecline } from "../../components/checkout/decline";
|
|
19
|
+
import { forgetHostedOrder, rememberHostedOrder } from "../../components/checkout/hosted-return";
|
|
20
|
+
import { handOffMethod, offeredMethods } from "../../components/checkout/method-capability";
|
|
21
|
+
import type {
|
|
22
|
+
BuyerInfo,
|
|
23
|
+
CheckoutOrder,
|
|
24
|
+
CreateOrderRequest,
|
|
25
|
+
OrderStatus,
|
|
26
|
+
PaymentMethod,
|
|
27
|
+
} from "../../components/checkout/types";
|
|
28
|
+
import { useCatalogExit } from "../catalog-exit";
|
|
29
|
+
import type { FlowsRuntime } from "../runtime";
|
|
30
|
+
|
|
31
|
+
import { applyingSteps, raisesCharge, sliceFor } from "./derive-step";
|
|
32
|
+
import type { EngineStore } from "./engine-state";
|
|
33
|
+
import { isPackageMethod } from "./methods";
|
|
34
|
+
import {
|
|
35
|
+
BUYER_FIELD_CODE,
|
|
36
|
+
DADOS_STEP_ID,
|
|
37
|
+
METHOD_STEP_ID,
|
|
38
|
+
dadosSliceOf,
|
|
39
|
+
} from "./steps";
|
|
40
|
+
import type {
|
|
41
|
+
AnyCheckoutGate,
|
|
42
|
+
AnyCheckoutStep,
|
|
43
|
+
AnySettlementMethod,
|
|
44
|
+
CheckoutContext,
|
|
45
|
+
} from "./types";
|
|
46
|
+
|
|
47
|
+
/** Everything one factory's engine closes over. Built once, never per render. */
|
|
48
|
+
export interface PipelineWiring {
|
|
49
|
+
steps: readonly AnyCheckoutStep[];
|
|
50
|
+
gates: readonly AnyCheckoutGate[];
|
|
51
|
+
methods: readonly AnySettlementMethod[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `method` the WIRE carries for a settlement this package cannot name.
|
|
56
|
+
*
|
|
57
|
+
* `method` is what the CHAIN can be asked to charge, so a host's registered id
|
|
58
|
+
* is never one of its values: the chain's own hand-off method is what the
|
|
59
|
+
* server will honour, exactly as it is for a store whose buyer was never asked.
|
|
60
|
+
*
|
|
61
|
+
* WHICH settlement the buyer actually chose rides {@link settlementOnWire}
|
|
62
|
+
* beside it. It has to: `method` alone makes a courier, a waiter and a Pix
|
|
63
|
+
* charge the same request, and `raisesCharge: false` is the capability this
|
|
64
|
+
* step exists for.
|
|
65
|
+
*/
|
|
66
|
+
function wireMethod(id: string, ctx: CheckoutContext): PaymentMethod {
|
|
67
|
+
return isPackageMethod(id) ? id : handOffMethod(offeredMethods(ctx.config));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The chosen id, for a settlement the two package methods do not cover.
|
|
72
|
+
*
|
|
73
|
+
* ABSENT for `PIX` and `CARD` — a host reading only `method` reads exactly the
|
|
74
|
+
* request it read before this field existed, which is the whole of the wire
|
|
75
|
+
* staying additive. A step that wants to say something else still can: its
|
|
76
|
+
* `contribute` returns `Partial<CreateOrderRequest>`, and the contributions are
|
|
77
|
+
* folded over this base.
|
|
78
|
+
*/
|
|
79
|
+
function settlementOnWire(id: string): Pick<CreateOrderRequest, "settlementMethod"> {
|
|
80
|
+
return isPackageMethod(id) ? {} : { settlementMethod: id };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The create request: the method, the buyer, and whatever each step contributes. */
|
|
84
|
+
function createRequest(input: {
|
|
85
|
+
ctx: CheckoutContext;
|
|
86
|
+
applying: readonly AnyCheckoutStep[];
|
|
87
|
+
stepFacts: Readonly<Record<string, unknown>>;
|
|
88
|
+
saveProfile: boolean;
|
|
89
|
+
methodId: string;
|
|
90
|
+
}): CreateOrderRequest {
|
|
91
|
+
const base: CreateOrderRequest = {
|
|
92
|
+
method: wireMethod(input.methodId, input.ctx),
|
|
93
|
+
buyer: input.ctx.buyer,
|
|
94
|
+
saveProfile: input.saveProfile,
|
|
95
|
+
...settlementOnWire(input.methodId),
|
|
96
|
+
};
|
|
97
|
+
return input.applying.reduce<CreateOrderRequest>(
|
|
98
|
+
(request, step) => ({
|
|
99
|
+
...request,
|
|
100
|
+
...step.contribute?.(input.ctx, input.stepFacts[step.id], sliceFor(step, input.ctx)),
|
|
101
|
+
}),
|
|
102
|
+
base,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the writers need from the render they were built in. */
|
|
107
|
+
interface EngineWritersInput {
|
|
108
|
+
runtime: FlowsRuntime;
|
|
109
|
+
wiring: PipelineWiring;
|
|
110
|
+
store: EngineStore;
|
|
111
|
+
ctx: CheckoutContext;
|
|
112
|
+
stepFacts: Readonly<Record<string, unknown>>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The engine's writers. */
|
|
116
|
+
interface EngineWriters {
|
|
117
|
+
choose(methodId: string): void;
|
|
118
|
+
place(): void;
|
|
119
|
+
continueFromDados(): void;
|
|
120
|
+
setBuyer(buyer: BuyerInfo): void;
|
|
121
|
+
setSaveProfile(save: boolean): void;
|
|
122
|
+
resolve(status: OrderStatus, decline?: CheckoutDecline | null): void;
|
|
123
|
+
adoptOrder(order: CheckoutOrder): void;
|
|
124
|
+
exitToCatalog(): void;
|
|
125
|
+
reopen(stepId: string): void;
|
|
126
|
+
openDados(): void;
|
|
127
|
+
retry(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Raise the payable, park it when it can be resumed, and record what came back. */
|
|
131
|
+
function usePlaceOrder(input: EngineWritersInput): (methodId?: string) => Promise<void> {
|
|
132
|
+
const { runtime, store, ctx, wiring, stepFacts } = input;
|
|
133
|
+
const saveProfile = store.state.saveProfile;
|
|
134
|
+
return useCallback(
|
|
135
|
+
async (methodId?: string) => {
|
|
136
|
+
const chosen = methodId ?? ctx.method;
|
|
137
|
+
if (chosen === null) return;
|
|
138
|
+
store.patch({ placing: true, error: null, decline: null });
|
|
139
|
+
// The walk is re-derived FOR THE CHOSEN METHOD, not read off the render
|
|
140
|
+
// that offered the picker: a step whose `applies` asks which settlement
|
|
141
|
+
// this is would otherwise be absent from the request that settles it.
|
|
142
|
+
// ONE context, used for both halves. Deriving the walk against the chosen
|
|
143
|
+
// method and then building the request against the un-overridden `ctx` let
|
|
144
|
+
// a step's `contribute` read `ctx.method === null` on the immediate-place
|
|
145
|
+
// path, which is the path a method with no Review takes — so the field
|
|
146
|
+
// ADOPTING.md says a step may set "when the settlement has a finer name
|
|
147
|
+
// than the tile does" was written from a context that did not know it.
|
|
148
|
+
const applyingCtx = { ...ctx, method: chosen };
|
|
149
|
+
const applying = applyingSteps({
|
|
150
|
+
steps: wiring.steps,
|
|
151
|
+
ctx: applyingCtx,
|
|
152
|
+
facts: stepFacts,
|
|
153
|
+
methods: wiring.methods,
|
|
154
|
+
});
|
|
155
|
+
const request = createRequest({
|
|
156
|
+
ctx: applyingCtx,
|
|
157
|
+
applying,
|
|
158
|
+
stepFacts,
|
|
159
|
+
saveProfile,
|
|
160
|
+
methodId: chosen,
|
|
161
|
+
});
|
|
162
|
+
const result = await runtime.config.ports.createPayable(request);
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
store.patch({ placing: false, error: result.error });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const charges = raisesCharge(chosen, wiring.methods);
|
|
168
|
+
// PARKED ON EVERY RAISE (FUT-1140), with the STORE and the BASKET — a
|
|
169
|
+
// low-memory phone discards this tab while the shopper is in their bank
|
|
170
|
+
// app. A settlement that raises no charge has nothing to come back to.
|
|
171
|
+
if (charges) {
|
|
172
|
+
rememberHostedOrder(result.data, {
|
|
173
|
+
...(ctx.tenantSlug === undefined ? {} : { tenantSlug: ctx.tenantSlug }),
|
|
174
|
+
basket: parkedBasket(ctx.cart.identity),
|
|
175
|
+
handoff: false,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
store.patch({
|
|
179
|
+
placing: false,
|
|
180
|
+
order: result.data,
|
|
181
|
+
// Placing IS the settlement for a no-charge method, so its own status
|
|
182
|
+
// is already the outcome — there is no pane and no poll to wait for.
|
|
183
|
+
outcome: charges ? null : result.data.status,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
[runtime, store, ctx, wiring, stepFacts, saveProfile],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** "Continuar" on the buyer-details step: gate on the chain's demands, then persist. */
|
|
191
|
+
function useContinueFromDados(input: EngineWritersInput): () => void {
|
|
192
|
+
const { runtime, store, ctx } = input;
|
|
193
|
+
const copy = runtime.config.copy.views.screens.screens.validation;
|
|
194
|
+
const fields = useMemo(() => buyerFieldsFor(ctx.config?.chain, null), [ctx.config]);
|
|
195
|
+
return useCallback(() => {
|
|
196
|
+
const complaint = buyerGateError(copy, ctx.buyer, fields, ctx.taxIdOnFile);
|
|
197
|
+
if (complaint) {
|
|
198
|
+
store.patch({
|
|
199
|
+
error: { code: BUYER_FIELD_CODE, message: complaint.message, field: complaint.field },
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// The write happens HERE and not when a payment is raised: everything after
|
|
204
|
+
// this step can fail, and the details must survive all of it (FUT-465).
|
|
205
|
+
if (store.state.saveProfile) {
|
|
206
|
+
runtime.config.ports.saveBuyerContact?.({
|
|
207
|
+
...(ctx.buyer.name === undefined ? {} : { name: ctx.buyer.name }),
|
|
208
|
+
...(ctx.buyer.phone === undefined ? {} : { phone: ctx.buyer.phone }),
|
|
209
|
+
...(ctx.buyer.taxId === undefined ? {} : { taxId: ctx.buyer.taxId }),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
store.setSlice(DADOS_STEP_ID, { ...dadosSliceOf(ctx), done: true });
|
|
213
|
+
store.patch({ reopened: null, error: null });
|
|
214
|
+
}, [copy, ctx, fields, runtime, store]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Every writer, bound to this render's context. */
|
|
218
|
+
export function useEngineWriters(input: EngineWritersInput): EngineWriters {
|
|
219
|
+
const { runtime, store, ctx, wiring } = input;
|
|
220
|
+
const placeOrder = usePlaceOrder(input);
|
|
221
|
+
const continueFromDados = useContinueFromDados(input);
|
|
222
|
+
const exitToCatalog = useCatalogExit(runtime);
|
|
223
|
+
const choose = useCallback(
|
|
224
|
+
(methodId: string) => {
|
|
225
|
+
// A different method means the order raised for the previous one is
|
|
226
|
+
// gone — and the parked entry with it, scoped to THIS store so another
|
|
227
|
+
// store's checkout in the same tab keeps its own.
|
|
228
|
+
store.setSlice(METHOD_STEP_ID, { chosen: methodId });
|
|
229
|
+
store.patch({ order: null, error: null, decline: null, reopened: null });
|
|
230
|
+
forgetHostedOrder(ctx.tenantSlug);
|
|
231
|
+
const descriptor = wiring.methods.find((entry) => entry.id === methodId);
|
|
232
|
+
if (!descriptor?.Review) void placeOrder(methodId);
|
|
233
|
+
},
|
|
234
|
+
[store, ctx.tenantSlug, wiring.methods, placeOrder],
|
|
235
|
+
);
|
|
236
|
+
return useMemo(
|
|
237
|
+
() => ({
|
|
238
|
+
choose,
|
|
239
|
+
continueFromDados,
|
|
240
|
+
exitToCatalog,
|
|
241
|
+
place: () => void placeOrder(),
|
|
242
|
+
setBuyer: (buyer: BuyerInfo) =>
|
|
243
|
+
store.patch({ buyer, buyerTouched: true, error: null }),
|
|
244
|
+
setSaveProfile: (saveProfile: boolean) => store.patch({ saveProfile }),
|
|
245
|
+
resolve: (status: OrderStatus, decline?: CheckoutDecline | null) =>
|
|
246
|
+
store.patch({ outcome: status, decline: decline ?? null, reopened: null, error: null }),
|
|
247
|
+
adoptOrder: (order: CheckoutOrder) => store.patch({ order, error: null }),
|
|
248
|
+
reopen: (stepId: string) => store.patch({ reopened: stepId }),
|
|
249
|
+
openDados: () => {
|
|
250
|
+
store.setSlice(DADOS_STEP_ID, { opened: true, done: false });
|
|
251
|
+
store.patch({ reopened: null, error: null });
|
|
252
|
+
},
|
|
253
|
+
retry: () => store.patch({ error: null, decline: null, reopened: null }),
|
|
254
|
+
}),
|
|
255
|
+
[choose, continueFromDados, exitToCatalog, placeOrder, store],
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -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
|
+
}
|