@12-apps/payments-frontend 3.38.0 → 3.40.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 +1 -1
- package/src/card/types.ts +6 -0
- package/src/components/checkout/card-sources.ts +11 -2
- package/src/components/checkout/list/bank-apps.ts +113 -0
- package/src/components/checkout/list/bank-en-US.ts +26 -0
- package/src/components/checkout/list/bank-logos.tsx +35 -0
- package/src/components/checkout/list/bank-pt-BR.ts +26 -0
- package/src/components/checkout/list/charge-runners.tsx +22 -3
- package/src/components/checkout/list/choice-list.tsx +4 -1
- package/src/components/checkout/list/choice.ts +30 -5
- package/src/components/checkout/list/context.tsx +27 -3
- package/src/components/checkout/list/en-US.ts +2 -0
- package/src/components/checkout/list/list-face.tsx +92 -29
- package/src/components/checkout/list/list-memory.ts +96 -0
- package/src/components/checkout/list/list-pix.tsx +153 -17
- package/src/components/checkout/list/online-content.tsx +11 -4
- package/src/components/checkout/list/option-row.tsx +4 -2
- package/src/components/checkout/list/payment-list-step.tsx +2 -1
- package/src/components/checkout/list/pix-banks.tsx +178 -0
- package/src/components/checkout/list/pt-BR.ts +2 -0
- package/src/components/checkout/list/types.ts +126 -4
- package/src/components/checkout/list/use-capability.ts +15 -10
- package/src/components/checkout/list/use-list-charge.ts +37 -12
- package/src/components/checkout/list/use-list-state.ts +11 -8
- package/src/components/checkout/pix-after-copy.tsx +8 -2
- package/src/components/checkout/pix-parts.tsx +56 -0
- package/src/components/checkout/pix-view.tsx +2 -0
- package/src/components/checkout/transport.ts +3 -1
- package/src/components/checkout/types.ts +9 -9
- package/src/index.ts +1 -1
- package/src/locales.ts +12 -1
|
@@ -10,7 +10,11 @@ import type { BuyerContact, CheckoutProviderConfig } from "../types";
|
|
|
10
10
|
import type { useCheckoutController } from "../use-checkout-controller";
|
|
11
11
|
import { useCheckoutComponents } from "../ui";
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
import { actionLabel, blockedReason, cardName } from "./choice";
|
|
15
|
+
import { usePaymentListMode, type PaymentListMode } from "./context";
|
|
16
|
+
import { armPixCopy, armedReady, otherBankShown, pixBankLabel, useArmedCharge, useDropRefusedCopy } from "./list-memory";
|
|
17
|
+
import { OtherBankLink } from "./list-pix";
|
|
14
18
|
import { ChoiceList } from "./choice-list";
|
|
15
19
|
import { CardRun, WalletRun, type ListChargeFailure } from "./charge-runners";
|
|
16
20
|
import { ActionButton, ChargeBar, GooglePayAction, ListBar } from "./list-bar";
|
|
@@ -66,11 +70,20 @@ function saveAsked(props: ListFaceProps): void {
|
|
|
66
70
|
saveBuyerContact?.({ name: c.buyer.name, phone: c.buyer.phone, taxId: c.buyer.taxId });
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What an online payment asks for: the host's amount due when a balance it
|
|
75
|
+
* holds pays part of the cart, else the cart's total (P3).
|
|
76
|
+
*/
|
|
77
|
+
function onlineTotal(props: ListFaceProps): string {
|
|
78
|
+
return props.list.amountDueLabel ?? props.total;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
/** An online choice's press: the card goes through the bar, Pix and the hand-off raise at once. */
|
|
70
|
-
function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
|
|
82
|
+
function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice, mode: PaymentListMode | null, pick = false): void {
|
|
71
83
|
const { c, charge, cap } = props;
|
|
72
84
|
saveAsked(props);
|
|
73
|
-
if (method === "
|
|
85
|
+
if (method === "PIX" && mode) armPixCopy(props.list, mode, pick);
|
|
86
|
+
if (method === "CARD") return charge.start("CARD", onlineTotal(props));
|
|
74
87
|
const raised = method === "PIX" ? "PIX" : handOffMethod(cap.offered);
|
|
75
88
|
if (method === "HAND_OFF") props.onHandOff();
|
|
76
89
|
c.setMethod(raised);
|
|
@@ -82,19 +95,20 @@ function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
|
|
|
82
95
|
* or a charge. A door option hands the press to the host; the card counts five
|
|
83
96
|
* seconds first; everything else raises at once.
|
|
84
97
|
*/
|
|
85
|
-
function useAct(props: ListFaceProps, clearFailure: () => void): () => void {
|
|
86
|
-
|
|
98
|
+
function useAct(props: ListFaceProps, clearFailure: () => void): (pick?: boolean) => void {
|
|
99
|
+
const mode = usePaymentListMode();
|
|
100
|
+
return (pick = false) => {
|
|
87
101
|
const { choice } = props.state;
|
|
88
102
|
if (!choice || inFlight(props)) return;
|
|
89
103
|
if (choice.kind === "door") return chosenDoor(props)?.onSend();
|
|
90
104
|
clearFailure();
|
|
91
|
-
actOnline(props, choice.method);
|
|
105
|
+
actOnline(props, choice.method, mode, pick);
|
|
92
106
|
};
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
/**
|
|
96
110
|
* Google Pay's press, from Google's own button: the sheet opens HERE, inside
|
|
97
|
-
* the tap, priced with the host's
|
|
111
|
+
* the tap, priced with the host's amount due — then the order is raised behind it.
|
|
98
112
|
*/
|
|
99
113
|
function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet: { current: OpenSheet | null }): () => void {
|
|
100
114
|
return () => {
|
|
@@ -113,7 +127,7 @@ function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet
|
|
|
113
127
|
},
|
|
114
128
|
);
|
|
115
129
|
sheet.current = { answer, cents };
|
|
116
|
-
charge.start("GOOGLE_PAY", props
|
|
130
|
+
charge.start("GOOGLE_PAY", onlineTotal(props));
|
|
117
131
|
};
|
|
118
132
|
}
|
|
119
133
|
|
|
@@ -151,26 +165,35 @@ function cardCharging(charge: ListCharge): boolean {
|
|
|
151
165
|
return charge.phase === "counting" || (charge.phase === "sending" && charge.kind === "CARD");
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* The bar's sentence: the amount frozen at the press, and the card it goes on
|
|
170
|
+
* — or, when the host's balance pays everything, no card at all.
|
|
171
|
+
*/
|
|
155
172
|
function chargingText(props: ListFaceProps): string {
|
|
156
|
-
const { charge, list, state
|
|
173
|
+
const { charge, list, state } = props;
|
|
174
|
+
if (coveredByHost(list) && list.copy.chargingCovered) return list.copy.chargingCovered;
|
|
157
175
|
const selected = state.card.saved.find((card) => card.id === state.card.selection);
|
|
158
|
-
return list.copy.charging(charge.pressedTotal ??
|
|
176
|
+
return list.copy.charging(charge.pressedTotal ?? onlineTotal(props), selected ? cardName(list.copy, selected) : null);
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/** The verb, the amount and the busy state of the button, for what is chosen. */
|
|
162
180
|
function buttonFacts(props: ListFaceProps): { label: string; total: string; busy: boolean } {
|
|
163
181
|
const { list, state } = props;
|
|
164
182
|
const door = chosenDoor(props);
|
|
183
|
+
const online = state.choice?.kind === "online";
|
|
165
184
|
return {
|
|
166
|
-
label: list.hold ? list.hold.action : actionLabel(list.copy, state.choice, door, props.provider),
|
|
167
|
-
total: door
|
|
185
|
+
label: list.hold ? list.hold.action : pixBankLabel(list, state) ?? actionLabel(list.copy, state.choice, door, props.provider),
|
|
186
|
+
total: door ? (door.totalLabel ?? props.total) : online ? onlineTotal(props) : props.total,
|
|
168
187
|
busy: inFlight(props),
|
|
169
188
|
};
|
|
170
189
|
}
|
|
171
190
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Google Pay chosen while the host re-prices (or for the one render before the
|
|
193
|
+
* list drops a choice nobody prices any more): a plain disabled button, never
|
|
194
|
+
* a spinner, with `copy.reasons.repricing` under it.
|
|
195
|
+
*/
|
|
196
|
+
function googlePayUnpriced(props: ListFaceProps): boolean {
|
|
174
197
|
const { choice } = props.state;
|
|
175
198
|
return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && !props.cap.googlePay;
|
|
176
199
|
}
|
|
@@ -181,27 +204,58 @@ function googlePayLive(props: ListFaceProps, reason: string | null, busy: boolea
|
|
|
181
204
|
return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && props.cap.googlePay !== null && reason === null && !busy;
|
|
182
205
|
}
|
|
183
206
|
|
|
207
|
+
/** The 5-second bar (or the charge going out), and the host's footer while it counts. */
|
|
208
|
+
function ChargingBar({ props, onCancel }: { props: ListFaceProps; onCancel: () => void }): JSX.Element {
|
|
209
|
+
const { charge, list } = props;
|
|
210
|
+
const counting = charge.phase === "counting";
|
|
211
|
+
const footer = counting ? list.chargeFooter?.(charge.cancel) : null;
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<ChargeBar text={chargingText(props)} full={!counting} cancelLabel={list.copy.cancel} onCancel={onCancel} />
|
|
215
|
+
{footer ? <Box data-testid="list-charge-footer">{footer}</Box> : null}
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The button, and "Abrir outro banco" under it when a remembered bank would open. */
|
|
221
|
+
function PressButton({ props, act, reason, returnFocus }: {
|
|
222
|
+
props: ListFaceProps;
|
|
223
|
+
act: (pick?: boolean) => void;
|
|
224
|
+
reason: string | null;
|
|
225
|
+
returnFocus: boolean;
|
|
226
|
+
}): JSX.Element {
|
|
227
|
+
const { list, state } = props;
|
|
228
|
+
const { label, total, busy } = buttonFacts(props);
|
|
229
|
+
const other = list.pixBank && otherBankShown(list, state, reason === null && !busy) ? list.pixBank.copy.openOther : null;
|
|
230
|
+
return (
|
|
231
|
+
<>
|
|
232
|
+
<ActionButton label={label} total={total} disabled={reason !== null || busy} loading={busy} onClick={() => act()} focusOnMount={returnFocus} />
|
|
233
|
+
{other ? <OtherBankLink label={other} onClick={() => act(true)} /> : null}
|
|
234
|
+
</>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
184
238
|
/** The button, the 5-second bar, Google's own button, or nothing for a charge nobody can confirm yet. */
|
|
185
239
|
function BarAction({ props, failure, act, onGooglePay, onCancel, reason, returnFocus }: {
|
|
186
240
|
props: ListFaceProps;
|
|
187
241
|
failure: ListChargeFailure | null;
|
|
188
|
-
act: () => void;
|
|
242
|
+
act: (pick?: boolean) => void;
|
|
189
243
|
onGooglePay: () => void;
|
|
190
244
|
onCancel: () => void;
|
|
191
245
|
reason: string | null;
|
|
192
246
|
returnFocus: boolean;
|
|
193
247
|
}): JSX.Element | null {
|
|
194
|
-
const { charge,
|
|
195
|
-
if (cardCharging(charge)) {
|
|
196
|
-
return <ChargeBar text={chargingText(props)} full={charge.phase !== "counting"} cancelLabel={list.copy.cancel} onCancel={onCancel} />;
|
|
197
|
-
}
|
|
248
|
+
const { charge, cap } = props;
|
|
249
|
+
if (cardCharging(charge)) return <ChargingBar props={props} onCancel={onCancel} />;
|
|
198
250
|
// An UNRESOLVED charge may be holding the buyer's money: no pay control at
|
|
199
251
|
// all under "não pague de novo" (FUT-563), as the card pane does today.
|
|
200
252
|
if (failure?.code === UNRESOLVED_CODE) return null;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
253
|
+
if (googlePayUnpriced(props)) {
|
|
254
|
+
const { label, total } = buttonFacts(props);
|
|
255
|
+
return <ActionButton label={label} total={total} disabled loading={false} onClick={() => act()} />;
|
|
256
|
+
}
|
|
257
|
+
if (googlePayLive(props, reason, inFlight(props)) && cap.googlePay) return <GooglePayAction client={cap.googlePay.client} onPress={onGooglePay} />;
|
|
258
|
+
return <PressButton props={props} act={act} reason={reason} returnFocus={returnFocus} />;
|
|
205
259
|
}
|
|
206
260
|
|
|
207
261
|
/** The charge under way, once there is an order to run it against. */
|
|
@@ -230,13 +284,19 @@ function RaiseError({ c, onRetry }: { c: Controller; onRetry: () => void }): JSX
|
|
|
230
284
|
return <PaymentErrorPanel message={c.createError} emailFlagged={c.errorField === "email"} code={c.errorCode} onUseEmail={c.payWithEmail} onRetry={onRetry} />;
|
|
231
285
|
}
|
|
232
286
|
|
|
287
|
+
/** The host's balance pays the whole amount (`totalCents: 0`): no card is asked for or charged. */
|
|
288
|
+
function coveredByHost(list: CheckoutPaymentList): boolean {
|
|
289
|
+
return list.totalCents === 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
233
292
|
/** Why the button is held: the host's hold, then the list's own reasons, in the prototype's order. */
|
|
234
293
|
function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): string | null {
|
|
235
294
|
const { c, list, state } = props;
|
|
236
|
-
|
|
295
|
+
const reason = blockedReason({
|
|
237
296
|
copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door, asked: state.asked,
|
|
238
|
-
buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete,
|
|
297
|
+
buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete, covered: coveredByHost(list),
|
|
239
298
|
});
|
|
299
|
+
return reason ?? (googlePayUnpriced(props) ? (list.copy.reasons.repricing ?? null) : null);
|
|
240
300
|
}
|
|
241
301
|
|
|
242
302
|
/**
|
|
@@ -262,8 +322,8 @@ function barFacts(props: ListFaceProps, door: CheckoutDoorOption | undefined, re
|
|
|
262
322
|
* appears or a total that moves mid-bar stops it, and `send` checks again.
|
|
263
323
|
*/
|
|
264
324
|
function useChargeGuard(props: ListFaceProps, reason: string | null): void {
|
|
265
|
-
const { charge
|
|
266
|
-
const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal ===
|
|
325
|
+
const { charge } = props;
|
|
326
|
+
const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === onlineTotal(props));
|
|
267
327
|
const { setAllowed } = charge;
|
|
268
328
|
useEffect(() => setAllowed(allowed), [allowed, setAllowed]);
|
|
269
329
|
}
|
|
@@ -295,16 +355,19 @@ export function ListFace(props: ListFaceProps): JSX.Element {
|
|
|
295
355
|
const door = chosenDoor(props);
|
|
296
356
|
const reason = reasonOf(props, door);
|
|
297
357
|
useChargeGuard(props, reason);
|
|
358
|
+
const free = reason === null && props.charge.phase === "idle" && !c.creating;
|
|
359
|
+
useArmedCharge(armedReady(list, state, free), act);
|
|
360
|
+
useDropRefusedCopy(c.createError);
|
|
298
361
|
const { returnFocus, onCancel } = useCancelWithFocus(charge);
|
|
299
362
|
const shown = charge.phase !== "waiting";
|
|
300
363
|
const facts = barFacts(props, door, reason);
|
|
301
364
|
return (
|
|
302
365
|
<Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
303
366
|
{shown ? (
|
|
304
|
-
<ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} />
|
|
367
|
+
<ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} covered={coveredByHost(list)} />
|
|
305
368
|
) : null}
|
|
306
369
|
<Runners props={props} sheet={sheet.current} onFailed={onFailed} />
|
|
307
|
-
<RaiseError c={c} onRetry={act} />
|
|
370
|
+
<RaiseError c={c} onRetry={() => act()} />
|
|
308
371
|
{shown ? (
|
|
309
372
|
<ListBar summary={list.summary} extras={facts.extras} payer={facts.charging ? null : <PayerLine props={props} />} reason={facts.underButton} live={facts.live}>
|
|
310
373
|
<BarAction props={props} failure={failure} act={act} onGooglePay={onGooglePay} onCancel={onCancel} reason={reason} returnFocus={returnFocus} />
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import { NEW_CARD } from "../../../card";
|
|
4
|
+
import { deferredCopy } from "../pix-parts";
|
|
5
|
+
|
|
6
|
+
import { bankFlowOs } from "./bank-apps";
|
|
7
|
+
import { usePaymentListMode, type PaymentListMode } from "./context";
|
|
8
|
+
import type { ListState } from "./use-list-state";
|
|
9
|
+
import type { CheckoutPaymentList, PixBankId } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the list does with the host's memory of the buyer (FUT-3611, FUT-3612):
|
|
13
|
+
* the bank a Pix opens in, and the charge "Pedir em 1 toque" armed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How long after the step opens an armed charge may still start by itself. */
|
|
17
|
+
export const ARMED_WINDOW_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
/** The bank flow's remembered bank, on a phone — what the Pix button opens. */
|
|
20
|
+
function rememberedBank(list: CheckoutPaymentList): PixBankId | null {
|
|
21
|
+
const bank = list.pixBank;
|
|
22
|
+
if (!bank?.remembered || !bankFlowOs(bank.phone)) return null;
|
|
23
|
+
return bank.remembered;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pix with the host's bank flow (FUT-3612): the clipboard is opened HERE,
|
|
28
|
+
* inside the tap, for a code the server has not answered yet — the Pix face
|
|
29
|
+
* writes it the moment it arrives, then opens the remembered bank (or, for
|
|
30
|
+
* "Abrir outro banco", shows the six).
|
|
31
|
+
*/
|
|
32
|
+
export function armPixCopy(list: CheckoutPaymentList, mode: PaymentListMode, pick: boolean): void {
|
|
33
|
+
if (!list.pixBank) return;
|
|
34
|
+
mode.pixIntent.current?.copy.fail();
|
|
35
|
+
const os = bankFlowOs(list.pixBank.phone);
|
|
36
|
+
mode.pixIntent.current = { copy: deferredCopy(), bank: os && !pick ? list.pixBank.remembered : null, pick };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pixChosen(state: ListState): boolean {
|
|
40
|
+
return state.choice?.kind === "online" && state.choice.method === "PIX";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pix with the bank flow: "Gerar e copiar o Pix", or "Gerar, copiar e abrir o Nubank". */
|
|
44
|
+
export function pixBankLabel(list: CheckoutPaymentList, state: ListState): string | null {
|
|
45
|
+
const bank = list.pixBank;
|
|
46
|
+
if (!bank || !pixChosen(state)) return null;
|
|
47
|
+
const remembered = rememberedBank(list);
|
|
48
|
+
return remembered ? bank.copy.generateCopyAndOpen(bank.copy.names[remembered]) : bank.copy.generateAndCopy;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "Abrir outro banco" under the button: Pix chosen, a bank remembered, a phone, and the button free to act. */
|
|
52
|
+
export function otherBankShown(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
|
|
53
|
+
return free && pixChosen(state) && rememberedBank(list) !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A saved card — not the fields — is what the card choice will charge. */
|
|
57
|
+
function savedCardChosen(state: ListState): boolean {
|
|
58
|
+
const { choice, card } = state;
|
|
59
|
+
if (choice?.kind !== "online" || choice.method !== "CARD") return false;
|
|
60
|
+
return card.selection !== null && card.selection !== NEW_CARD;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The armed charge may start now: the host armed it, the saved card the
|
|
65
|
+
* consent named is the one chosen, and nothing (`free`) holds the button.
|
|
66
|
+
*/
|
|
67
|
+
export function armedReady(list: CheckoutPaymentList, state: ListState, free: boolean): boolean {
|
|
68
|
+
if (!list.armedCharge || !free || !savedCardChosen(state)) return false;
|
|
69
|
+
return list.armedCardId === undefined || state.card.selection === list.armedCardId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "Pedir em 1 toque" opened this checkout (FUT-3611): start the card's bar by
|
|
74
|
+
* itself, ONCE per step — as soon as {@link armedReady} holds, within
|
|
75
|
+
* {@link ARMED_WINDOW_MS}. Anything after Cancelar never charges by itself.
|
|
76
|
+
*/
|
|
77
|
+
export function useArmedCharge(ready: boolean, act: () => void): void {
|
|
78
|
+
const mode = usePaymentListMode();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const armed = mode?.armed.current;
|
|
81
|
+
if (!ready || !armed || armed.spent) return;
|
|
82
|
+
// Spent either way: fired now, or too late to be the tap's own charge.
|
|
83
|
+
armed.spent = true;
|
|
84
|
+
if (Date.now() - armed.since <= ARMED_WINDOW_MS) act();
|
|
85
|
+
}, [ready, act, mode]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A Pix press whose code never came (the raise was refused) gives the clipboard up. */
|
|
89
|
+
export function useDropRefusedCopy(createError: unknown): void {
|
|
90
|
+
const mode = usePaymentListMode();
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!createError || !mode?.pixIntent.current) return;
|
|
93
|
+
mode.pixIntent.current.copy.fail();
|
|
94
|
+
mode.pixIntent.current = null;
|
|
95
|
+
}, [createError, mode]);
|
|
96
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box, useTheme } from "@mui/material";
|
|
2
|
-
import type
|
|
2
|
+
import { useEffect, useRef, useState, type JSX, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { usePaymentRefresh, usePaymentStale } from "../cart-changed";
|
|
5
5
|
import { useCheckoutCopy } from "../copy-context";
|
|
@@ -9,8 +9,11 @@ import type { PixAfterCopyCopy } from "../screens-copy";
|
|
|
9
9
|
import type { CheckoutOrder, PixCharge } from "../types";
|
|
10
10
|
import { useCheckoutComponents } from "../ui";
|
|
11
11
|
|
|
12
|
+
import { bankFlowOs, type PhoneOs } from "./bank-apps";
|
|
12
13
|
import type { PaymentListMode } from "./context";
|
|
13
14
|
import { ListBar, ActionButton, WaitingLine } from "./list-bar";
|
|
15
|
+
import { PixBanks, usePixBankState, type PixBankState } from "./pix-banks";
|
|
16
|
+
import type { CheckoutPixBank, PixBankId } from "./types";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* The Pix, in the one list (FUT-3592 #3): "Gerar Pix" already created the
|
|
@@ -146,6 +149,42 @@ interface ListPixFaceProps {
|
|
|
146
149
|
onPreferCard?: () => void;
|
|
147
150
|
/** The poll's stalled panel, when it has one. */
|
|
148
151
|
footer: ReactNode;
|
|
152
|
+
/** The clipboard alone, saying whether it took the code — the bank flow's copy. */
|
|
153
|
+
writeClipboard: () => Promise<boolean>;
|
|
154
|
+
setAfterCopy: (afterCopy: boolean) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A phone with a bank remembered: the copy opens it too, and keeps the way to another (FUT-3612). */
|
|
158
|
+
function rememberedOnPhone(flow: BankFlow | null): PixBankId | null {
|
|
159
|
+
return flow?.os ? flow.state.chosen : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** "Copiar e abrir no Nubank" with a remembered bank on a phone, else the plain copy. */
|
|
163
|
+
function copyLabel(plain: string, flow: BankFlow | null): string {
|
|
164
|
+
const remembered = rememberedOnPhone(flow);
|
|
165
|
+
return remembered && flow ? flow.bank.copy.copyAndOpen(flow.bank.copy.names[remembered]) : plain;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** "Abrir outro banco", when a remembered bank on a phone is what the copy opens. */
|
|
169
|
+
function otherBankLabel(flow: BankFlow | null): string | null {
|
|
170
|
+
return flow && rememberedOnPhone(flow) ? flow.bank.copy.openOther : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The bar's one control: held, "Atualizar pagamento", the wait after the copy, or the copy. */
|
|
174
|
+
function PixBarAction({ mode, afterCopy, onCopy, copyLabel }: {
|
|
175
|
+
mode: PaymentListMode;
|
|
176
|
+
afterCopy: boolean;
|
|
177
|
+
onCopy: () => void;
|
|
178
|
+
copyLabel: string;
|
|
179
|
+
}): JSX.Element {
|
|
180
|
+
const stale = usePaymentStale();
|
|
181
|
+
const refresh = usePaymentRefresh();
|
|
182
|
+
const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
|
|
183
|
+
const { hold } = mode.list;
|
|
184
|
+
if (hold) return <ActionButton label={hold.action} total={null} disabled onClick={() => undefined} />;
|
|
185
|
+
if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
|
|
186
|
+
if (afterCopy) return <WaitingLine text={mode.list.copy.pixFace.waiting} />;
|
|
187
|
+
return <ActionButton label={copyLabel} total={null} disabled={stale} onClick={onCopy} />;
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
/**
|
|
@@ -153,54 +192,151 @@ interface ListPixFaceProps {
|
|
|
153
192
|
* code the cart has outgrown — "Atualizar pagamento" (FUT-3535, E20), then
|
|
154
193
|
* the copy before it and the wait after it.
|
|
155
194
|
*/
|
|
156
|
-
function ListPixBar({ mode, afterCopy, onCopy
|
|
195
|
+
function ListPixBar({ mode, afterCopy, onCopy, bankFlow, onOtherBank }: {
|
|
196
|
+
mode: PaymentListMode;
|
|
197
|
+
afterCopy: boolean;
|
|
198
|
+
onCopy: () => void;
|
|
199
|
+
bankFlow: BankFlow | null;
|
|
200
|
+
onOtherBank: () => void;
|
|
201
|
+
}): JSX.Element {
|
|
157
202
|
const copy = mode.list.copy.pixFace;
|
|
158
203
|
const stale = usePaymentStale();
|
|
159
|
-
const refresh = usePaymentRefresh();
|
|
160
|
-
const refreshLabel = useCheckoutCopy().screens.cartChanged.refreshAction;
|
|
161
204
|
const { hold } = mode.list;
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
if (stale && refresh) return <ActionButton label={refreshLabel} total={null} disabled={false} onClick={refresh} />;
|
|
165
|
-
if (afterCopy) return <WaitingLine text={copy.waiting} />;
|
|
166
|
-
return <ActionButton label={copy.copyCode} total={null} disabled={stale} onClick={onCopy} />;
|
|
167
|
-
};
|
|
205
|
+
const settled = !hold && !stale;
|
|
206
|
+
const other = settled && !afterCopy ? otherBankLabel(bankFlow) : null;
|
|
168
207
|
return (
|
|
169
|
-
<ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy &&
|
|
170
|
-
{
|
|
208
|
+
<ListBar summary={mode.list.summary} reason={hold?.reason ?? null} live={afterCopy && settled ? copy.waiting : null}>
|
|
209
|
+
<PixBarAction mode={mode} afterCopy={afterCopy} onCopy={onCopy} copyLabel={copyLabel(copy.copyCode, bankFlow)} />
|
|
210
|
+
{other ? <OtherBankLink label={other} onClick={onOtherBank} /> : null}
|
|
171
211
|
</ListBar>
|
|
172
212
|
);
|
|
173
213
|
}
|
|
174
214
|
|
|
215
|
+
/** "Abrir outro banco" — a centred text link under the bar's button (FUT-3612). */
|
|
216
|
+
export function OtherBankLink({ label, onClick }: { label: string; onClick: () => void }): JSX.Element {
|
|
217
|
+
const { Button } = useCheckoutComponents();
|
|
218
|
+
return (
|
|
219
|
+
<Box sx={{ display: "flex", justifyContent: "center", "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5.5) } }}>
|
|
220
|
+
<Button variant="text" color="primary" size="sm" onClick={onClick} dataTestId="pix-open-other-bank">
|
|
221
|
+
{label}
|
|
222
|
+
</Button>
|
|
223
|
+
</Box>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The Pix-to-bank flow, when the host offers it (FUT-3612). */
|
|
228
|
+
interface BankFlow {
|
|
229
|
+
bank: CheckoutPixBank;
|
|
230
|
+
/** `null` on a computer: copy-and-say, no banks. */
|
|
231
|
+
os: PhoneOs | null;
|
|
232
|
+
state: PixBankState;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The toast after a copy: which bank opens, or that one is to be chosen. */
|
|
236
|
+
function copiedSentence(flow: BankFlow, opening: PixBankId | null): string {
|
|
237
|
+
const { copy } = flow.bank;
|
|
238
|
+
if (!flow.os) return copy.copied;
|
|
239
|
+
return opening ? copy.copiedOpening(copy.names[opening]) : copy.copiedPick;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A copy the clipboard took: the after-copy face, the grid when asked for, the toast, and the bank opened. */
|
|
243
|
+
function landCopy(at: { mode: PaymentListMode; flow: BankFlow; ports: CopyPorts; stale: boolean }, opening: PixBankId | null, grid: boolean): void {
|
|
244
|
+
const { mode, flow, ports } = at;
|
|
245
|
+
ports.setAfterCopy(true);
|
|
246
|
+
if (grid) flow.state.showGrid();
|
|
247
|
+
const target = flow.os ? opening : null;
|
|
248
|
+
mode.list.notify?.(copiedSentence(flow, target));
|
|
249
|
+
if (target && !at.stale) flow.state.open(target, false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every copy on the bank flow ends the same way: the after-copy face, the
|
|
254
|
+
* toast, and the bank opened — the one chosen, or none when the grid is asked
|
|
255
|
+
* for. A copy the clipboard refused does none of it (the code stays to copy by
|
|
256
|
+
* hand).
|
|
257
|
+
*/
|
|
258
|
+
function useBankCopies(mode: PaymentListMode, flow: BankFlow | null, pix: PixCharge, ports: CopyPorts) {
|
|
259
|
+
const stale = usePaymentStale();
|
|
260
|
+
const live = useRef(true);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
live.current = true;
|
|
263
|
+
return () => {
|
|
264
|
+
live.current = false;
|
|
265
|
+
};
|
|
266
|
+
}, []);
|
|
267
|
+
const land = (ok: boolean, grid: boolean, target: PixBankId | null): void => {
|
|
268
|
+
if (ok && live.current && flow) landCopy({ mode, flow, ports, stale }, grid ? null : target, grid);
|
|
269
|
+
};
|
|
270
|
+
// The bar's press already opened the clipboard, waiting for this code.
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const intent = mode.pixIntent.current;
|
|
273
|
+
if (!intent) return;
|
|
274
|
+
mode.pixIntent.current = null;
|
|
275
|
+
intent.copy.resolve(pix.copyPaste);
|
|
276
|
+
void intent.copy.done.then((ok) => land(ok, intent.pick, intent.pick ? null : intent.bank));
|
|
277
|
+
// Once, as the code arrives.
|
|
278
|
+
}, []);
|
|
279
|
+
const copyThen = (grid: boolean) => () =>
|
|
280
|
+
void ports.writeClipboard().then((ok) => land(ok, grid, flow?.state.chosen ?? null));
|
|
281
|
+
return { copy: copyThen(false), other: copyThen(true) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface CopyPorts {
|
|
285
|
+
writeClipboard: () => Promise<boolean>;
|
|
286
|
+
setAfterCopy: (afterCopy: boolean) => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The bank flow's state, or `null` when the host does not offer it. */
|
|
290
|
+
function useBankFlow(mode: PaymentListMode): BankFlow | null {
|
|
291
|
+
const bank = mode.list.pixBank;
|
|
292
|
+
const [os] = useState(() => bankFlowOs(bank?.phone));
|
|
293
|
+
const stale = usePaymentStale();
|
|
294
|
+
const startOnGrid = mode.pixIntent.current?.pick ?? false;
|
|
295
|
+
const state = usePixBankState(bank?.remembered ?? null, bank?.onPick ?? noPick, os, stale, startOnGrid);
|
|
296
|
+
return bank ? { bank, os, state } : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function noPick(): void {
|
|
300
|
+
// No bank flow: nothing to remember.
|
|
301
|
+
}
|
|
302
|
+
|
|
175
303
|
/** Both faces of the list's Pix, and the bar under them. */
|
|
176
304
|
export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode }): JSX.Element {
|
|
177
305
|
const { Text } = useCheckoutComponents();
|
|
178
306
|
const { mode, order, pix, afterCopy } = props;
|
|
179
307
|
const copy = mode.list.copy.pixFace;
|
|
308
|
+
const flow = useBankFlow(mode);
|
|
309
|
+
const copies = useBankCopies(mode, flow, pix, props);
|
|
310
|
+
const stale = usePaymentStale();
|
|
311
|
+
// On a phone the bank flow takes the copy: "Copiar e abrir o Nubank", the
|
|
312
|
+
// toast, the app. Without it — or on a computer — the copy is today's.
|
|
313
|
+
const onCopy = flow ? copies.copy : props.onCopy;
|
|
314
|
+
const banks = flow?.os ? { node: <PixBanks bank={flow.bank} state={flow.state} stale={stale} />, replacesChecklist: flow.state.chosen === null } : undefined;
|
|
180
315
|
return (
|
|
181
316
|
<Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
182
317
|
<Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
|
|
183
|
-
{copy.title(order.totalLabel)}
|
|
318
|
+
{copy.title(mode.list.amountDueLabel ?? order.totalLabel)}
|
|
184
319
|
</Text>
|
|
185
320
|
{afterCopy ? (
|
|
186
321
|
<PixAfterCopy
|
|
187
322
|
pix={pix}
|
|
188
323
|
words={props.words}
|
|
189
|
-
totalLabel={order.totalLabel}
|
|
324
|
+
totalLabel={mode.list.amountDueLabel ?? order.totalLabel}
|
|
190
325
|
deadline={props.time}
|
|
191
326
|
justCopied={props.justCopied}
|
|
192
|
-
onCopyAgain={props.onCopyAgain}
|
|
327
|
+
onCopyAgain={flow ? copies.copy : props.onCopyAgain}
|
|
193
328
|
onShowQr={() => {
|
|
194
329
|
mode.setQrOpen(true);
|
|
195
330
|
props.onShowQr();
|
|
196
331
|
}}
|
|
197
332
|
onPreferCard={props.onPreferCard}
|
|
198
333
|
footer={props.footer}
|
|
334
|
+
banks={banks}
|
|
199
335
|
/>
|
|
200
336
|
) : (
|
|
201
|
-
<BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={
|
|
337
|
+
<BeforeCopy mode={mode} pix={pix} time={props.time} onCopy={onCopy} onChangeMethod={mode.onLeavePix} footer={props.footer} />
|
|
202
338
|
)}
|
|
203
|
-
<ListPixBar mode={mode} afterCopy={afterCopy} onCopy={
|
|
339
|
+
<ListPixBar mode={mode} afterCopy={afterCopy} onCopy={onCopy} bankFlow={flow} onOtherBank={copies.other} />
|
|
204
340
|
</Box>
|
|
205
341
|
);
|
|
206
342
|
}
|
|
@@ -72,9 +72,13 @@ function SavedChips({ card, copy }: { card: ListCard; copy: PaymentListCopy }):
|
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Whether a chosen online row has anything to open — a row with nothing opens
|
|
77
|
+
* no box (L5). The card opens nothing of its own when the host's balance pays
|
|
78
|
+
* the whole amount: no card is charged, so none is asked for.
|
|
79
|
+
*/
|
|
80
|
+
export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null, covered = false): boolean {
|
|
81
|
+
return (method === "CARD" && !covered) || asked.length > 0 || failure !== null;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Move the focus to the card number — the next thing to type once the CPF is complete. */
|
|
@@ -97,6 +101,7 @@ export function OnlineContent({
|
|
|
97
101
|
copy,
|
|
98
102
|
provider,
|
|
99
103
|
failure,
|
|
104
|
+
covered = false,
|
|
100
105
|
}: {
|
|
101
106
|
method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
|
|
102
107
|
asked: readonly CheckoutCustomerField[];
|
|
@@ -106,9 +111,11 @@ export function OnlineContent({
|
|
|
106
111
|
copy: PaymentListCopy;
|
|
107
112
|
provider: string | null;
|
|
108
113
|
failure: ListChargeFailure | null;
|
|
114
|
+
/** The host's balance pays the whole amount — no chips, no card fields. */
|
|
115
|
+
covered?: boolean;
|
|
109
116
|
}): JSX.Element {
|
|
110
117
|
const scope = useRef<HTMLDivElement | null>(null);
|
|
111
|
-
const isCard = method === "CARD";
|
|
118
|
+
const isCard = method === "CARD" && !covered;
|
|
112
119
|
return (
|
|
113
120
|
<Box ref={scope} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
114
121
|
<BuyerInline
|
|
@@ -70,8 +70,10 @@ export function OptionRow({
|
|
|
70
70
|
borderRadius: 2.5,
|
|
71
71
|
px: 1.5,
|
|
72
72
|
opacity: disabled ? 0.6 : 1,
|
|
73
|
-
// The keyboard's place in the group, drawn on the whole row (5.1 item 6)
|
|
74
|
-
|
|
73
|
+
// The keyboard's place in the group, drawn on the whole row (5.1 item 6)
|
|
74
|
+
// — for the row's own radio only: a field opened inside draws its own
|
|
75
|
+
// ring, and a second one around the row doubled it.
|
|
76
|
+
"&:has(> label input[type='radio']:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
|
|
75
77
|
}}
|
|
76
78
|
>
|
|
77
79
|
<Box
|
|
@@ -130,9 +130,10 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
|
130
130
|
fresh: c.freshInstrument,
|
|
131
131
|
arrivedWith: props.defaultBuyer,
|
|
132
132
|
taxIdOnFile: props.taxIdOnFile,
|
|
133
|
+
lastMethod: list.lastMethod,
|
|
133
134
|
onChoiceChange: list.onChoiceChange,
|
|
134
135
|
});
|
|
135
|
-
const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label });
|
|
136
|
+
const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
|
|
136
137
|
const [handingOff, startHandOff] = useHandingOff(c.createError);
|
|
137
138
|
const pixStage = usePaymentPixStage(cap, usePreferMethod(c, state.choose));
|
|
138
139
|
const pix = c.order?.method === "PIX" ? c.order : null;
|