@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,387 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
|
|
3
|
+
import { buyerGateError } from "./buyer-gate";
|
|
4
|
+
import type { ConfirmationWait } from "./confirmation-wait";
|
|
5
|
+
import type { CheckoutDecline } from "./decline";
|
|
6
|
+
import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
|
|
7
|
+
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
8
|
+
import type { CheckoutNavigate } from "./navigate-context";
|
|
9
|
+
import type { CheckoutScreensCopy } from "./screens-copy";
|
|
10
|
+
import type {
|
|
11
|
+
BuyerContact,
|
|
12
|
+
BuyerField,
|
|
13
|
+
BuyerInfo,
|
|
14
|
+
CheckoutCustomerField,
|
|
15
|
+
CheckoutOrder,
|
|
16
|
+
CreateOrderRequest,
|
|
17
|
+
CreateOrderResult,
|
|
18
|
+
OrderStatus,
|
|
19
|
+
PaymentMethod,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import { useResumeEffect, type HostedResume } from "./use-hosted-resume";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The controller's own moving parts, one concern per hook.
|
|
25
|
+
*
|
|
26
|
+
* Split out of `./use-checkout-controller.ts` when the resumed leg grew a
|
|
27
|
+
* decision (FUT-1213), a general parked entry (FUT-1140) and a way out
|
|
28
|
+
* (FUT-1146) and that file's one exported function reached its size gate. The
|
|
29
|
+
* seam is the obvious one: everything here is state a step reads, and nothing
|
|
30
|
+
* here knows the ORDER the steps come in.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Where the flow can be — the step ids, which are the flow's own contract. */
|
|
34
|
+
export type Step = "dados" | "payment" | "status";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The flow's navigation actions, split out of {@link useCheckoutController} for
|
|
38
|
+
* the 80-line per-function gate.
|
|
39
|
+
*
|
|
40
|
+
* `back` is where "the Dados step was skipped" has to be honoured: going back
|
|
41
|
+
* off Pagamento normally lands on Dados, but for a buyer with a CPF on file
|
|
42
|
+
* that step is not part of their flow, so the menu is the only honest
|
|
43
|
+
* destination — until they open it themselves via `editBuyer` ("alterar" on the
|
|
44
|
+
* payer block), after which it IS part of their flow and back returns to it.
|
|
45
|
+
*/
|
|
46
|
+
export function useCheckoutNav(
|
|
47
|
+
taxIdOnFile: boolean,
|
|
48
|
+
goToMenu: () => void,
|
|
49
|
+
setStep: Dispatch<SetStateAction<Step>>,
|
|
50
|
+
): { back: () => void; editBuyer: (() => void) | undefined } {
|
|
51
|
+
const [dadosOpened, setDadosOpened] = useState(false);
|
|
52
|
+
const openDados = useCallback(() => {
|
|
53
|
+
setDadosOpened(true);
|
|
54
|
+
setStep("dados");
|
|
55
|
+
}, [setStep]);
|
|
56
|
+
const back = useCallback(() => {
|
|
57
|
+
setStep((current) => {
|
|
58
|
+
if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
|
|
59
|
+
goToMenu();
|
|
60
|
+
return current;
|
|
61
|
+
});
|
|
62
|
+
}, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
|
|
63
|
+
// Undefined unless Dados was skipped — the payer block keys off its presence,
|
|
64
|
+
// so the decision lives here rather than being re-derived by every caller.
|
|
65
|
+
return { back, editBuyer: taxIdOnFile ? openDados : undefined };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* WHAT THE CONFIRMATION SCREEN KNOWS ABOUT ITS WAIT, from whichever wait is
|
|
70
|
+
* live.
|
|
71
|
+
*
|
|
72
|
+
* Two can reach that screen and they are mutually exclusive by construction: a
|
|
73
|
+
* checkout RESUMED from a parked entry polls through `useHostedResume`, and one
|
|
74
|
+
* that never left this tab polls through the confirmation wait (FUT-1170) — and
|
|
75
|
+
* that one stands down whenever a resumed order is what put the flow here. So
|
|
76
|
+
* the merge below is a choice between one live wait and one inert one, never a
|
|
77
|
+
* blend of two opinions.
|
|
78
|
+
*
|
|
79
|
+
* All of it is inert for a checkout with an answer already: with nothing to
|
|
80
|
+
* wait on both polls are disabled, so the error stays null, no bound elapses,
|
|
81
|
+
* and none of the actions has a wait to act on.
|
|
82
|
+
*
|
|
83
|
+
* `release` stays the resumed leg's alone. "Não consegui pagar" exists because
|
|
84
|
+
* a provider's own page produces no signal when a buyer abandons it (FUT-1146);
|
|
85
|
+
* a charge raised and still held on THIS page has no such gap to close.
|
|
86
|
+
*/
|
|
87
|
+
export function resumeSurface(
|
|
88
|
+
resume: HostedResume,
|
|
89
|
+
confirming: ConfirmationWait,
|
|
90
|
+
): {
|
|
91
|
+
awaitingTimedOut: boolean;
|
|
92
|
+
awaitingError: string | null;
|
|
93
|
+
awaitingCheckAgain: () => void;
|
|
94
|
+
/** @deprecated Renamed to `awaitingTimedOut`; kept so the rename ships additively. */
|
|
95
|
+
resumeTimedOut: boolean;
|
|
96
|
+
/** @deprecated Renamed to `awaitingError`; kept so the rename ships additively. */
|
|
97
|
+
resumeError: string | null;
|
|
98
|
+
/** @deprecated Renamed to `awaitingCheckAgain`; kept so the rename ships additively. */
|
|
99
|
+
resumeCheckAgain: () => void;
|
|
100
|
+
resumeRelease: (() => void) | undefined;
|
|
101
|
+
resumeReleasing: boolean;
|
|
102
|
+
} {
|
|
103
|
+
const live = resume.order === null ? confirming : resume;
|
|
104
|
+
return {
|
|
105
|
+
awaitingTimedOut: live.timedOut,
|
|
106
|
+
awaitingError: live.error,
|
|
107
|
+
awaitingCheckAgain: live.checkAgain,
|
|
108
|
+
// The three old spellings, beside the new ones rather than replaced by them.
|
|
109
|
+
// This shape is PUBLIC — `CheckoutController` is `ReturnType<typeof
|
|
110
|
+
// useCheckoutController>` (flows/types.ts:148), re-exported from index.ts,
|
|
111
|
+
// and `PaymentFlows.useCheckout()` returns it. Dropping three members of it
|
|
112
|
+
// is a source break for any adopter that hand-composes the flow, and this
|
|
113
|
+
// release is a patch. The rename was cosmetic; breaking a published type for
|
|
114
|
+
// it is not a trade worth making, so the old names stay until a major.
|
|
115
|
+
resumeTimedOut: live.timedOut,
|
|
116
|
+
resumeError: live.error,
|
|
117
|
+
resumeCheckAgain: live.checkAgain,
|
|
118
|
+
resumeRelease: resume.release,
|
|
119
|
+
resumeReleasing: resume.releasing,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Open the flow on whatever was resumed, and close it again if the buyer
|
|
125
|
+
* releases the order (FUT-1140/FUT-1146).
|
|
126
|
+
*
|
|
127
|
+
* A LAYOUT effect, through `useResumeEffect`: the decision cannot be made
|
|
128
|
+
* during render (it waits for the host's cart), and an ordinary effect runs
|
|
129
|
+
* after paint — so a buyer coming back from a payment would see one frame of
|
|
130
|
+
* the Dados or Pagamento step before their confirmation replaced it.
|
|
131
|
+
*/
|
|
132
|
+
export function useResumedCheckout(
|
|
133
|
+
resume: HostedResume,
|
|
134
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
|
|
135
|
+
setStep: Dispatch<SetStateAction<Step>>,
|
|
136
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>,
|
|
137
|
+
setMethod: Dispatch<SetStateAction<PaymentMethod | null>>,
|
|
138
|
+
): void {
|
|
139
|
+
const { order, step, released } = resume;
|
|
140
|
+
useResumeEffect(() => {
|
|
141
|
+
if (!order || !step) return;
|
|
142
|
+
setOrder(order);
|
|
143
|
+
setStep(step);
|
|
144
|
+
// The METHOD comes back with it, which matters for a resume that lands on
|
|
145
|
+
// the payment step: the picker keys off it, and a shopper looking at the
|
|
146
|
+
// PIX code they were already paying must not also be asked to choose how
|
|
147
|
+
// to pay. Set through the raw setter on purpose — the public `setMethod`
|
|
148
|
+
// drops the order on a change, which is the opposite of resuming one.
|
|
149
|
+
setMethod(order.method);
|
|
150
|
+
}, [order, step, setOrder, setStep, setMethod]);
|
|
151
|
+
useResumeEffect(() => {
|
|
152
|
+
if (!released) return;
|
|
153
|
+
// Back to a checkout they can actually use, with the basket they are
|
|
154
|
+
// holding. The order they released is gone from this screen; whether it is
|
|
155
|
+
// gone at the provider is the server's answer, not ours.
|
|
156
|
+
setOrder(null);
|
|
157
|
+
setFinalStatus(null);
|
|
158
|
+
setStep("payment");
|
|
159
|
+
}, [released, setOrder, setStep, setFinalStatus]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The end of a checkout: tell the host, and let the parked entry go.
|
|
164
|
+
*
|
|
165
|
+
* PAID fires the host's `onPaid` port. FUT-601 made the SERVER empty the cart
|
|
166
|
+
* inside the confirmation transaction — but nothing told the SPA, whose cart
|
|
167
|
+
* provider survives every checkout route change and kept counting the items the
|
|
168
|
+
* buyer had just bought. A FAILED or EXPIRED order fires nothing: that shopper
|
|
169
|
+
* still has a basket to retry with, and the host must not be told otherwise.
|
|
170
|
+
*
|
|
171
|
+
* The parked entry is dropped on ANY terminal status (FUT-1140). It exists to
|
|
172
|
+
* carry one payment across a torn-down SPA; once that payment has an answer,
|
|
173
|
+
* resuming it could only re-show an outcome the buyer has already been given.
|
|
174
|
+
*/
|
|
175
|
+
export function useSettledPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!settled || settled === "AWAITING_PAYMENT") return;
|
|
178
|
+
forgetHostedOrder();
|
|
179
|
+
if (settled === "PAID") onPaid?.();
|
|
180
|
+
}, [settled, onPaid]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The create-order refusal the steps render: what to say, which field to
|
|
185
|
+
* highlight, and the machine CODE that decides how it is presented — an
|
|
186
|
+
* unresolved charge is not a failed one, and the Pagamento step must not offer
|
|
187
|
+
* it a "Tentar novamente" (FUT-563). One hook so the three always move
|
|
188
|
+
* together; they were three `useState`s that could be cleared apart.
|
|
189
|
+
*/
|
|
190
|
+
export function useCreateFailure() {
|
|
191
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
192
|
+
const [field, setField] = useState<BuyerField | null>(null);
|
|
193
|
+
const [code, setCode] = useState<string | null>(null);
|
|
194
|
+
const clear = useCallback(() => {
|
|
195
|
+
setMessage(null);
|
|
196
|
+
setField(null);
|
|
197
|
+
setCode(null);
|
|
198
|
+
}, []);
|
|
199
|
+
const fail = useCallback(
|
|
200
|
+
(next: { message: string; field?: BuyerField | null; code?: string }) => {
|
|
201
|
+
setMessage(next.message);
|
|
202
|
+
setField(next.field ?? null);
|
|
203
|
+
setCode(next.code ?? null);
|
|
204
|
+
},
|
|
205
|
+
[],
|
|
206
|
+
);
|
|
207
|
+
return { message, field, code, clear, fail };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* "Continuar" on the Dados step: gate on what the store's chain demands, then
|
|
213
|
+
* PERSIST the buyer's details before advancing.
|
|
214
|
+
*
|
|
215
|
+
* The write happens HERE and not when a payment is raised, because everything
|
|
216
|
+
* after this step can fail — no provider configured, a declined card, an
|
|
217
|
+
* abandoned PIX, a closed tab — and the details must survive all of it.
|
|
218
|
+
* Fire-and-forget on purpose: making the buyer wait on the write, or blocking
|
|
219
|
+
* them when it fails, would trade the bug for a worse one. Gated on the
|
|
220
|
+
* "salvar meus dados" consent (LGPD), which is what the checkbox means.
|
|
221
|
+
*/
|
|
222
|
+
export function useGoToPayment(input: {
|
|
223
|
+
buyer: BuyerInfo;
|
|
224
|
+
buyerFields: readonly CheckoutCustomerField[];
|
|
225
|
+
taxIdOnFile: boolean;
|
|
226
|
+
saveProfile: boolean;
|
|
227
|
+
saveBuyerContact: ((contact: BuyerContact) => void) | undefined;
|
|
228
|
+
validation: CheckoutScreensCopy["validation"];
|
|
229
|
+
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null }) => void };
|
|
230
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
231
|
+
}): () => void {
|
|
232
|
+
const { buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact } = input;
|
|
233
|
+
const { validation, failure, setStep } = input;
|
|
234
|
+
return useCallback(() => {
|
|
235
|
+
failure.clear();
|
|
236
|
+
const complaint = buyerGateError(validation, buyer, buyerFields, taxIdOnFile);
|
|
237
|
+
if (complaint) {
|
|
238
|
+
failure.fail(complaint);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (saveProfile) {
|
|
242
|
+
saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
|
|
243
|
+
}
|
|
244
|
+
setStep("payment");
|
|
245
|
+
}, [buyer, buyerFields, saveProfile, taxIdOnFile, failure, saveBuyerContact, validation, setStep]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* "Tentar novamente" after a refusal — and WHICH order it goes against.
|
|
250
|
+
*
|
|
251
|
+
* A RETRIABLE decline keeps the order (FUT-1145). Minting a new one for every
|
|
252
|
+
* refused card leaves a trail of failed orders in the buyer's own history for
|
|
253
|
+
* one purchase they are still trying to make, and the money rule is unchanged
|
|
254
|
+
* either way: the server refuses a second charge on a payable it has already
|
|
255
|
+
* settled. `freshInstrument` is the other half — the saved card that just
|
|
256
|
+
* failed is not chosen for them again.
|
|
257
|
+
*/
|
|
258
|
+
export function useRetryAction(input: {
|
|
259
|
+
decline: CheckoutDecline | null;
|
|
260
|
+
order: CheckoutOrder | null;
|
|
261
|
+
clearError: () => void;
|
|
262
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
263
|
+
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
264
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
265
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
266
|
+
setFreshInstrument: Dispatch<SetStateAction<boolean>>;
|
|
267
|
+
}): () => void {
|
|
268
|
+
const { decline, order, clearError, setOrder, setDecline } = input;
|
|
269
|
+
const { setFinalStatus, setStep, setFreshInstrument } = input;
|
|
270
|
+
return useCallback(() => {
|
|
271
|
+
setFinalStatus(null);
|
|
272
|
+
clearError();
|
|
273
|
+
setStep("payment");
|
|
274
|
+
if (decline?.retriable && order) {
|
|
275
|
+
setFreshInstrument(true);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
setOrder(null);
|
|
279
|
+
setDecline(null);
|
|
280
|
+
forgetHostedOrder();
|
|
281
|
+
}, [
|
|
282
|
+
clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Hand the buyer to a redirect provider's own page, if that is where this
|
|
288
|
+
* charge settles (FUT-556).
|
|
289
|
+
*
|
|
290
|
+
* Called BEFORE the order is stored: storing it first would render the PIX or
|
|
291
|
+
* card step for a provider that returned neither, which is the dead end this
|
|
292
|
+
* fixes.
|
|
293
|
+
*
|
|
294
|
+
* A full navigation rather than the host's router, because the destination is
|
|
295
|
+
* another origin. The return trip comes back to this same checkout route
|
|
296
|
+
* carrying `transaction_nsu` + `slug`, which the status poll already reads.
|
|
297
|
+
*
|
|
298
|
+
* @returns true when the buyer is on their way and the caller must stop.
|
|
299
|
+
*/
|
|
300
|
+
function handOverToProvider(
|
|
301
|
+
order: CheckoutOrder,
|
|
302
|
+
navigate: CheckoutNavigate,
|
|
303
|
+
tenantSlug?: string,
|
|
304
|
+
basket?: CheckoutBasketIdentity,
|
|
305
|
+
): boolean {
|
|
306
|
+
if (!order.hostedCheckoutUrl) return false;
|
|
307
|
+
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
308
|
+
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
309
|
+
// any later write lands.
|
|
310
|
+
//
|
|
311
|
+
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
312
|
+
// storefront every store shares an origin. Without the slug, abandoning this
|
|
313
|
+
// hand-off and opening another store's checkout resumed THIS order there.
|
|
314
|
+
//
|
|
315
|
+
// So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
|
|
316
|
+
// itself over the shopper's next basket, and the only way to tell the two
|
|
317
|
+
// apart later is to record which basket this one was raised from.
|
|
318
|
+
rememberHostedOrder(order, {
|
|
319
|
+
tenantSlug,
|
|
320
|
+
basket: parkedBasket(basket),
|
|
321
|
+
handoff: true,
|
|
322
|
+
});
|
|
323
|
+
navigate(order.hostedCheckoutUrl);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Raise the order for a chosen method, and decide what happens to it.
|
|
329
|
+
*
|
|
330
|
+
* Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
|
|
331
|
+
* this page for the provider's own, and an order raised here.
|
|
332
|
+
*/
|
|
333
|
+
export function useStartPayment(input: {
|
|
334
|
+
buyer: BuyerInfo;
|
|
335
|
+
saveProfile: boolean;
|
|
336
|
+
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
337
|
+
navigate: CheckoutNavigate;
|
|
338
|
+
tenantSlug: string | undefined;
|
|
339
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
340
|
+
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
|
|
341
|
+
setCreating: Dispatch<SetStateAction<boolean>>;
|
|
342
|
+
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
343
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
344
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
345
|
+
}): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
|
|
346
|
+
const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
|
|
347
|
+
const { setCreating, setDecline, setOrder, setFinalStatus } = input;
|
|
348
|
+
return useCallback(
|
|
349
|
+
async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
350
|
+
failure.clear();
|
|
351
|
+
setDecline(null);
|
|
352
|
+
// THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
|
|
353
|
+
// rather than after it. Raising a payment means whatever was on screen is
|
|
354
|
+
// no longer the one being paid, and a provider round trip is long enough
|
|
355
|
+
// for the difference to matter: "Gerar novo código" left the expired
|
|
356
|
+
// charge mounted, so its own view polled it, got the terminal EXPIRED it
|
|
357
|
+
// was always going to get, and bounced the flow to the confirmation
|
|
358
|
+
// screen — where the new charge then landed with nothing polling it.
|
|
359
|
+
//
|
|
360
|
+
// A no-op on every other caller (the auto-raise, the alternate e-mail and
|
|
361
|
+
// the retry all run with no order held), which is the point: the clear
|
|
362
|
+
// belongs to what raising a payment MEANS, not to the one path that
|
|
363
|
+
// noticed.
|
|
364
|
+
setOrder(null);
|
|
365
|
+
setFinalStatus(null);
|
|
366
|
+
setCreating(true);
|
|
367
|
+
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
368
|
+
setCreating(false);
|
|
369
|
+
if (!result.ok) {
|
|
370
|
+
failure.fail(result.error);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
|
|
374
|
+
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
375
|
+
// discards this tab while the shopper is in their bank app, and the SPA
|
|
376
|
+
// that comes back has never heard of the order it raised — so the buyer
|
|
377
|
+
// meets an empty cart and a retry button instead of the confirmation for
|
|
378
|
+
// the payment they just made.
|
|
379
|
+
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
380
|
+
setOrder(result.data);
|
|
381
|
+
},
|
|
382
|
+
[
|
|
383
|
+
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
384
|
+
setCreating, setDecline, setOrder, setFinalStatus,
|
|
385
|
+
],
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
2
|
import { useMemo, type JSX, type ReactNode } from "react";
|
|
3
3
|
|
|
4
|
+
import type { CheckoutBasketIdentity } from "./basket";
|
|
4
5
|
import { buyerFieldsFor } from "./buyer-fields";
|
|
5
|
-
import {
|
|
6
|
+
import { EmptyCart, PaymentStep } from "./checkout-steps";
|
|
7
|
+
import { DadosStep } from "./dados-step";
|
|
6
8
|
import { ArrowBackIcon } from "./icons";
|
|
7
9
|
import { PaymentStatus } from "./payment-status";
|
|
8
10
|
import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
|
|
@@ -29,6 +31,18 @@ export interface CheckoutCartView {
|
|
|
29
31
|
totalItems: number;
|
|
30
32
|
/** Host-rendered discount itemization under the pay-bar total (FUT-246). */
|
|
31
33
|
discountLines?: ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* WHICH basket this is, so a payment raised from another one cannot resume
|
|
36
|
+
* itself over it (FUT-1213).
|
|
37
|
+
*
|
|
38
|
+
* Optional, and absent means the pre-1213 behaviour — a parked payment
|
|
39
|
+
* resumes on whatever checkout mounts next, which is what shipped and what
|
|
40
|
+
* this ticket exists to bound. A host supplies it by calling
|
|
41
|
+
* `basketSignature(lines)` on its own cart and passing `ready: false` while
|
|
42
|
+
* that cart is still loading; see `./basket.ts` for why the identity is the
|
|
43
|
+
* LINES and not the cart's id.
|
|
44
|
+
*/
|
|
45
|
+
identity?: CheckoutBasketIdentity;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/**
|
|
@@ -84,6 +98,16 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
|
84
98
|
components?: Partial<CheckoutComponents>;
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Whether the host's cart has actually answered yet.
|
|
103
|
+
*
|
|
104
|
+
* `true` for every host that wires no identity, which is what it meant before
|
|
105
|
+
* there was one to wire.
|
|
106
|
+
*/
|
|
107
|
+
function cartLoaded(cart: CheckoutCartView): boolean {
|
|
108
|
+
return cart.identity?.ready !== false;
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
/** The pay-bar total override when settling a settlement (else the cart's own totals). */
|
|
88
112
|
function settlementTotalOverride(
|
|
89
113
|
settlement: SettlementCheckout | null | undefined,
|
|
@@ -173,12 +197,101 @@ function StatusStep({
|
|
|
173
197
|
onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
|
|
174
198
|
onBackToMenu={c.goToMenu}
|
|
175
199
|
paidExtra={confirmationExtra}
|
|
176
|
-
awaitingTimedOut={c.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
200
|
+
awaitingTimedOut={c.awaitingTimedOut}
|
|
201
|
+
// How the wait behind this screen is going, and the way out of it
|
|
202
|
+
// (FUT-1144). It is the resumed leg's wait for a checkout that came back
|
|
203
|
+
// from a provider's page, and this step's own (FUT-1170) for one that
|
|
204
|
+
// never left the tab — which used to have no wait at all, and so a
|
|
205
|
+
// spinner that stood for nothing.
|
|
206
|
+
awaitingError={c.awaitingError}
|
|
207
|
+
onCheckAgain={c.awaitingCheckAgain}
|
|
208
|
+
// The buyer's own way out of a hosted wait with no terminal state
|
|
209
|
+
// (FUT-1146). Absent — and so unrendered — until the resumed leg has one
|
|
210
|
+
// to offer, which is every checkout that never left this tab.
|
|
211
|
+
onNotPaid={c.resumeRelease}
|
|
212
|
+
releasing={c.resumeReleasing}
|
|
213
|
+
// WHY a card was refused, when the server said (FUT-1145). Read on
|
|
214
|
+
// FAILED only, where it picks the sentence and decides whether a retry
|
|
215
|
+
// could work at all.
|
|
216
|
+
decline={c.decline}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Nothing to check out — and the cart has ANSWERED, which is the half FUT-1213
|
|
223
|
+
* added.
|
|
224
|
+
*
|
|
225
|
+
* A settlement pays already-sent items, so the cart is legitimately empty
|
|
226
|
+
* there. The guard cannot key on the Dados step either: skipping it (FUT-465)
|
|
227
|
+
* makes Pagamento the first screen. And it holds until an order exists — once
|
|
228
|
+
* one does, its lines are snapshotted server-side and the cart no longer speaks
|
|
229
|
+
* for it.
|
|
230
|
+
*
|
|
231
|
+
* A cart still being FETCHED is empty in exactly the way a real one is not, so
|
|
232
|
+
* a host that wires `identity` gets this screen when its cart is empty rather
|
|
233
|
+
* than when it is late — which is also the moment the resume decision waits for.
|
|
234
|
+
*
|
|
235
|
+
* A RAISE IN FLIGHT is not "nothing to pay for" either (FUT-1170). Raising a
|
|
236
|
+
* charge now drops the one it replaces before it asks for the new one, so there
|
|
237
|
+
* is a window with no order held — and a checkout whose cart the host had
|
|
238
|
+
* already emptied would otherwise swap the buyer onto the empty-cart screen
|
|
239
|
+
* mid-regenerate, discarding the charge as it arrived.
|
|
240
|
+
*/
|
|
241
|
+
function nothingToPayFor(
|
|
242
|
+
settlement: SettlementCheckout | null | undefined,
|
|
243
|
+
cart: CheckoutCartView,
|
|
244
|
+
c: ReturnType<typeof useCheckoutController>,
|
|
245
|
+
): boolean {
|
|
246
|
+
if (settlement || !cart.empty || !cartLoaded(cart)) return false;
|
|
247
|
+
return !c.order && !c.creating && c.step !== "status";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Step 2, with the controller's facts and the host's money mapped onto it. */
|
|
251
|
+
function PagamentoStep({
|
|
252
|
+
c,
|
|
253
|
+
cart,
|
|
254
|
+
settlement,
|
|
255
|
+
providerConfig,
|
|
256
|
+
tenantSlug,
|
|
257
|
+
validateApplePayMerchant,
|
|
258
|
+
}: {
|
|
259
|
+
c: ReturnType<typeof useCheckoutController>;
|
|
260
|
+
cart: CheckoutCartView;
|
|
261
|
+
settlement: SettlementCheckout | null | undefined;
|
|
262
|
+
providerConfig: CheckoutProviderConfig | null | undefined;
|
|
263
|
+
tenantSlug: string | undefined;
|
|
264
|
+
validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
|
|
265
|
+
}): JSX.Element {
|
|
266
|
+
return (
|
|
267
|
+
<PaymentStep
|
|
268
|
+
method={c.method}
|
|
269
|
+
onMethodChange={c.setMethod}
|
|
270
|
+
order={c.order}
|
|
271
|
+
buyer={c.buyer}
|
|
272
|
+
creating={c.creating}
|
|
273
|
+
createError={c.createError}
|
|
274
|
+
errorField={c.errorField}
|
|
275
|
+
errorCode={c.errorCode}
|
|
276
|
+
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
277
|
+
onUseEmail={c.payWithEmail}
|
|
278
|
+
// Set only for a skipped-Dados flow (the controller decides); the payer
|
|
279
|
+
// block hides itself when it is absent.
|
|
280
|
+
onEditBuyer={c.editBuyer}
|
|
281
|
+
providerConfig={providerConfig}
|
|
282
|
+
tenantSlug={tenantSlug}
|
|
283
|
+
// The amount, on the step that asks for it (FUT-1179).
|
|
284
|
+
cartTotals={cart}
|
|
285
|
+
totalOverride={settlementTotalOverride(settlement)}
|
|
286
|
+
discountLines={cart.discountLines}
|
|
287
|
+
// Retrying a refused card: the saved card that failed is not chosen for
|
|
288
|
+
// them again (FUT-1145).
|
|
289
|
+
freshInstrument={c.freshInstrument}
|
|
290
|
+
// The card path parks an order of its own for a 3-D Secure challenge, so
|
|
291
|
+
// it needs the same basket the flow was mounted for (FUT-1213).
|
|
292
|
+
basket={cart.identity}
|
|
293
|
+
validateApplePayMerchant={validateApplePayMerchant}
|
|
294
|
+
onResolved={c.handleResolved}
|
|
182
295
|
/>
|
|
183
296
|
);
|
|
184
297
|
}
|
|
@@ -190,7 +303,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
190
303
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
191
304
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
192
305
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
193
|
-
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
|
|
306
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug, cart.identity);
|
|
194
307
|
const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
|
|
195
308
|
|
|
196
309
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
@@ -200,7 +313,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
200
313
|
// Pagamento the first screen, so an empty cart would otherwise reach the
|
|
201
314
|
// method picker. It holds until an order exists — once one does, its lines are
|
|
202
315
|
// snapshotted server-side and the cart no longer speaks for it.
|
|
203
|
-
if (
|
|
316
|
+
if (nothingToPayFor(settlement, cart, c)) {
|
|
204
317
|
return <EmptyCart copy={copy.emptyCart} onBack={c.goToMenu} />;
|
|
205
318
|
}
|
|
206
319
|
|
|
@@ -229,24 +342,13 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
229
342
|
|
|
230
343
|
{c.step === "payment" ? (
|
|
231
344
|
<OneClickProvider armed={armed}>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
buyer={c.buyer}
|
|
237
|
-
creating={c.creating}
|
|
238
|
-
createError={c.createError}
|
|
239
|
-
errorField={c.errorField}
|
|
240
|
-
errorCode={c.errorCode}
|
|
241
|
-
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
242
|
-
onUseEmail={c.payWithEmail}
|
|
243
|
-
// Set only for a skipped-Dados flow (the controller decides); the
|
|
244
|
-
// payer block hides itself when it is absent.
|
|
245
|
-
onEditBuyer={c.editBuyer}
|
|
345
|
+
<PagamentoStep
|
|
346
|
+
c={c}
|
|
347
|
+
cart={cart}
|
|
348
|
+
settlement={settlement}
|
|
246
349
|
providerConfig={providerConfig}
|
|
247
350
|
tenantSlug={tenantSlug}
|
|
248
351
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
249
|
-
onResolved={c.handleResolved}
|
|
250
352
|
/>
|
|
251
353
|
</OneClickProvider>
|
|
252
354
|
) : null}
|