@12-apps/payments-frontend 3.36.0 → 3.38.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/components/checkout/card-sources.ts +131 -0
- package/src/components/checkout/card-view.tsx +6 -2
- package/src/components/checkout/cart-changed.tsx +50 -18
- package/src/components/checkout/checkout-actions.ts +18 -0
- package/src/components/checkout/checkout-flow.tsx +24 -77
- package/src/components/checkout/google-pay-button.tsx +4 -4
- package/src/components/checkout/list/buyer-inline.tsx +125 -0
- package/src/components/checkout/list/charge-runners.tsx +156 -0
- package/src/components/checkout/list/choice-list.tsx +156 -0
- package/src/components/checkout/list/choice.ts +193 -0
- package/src/components/checkout/list/context.tsx +55 -0
- package/src/components/checkout/list/en-US.ts +61 -0
- package/src/components/checkout/list/list-bar.tsx +186 -0
- package/src/components/checkout/list/list-face.tsx +315 -0
- package/src/components/checkout/list/list-pix.tsx +206 -0
- package/src/components/checkout/list/online-content.tsx +137 -0
- package/src/components/checkout/list/option-row.tsx +123 -0
- package/src/components/checkout/list/payment-list-step.tsx +166 -0
- package/src/components/checkout/list/pt-BR.ts +77 -0
- package/src/components/checkout/list/types.ts +187 -0
- package/src/components/checkout/list/use-capability.ts +53 -0
- package/src/components/checkout/list/use-list-charge.ts +161 -0
- package/src/components/checkout/list/use-list-state.ts +152 -0
- package/src/components/checkout/payment-phase.tsx +119 -0
- package/src/components/checkout/pix-after-copy.tsx +10 -3
- package/src/components/checkout/pix-pane.tsx +33 -22
- package/src/components/checkout/pix-parts.tsx +50 -1
- package/src/components/checkout/pix-view.tsx +53 -20
- package/src/components/checkout/screens-copy.ts +6 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +1 -0
- package/src/components/checkout/use-card-checkout.ts +11 -78
- package/src/components/checkout/use-checkout-controller.ts +3 -1
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/types.ts +7 -0
- package/src/index.ts +4 -5
- package/src/locales.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { NEW_CARD, tokenizerFor, type CardTokenizationConfig, type SavedCard } from "../../card";
|
|
4
|
+
|
|
5
|
+
import { useCheckoutClientApi } from "./client-context";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the card path's two inputs come from: the buyer's saved cards and the
|
|
9
|
+
* store's card key. Split out of `./use-card-checkout.ts` at its size gate when
|
|
10
|
+
* the one-list Pagamento step (FUT-3591) needed the saved cards BEFORE any
|
|
11
|
+
* order exists — the list preselects one — and needed to know when the key is
|
|
12
|
+
* still on its way, because its charge runs the moment the 5-second bar fills
|
|
13
|
+
* rather than after the buyer has spent a minute typing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The buyer's saved cards at this store (FUT-697), loaded once per store, and
|
|
18
|
+
* whether the list has answered — "no saved card" and "not asked yet" are
|
|
19
|
+
* different facts to a screen that preselects one.
|
|
20
|
+
*/
|
|
21
|
+
export function useSavedCardList(tenantSlug: string | undefined): { cards: SavedCard[]; loaded: boolean } {
|
|
22
|
+
const [state, setState] = useState<{ cards: SavedCard[]; loaded: boolean }>({ cards: [], loaded: false });
|
|
23
|
+
const client = useCheckoutClientApi();
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let active = true;
|
|
26
|
+
// A list that cannot be read is an empty one that HAS answered: the
|
|
27
|
+
// buyer types a card, rather than waiting on a list that never comes.
|
|
28
|
+
void client
|
|
29
|
+
.listInstruments(tenantSlug)
|
|
30
|
+
.catch((): SavedCard[] => [])
|
|
31
|
+
.then((cards) => {
|
|
32
|
+
if (active) setState({ cards, loaded: true });
|
|
33
|
+
});
|
|
34
|
+
return () => {
|
|
35
|
+
active = false;
|
|
36
|
+
};
|
|
37
|
+
}, [tenantSlug, client]);
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Saved-card list + current selection — scoped to the current store (FUT-697),
|
|
43
|
+
* so only cards the store's active provider can actually charge are offered.
|
|
44
|
+
* The slug arrives as an argument (the host's routing owns it); absent, the
|
|
45
|
+
* list is unscoped, exactly as before FUT-697.
|
|
46
|
+
*
|
|
47
|
+
* `freshInstrument`: the buyer is here because a card was REFUSED (FUT-1145),
|
|
48
|
+
* so the saved card this would otherwise preselect is the one that just
|
|
49
|
+
* failed. The list is still offered — another saved card may well work — but
|
|
50
|
+
* nothing is chosen for them, which puts the form in front of a buyer whose
|
|
51
|
+
* only untried instrument is a new one.
|
|
52
|
+
*/
|
|
53
|
+
export function useSavedCards(
|
|
54
|
+
tenantSlug: string | undefined,
|
|
55
|
+
freshInstrument: boolean,
|
|
56
|
+
): {
|
|
57
|
+
savedCards: SavedCard[];
|
|
58
|
+
selection: string;
|
|
59
|
+
setSelection: (id: string) => void;
|
|
60
|
+
} {
|
|
61
|
+
// The cards and the selection land in ONE update, as they always did: a
|
|
62
|
+
// render holding the cards with nothing selected would flash the new-card
|
|
63
|
+
// form at a buyer whose saved card is about to be chosen for them.
|
|
64
|
+
const [state, setState] = useState<{ savedCards: SavedCard[]; selection: string }>({ savedCards: [], selection: NEW_CARD });
|
|
65
|
+
const client = useCheckoutClientApi();
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let active = true;
|
|
68
|
+
void client
|
|
69
|
+
.listInstruments(tenantSlug)
|
|
70
|
+
.catch((): SavedCard[] => [])
|
|
71
|
+
.then((cards) => {
|
|
72
|
+
if (!active) return;
|
|
73
|
+
const first = cards[0];
|
|
74
|
+
setState((prev) => ({ savedCards: cards, selection: first && !freshInstrument ? first.id : prev.selection }));
|
|
75
|
+
});
|
|
76
|
+
return () => {
|
|
77
|
+
active = false;
|
|
78
|
+
};
|
|
79
|
+
}, [tenantSlug, client, freshInstrument]);
|
|
80
|
+
const setSelection = useCallback((id: string) => setState((prev) => ({ ...prev, selection: id })), []);
|
|
81
|
+
return { savedCards: state.savedCards, selection: state.selection, setSelection };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The store's card public key for this order. The checkout config resolved the
|
|
86
|
+
* initial key server-side (`GET /api/checkout/config`, FUT-697); the SPA
|
|
87
|
+
* fetches through the order-scoped refresh route only when the ACTIVE provider
|
|
88
|
+
* is PagBank — the sole provider that can mint a key on demand — and the
|
|
89
|
+
* config arrived without one. A `null` key never means "mock": stub permission
|
|
90
|
+
* travels separately as `mockTokenization`.
|
|
91
|
+
*
|
|
92
|
+
* `keyPending` is true while that refresh has not answered: a charge started
|
|
93
|
+
* before it lands would tokenize with no key at all.
|
|
94
|
+
*/
|
|
95
|
+
export function useCardPublicKey(
|
|
96
|
+
orderId: string,
|
|
97
|
+
config: CardTokenizationConfig,
|
|
98
|
+
): {
|
|
99
|
+
publicKey: string | null;
|
|
100
|
+
setPublicKey: (key: string) => void;
|
|
101
|
+
keyPending: boolean;
|
|
102
|
+
} {
|
|
103
|
+
const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
|
|
104
|
+
const [answered, setAnswered] = useState(false);
|
|
105
|
+
const client = useCheckoutClientApi();
|
|
106
|
+
const refreshable =
|
|
107
|
+
config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
|
|
108
|
+
const asks = config.publicKey === null && refreshable;
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!asks) return undefined;
|
|
112
|
+
let active = true;
|
|
113
|
+
void client
|
|
114
|
+
.refreshBrowserKey({ orderId })
|
|
115
|
+
.then((result) => {
|
|
116
|
+
if (!active) return;
|
|
117
|
+
setAnswered(true);
|
|
118
|
+
if (result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
|
|
119
|
+
})
|
|
120
|
+
// A refresh that failed has answered too: the charge goes on and the
|
|
121
|
+
// tokenizer says why, rather than the bar waiting forever.
|
|
122
|
+
.catch(() => {
|
|
123
|
+
if (active) setAnswered(true);
|
|
124
|
+
});
|
|
125
|
+
return () => {
|
|
126
|
+
active = false;
|
|
127
|
+
};
|
|
128
|
+
}, [orderId, asks, client]);
|
|
129
|
+
|
|
130
|
+
return { publicKey, setPublicKey, keyPending: asks && !answered };
|
|
131
|
+
}
|
|
@@ -45,7 +45,11 @@ function CardHeading(): JSX.Element | null {
|
|
|
45
45
|
* ending; now the clock is, and a wait that failed its way to the clock carries
|
|
46
46
|
* both. "We keep trying" over a wait nothing is scheduled for is the lie.
|
|
47
47
|
*/
|
|
48
|
-
function SubmittedState({
|
|
48
|
+
export function SubmittedState({
|
|
49
|
+
card,
|
|
50
|
+
}: {
|
|
51
|
+
card: Pick<CardCheckout, "pollTimedOut" | "pollError" | "pollCheckAgain">;
|
|
52
|
+
}): JSX.Element {
|
|
49
53
|
const { LoadingState } = useCheckoutComponents();
|
|
50
54
|
const copy = useCheckoutCopy().screens.settling;
|
|
51
55
|
if (card.pollTimedOut) {
|
|
@@ -88,7 +92,7 @@ function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
|
|
|
88
92
|
* "Não foi possível pagar" above a body that says "não pague de novo" — pushes
|
|
89
93
|
* the buyer toward exactly the second payment it forbids.
|
|
90
94
|
*/
|
|
91
|
-
function ChargeFailure({
|
|
95
|
+
export function ChargeFailure({
|
|
92
96
|
message,
|
|
93
97
|
unresolved,
|
|
94
98
|
}: {
|
|
@@ -185,6 +185,11 @@ export function PaymentRefreshProvider({
|
|
|
185
185
|
* The card pane's half: a charge the host refused with
|
|
186
186
|
* {@link CART_CHANGED_CODE} re-reads the order, once per refusal.
|
|
187
187
|
*/
|
|
188
|
+
/** The step's "Atualizar pagamento" for the order on screen, or `null` when there is nothing to refresh. */
|
|
189
|
+
export function usePaymentRefresh(): (() => void) | null {
|
|
190
|
+
return useContext(PaymentRefreshContext);
|
|
191
|
+
}
|
|
192
|
+
|
|
188
193
|
export function useRefreshOnCartChanged(errorCode: string | null): void {
|
|
189
194
|
const refresh = useContext(PaymentRefreshContext);
|
|
190
195
|
// Held in a ref: the step hands down a fresh closure on every render, and
|
|
@@ -199,6 +204,23 @@ export function useRefreshOnCartChanged(errorCode: string | null): void {
|
|
|
199
204
|
}, [errorCode]);
|
|
200
205
|
}
|
|
201
206
|
|
|
207
|
+
/** Where a refresh stands, for a step that may not have one (a settlement). */
|
|
208
|
+
function refreshStatus(refresh: OrderRefresh | undefined): { pending: boolean; error: string | null } {
|
|
209
|
+
return { pending: refresh?.pending ?? false, error: refresh?.error ?? null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const PaymentStaleContext = createContext(false);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether the payment on screen was prepared for a cart that has since changed
|
|
216
|
+
* (FUT-3535). A PIX pane reads it to veil its QR and code and to stop the copy
|
|
217
|
+
* controls: the amount is IN the code, so a buyer who pays it now pays the old
|
|
218
|
+
* order. False outside a {@link CartChangedBoundary}.
|
|
219
|
+
*/
|
|
220
|
+
export function usePaymentStale(): boolean {
|
|
221
|
+
return useContext(PaymentStaleContext);
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
/**
|
|
203
225
|
* The payment pane, with the step's warning above it when the cart moved, and
|
|
204
226
|
* the refresh handed down to the card form. Nothing changes on screen while the
|
|
@@ -210,8 +232,11 @@ export function CartChangedBoundary({
|
|
|
210
232
|
creating,
|
|
211
233
|
onGenerate,
|
|
212
234
|
refresh,
|
|
235
|
+
actionInBar = false,
|
|
213
236
|
children,
|
|
214
237
|
}: {
|
|
238
|
+
/** The step's bar carries "Atualizar pagamento" itself (the one list): the notice only says why. */
|
|
239
|
+
actionInBar?: boolean;
|
|
215
240
|
order: CheckoutOrder | null;
|
|
216
241
|
/** Absent for a settlement: it pays a balance, not the cart, so its basket is no signal. */
|
|
217
242
|
basket: CheckoutBasketIdentity | undefined;
|
|
@@ -222,15 +247,19 @@ export function CartChangedBoundary({
|
|
|
222
247
|
}): JSX.Element {
|
|
223
248
|
const changed = useBasketChanged(order, basket) && !creating;
|
|
224
249
|
const run = paymentRefresh(order, onGenerate, refresh?.run);
|
|
225
|
-
const pending = refresh
|
|
226
|
-
const error = refresh?.error ?? null;
|
|
250
|
+
const { pending, error } = refreshStatus(refresh);
|
|
227
251
|
// ONE tree shape whether or not the warning shows: wrapping the pane only
|
|
228
252
|
// while it does would remount the card form the moment the refresh lands,
|
|
229
253
|
// dropping what the buyer typed and the refusal that explains the new total.
|
|
230
254
|
return (
|
|
231
255
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
232
|
-
{(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
|
|
233
|
-
|
|
256
|
+
{(changed || error) && run ? <CartChangedNotice onRefresh={actionInBar ? undefined : run} pending={pending} error={error} /> : null}
|
|
257
|
+
{/* Stale while the warning stands, so the veil goes with it the moment the
|
|
258
|
+
new order lands. A PIX refresh mints a new code, which takes the old
|
|
259
|
+
one off the screen first, so a failed one leaves nothing to copy. */}
|
|
260
|
+
<PaymentStaleContext.Provider value={changed && run !== null}>
|
|
261
|
+
<PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
|
|
262
|
+
</PaymentStaleContext.Provider>
|
|
234
263
|
</Box>
|
|
235
264
|
);
|
|
236
265
|
}
|
|
@@ -241,7 +270,8 @@ function CartChangedNotice({
|
|
|
241
270
|
pending = false,
|
|
242
271
|
error = null,
|
|
243
272
|
}: {
|
|
244
|
-
|
|
273
|
+
/** Absent: the bar carries the control. */
|
|
274
|
+
onRefresh?: () => void;
|
|
245
275
|
pending?: boolean;
|
|
246
276
|
/** A refresh that failed, in the host's words: said, with the same control to try again. */
|
|
247
277
|
error?: string | null;
|
|
@@ -255,19 +285,21 @@ function CartChangedNotice({
|
|
|
255
285
|
) : (
|
|
256
286
|
<Alert variant="warning" title={copy.heading} description={copy.support} showIcon />
|
|
257
287
|
)}
|
|
258
|
-
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
288
|
+
{onRefresh ? (
|
|
289
|
+
<Box>
|
|
290
|
+
<Button
|
|
291
|
+
variant="solid"
|
|
292
|
+
color="primary"
|
|
293
|
+
size="md"
|
|
294
|
+
onClick={onRefresh}
|
|
295
|
+
loading={pending}
|
|
296
|
+
disabled={pending}
|
|
297
|
+
dataTestId="checkout-cart-changed-refresh"
|
|
298
|
+
>
|
|
299
|
+
{copy.refreshAction}
|
|
300
|
+
</Button>
|
|
301
|
+
</Box>
|
|
302
|
+
) : null}
|
|
271
303
|
</Box>
|
|
272
304
|
);
|
|
273
305
|
}
|
|
@@ -278,3 +278,21 @@ export function useRetryAction(input: {
|
|
|
278
278
|
clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
|
|
279
279
|
]);
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Leave the raised order without choosing another method (FUT-3592): the one
|
|
284
|
+
* list's "Trocar a forma de pagamento" goes back to the list from a Pix, and
|
|
285
|
+
* the order — and its parked entry, scoped to this store — go with it, as a
|
|
286
|
+
* method switch takes them.
|
|
287
|
+
*/
|
|
288
|
+
export function useDropOrder(
|
|
289
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
|
|
290
|
+
tenantSlug: string | undefined,
|
|
291
|
+
clearError: () => void,
|
|
292
|
+
): () => void {
|
|
293
|
+
return useCallback(() => {
|
|
294
|
+
setOrder(null);
|
|
295
|
+
forgetHostedOrder(tenantSlug);
|
|
296
|
+
clearError();
|
|
297
|
+
}, [setOrder, tenantSlug, clearError]);
|
|
298
|
+
}
|
|
@@ -2,18 +2,20 @@ import { useMemo, type JSX, type ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
import type { CheckoutBasketIdentity } from "./basket";
|
|
4
4
|
import { buyerFieldsFor } from "./buyer-fields";
|
|
5
|
-
import { EmptyCart
|
|
5
|
+
import { EmptyCart } from "./checkout-steps";
|
|
6
6
|
import { DadosStep } from "./dados-step";
|
|
7
7
|
import { PaymentStatus } from "./payment-status";
|
|
8
8
|
import type { BackActionEmphasis } from "./payment-status-parts";
|
|
9
9
|
import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
|
|
10
10
|
import { CheckoutCopyProvider } from "./copy-context";
|
|
11
|
-
import {
|
|
11
|
+
import { useOneClick } from "./one-click";
|
|
12
12
|
import { CheckoutComponentsProvider, type CheckoutComponents } from "./ui";
|
|
13
13
|
import type { CheckoutViewCopy } from "./view-copy";
|
|
14
14
|
import { CheckoutFrame, FlowHeader } from "./checkout-frame";
|
|
15
15
|
import type { CheckoutDeliverySummary, CheckoutSummaryLine } from "./payment-summary";
|
|
16
16
|
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
17
|
+
import type { CheckoutPaymentList } from "./list/types";
|
|
18
|
+
import { PaymentPhase, settlementTotalOverride } from "./payment-phase";
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
/** What the flow reads off the host's cart — display facts, never money math. */
|
|
@@ -111,6 +113,16 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
|
111
113
|
backActionEmphasis?: BackActionEmphasis;
|
|
112
114
|
/** Design-system slots; unfilled slots render the raw-MUI defaults. */
|
|
113
115
|
components?: Partial<CheckoutComponents>;
|
|
116
|
+
/**
|
|
117
|
+
* Opt-in: the Pagamento step as ONE list (FUT-3591) — the store's online
|
|
118
|
+
* options and the host's door options in one radio group, nothing raised on
|
|
119
|
+
* a pick, the bar's button the only way an order is made, and the card
|
|
120
|
+
* charged after a 5-second bar the buyer can cancel (FUT-3592). The buyer's
|
|
121
|
+
* missing CPF is asked inside the option instead of on a Dados step first.
|
|
122
|
+
* Absent: the method tabs, the order summary and raise-on-pick, unchanged.
|
|
123
|
+
* See `./list/types.ts`.
|
|
124
|
+
*/
|
|
125
|
+
paymentList?: CheckoutPaymentList;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/**
|
|
@@ -123,13 +135,6 @@ function cartLoaded(cart: CheckoutCartView): boolean {
|
|
|
123
135
|
return cart.identity?.ready !== false;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
/** The pay-bar total override when settling a settlement (else the cart's own totals). */
|
|
127
|
-
function settlementTotalOverride(
|
|
128
|
-
settlement: SettlementCheckout | null | undefined,
|
|
129
|
-
): { label: string; items: number } | undefined {
|
|
130
|
-
return settlement ? { label: settlement.totalLabel, items: settlement.totalItems } : undefined;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
138
|
/**
|
|
134
139
|
* The facts the confirmation screen shows beside the total (FUT-593): which
|
|
135
140
|
* order this was, and where its receipt went. Its own function so the optional
|
|
@@ -244,67 +249,17 @@ function nothingToPayFor(
|
|
|
244
249
|
return !c.order && !c.creating && c.step !== "status";
|
|
245
250
|
}
|
|
246
251
|
|
|
247
|
-
/** Step 2, with the controller's facts and the host's money mapped onto it. */
|
|
248
|
-
function PagamentoStep({
|
|
249
|
-
c,
|
|
250
|
-
cart,
|
|
251
|
-
settlement,
|
|
252
|
-
providerConfig,
|
|
253
|
-
tenantSlug,
|
|
254
|
-
validateApplePayMerchant,
|
|
255
|
-
}: {
|
|
256
|
-
c: ReturnType<typeof useCheckoutController>;
|
|
257
|
-
cart: CheckoutCartView;
|
|
258
|
-
settlement: SettlementCheckout | null | undefined;
|
|
259
|
-
providerConfig: CheckoutProviderConfig | null | undefined;
|
|
260
|
-
tenantSlug: string | undefined;
|
|
261
|
-
validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
|
|
262
|
-
}): JSX.Element {
|
|
263
|
-
return (
|
|
264
|
-
<PaymentStep
|
|
265
|
-
method={c.method}
|
|
266
|
-
onMethodChange={c.setMethod}
|
|
267
|
-
order={c.order}
|
|
268
|
-
buyer={c.buyer}
|
|
269
|
-
creating={c.creating}
|
|
270
|
-
createError={c.createError}
|
|
271
|
-
errorField={c.errorField}
|
|
272
|
-
errorCode={c.errorCode}
|
|
273
|
-
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
274
|
-
refresh={c.refresh}
|
|
275
|
-
onUseEmail={c.payWithEmail}
|
|
276
|
-
// Set only for a skipped-Dados flow (the controller decides); the payer
|
|
277
|
-
// block hides itself when it is absent.
|
|
278
|
-
onEditBuyer={c.editBuyer}
|
|
279
|
-
providerConfig={providerConfig}
|
|
280
|
-
tenantSlug={tenantSlug}
|
|
281
|
-
// The amount, on the step that asks for it (FUT-1179).
|
|
282
|
-
cartTotals={cart}
|
|
283
|
-
totalOverride={settlementTotalOverride(settlement)}
|
|
284
|
-
discountLines={cart.discountLines}
|
|
285
|
-
// The order around the payment, for the summary (FUT-3525).
|
|
286
|
-
lines={cart.lines}
|
|
287
|
-
delivery={cart.delivery}
|
|
288
|
-
orderNote={cart.orderNote}
|
|
289
|
-
// Retrying a refused card: the failed saved card is not chosen again (FUT-1145).
|
|
290
|
-
freshInstrument={c.freshInstrument}
|
|
291
|
-
// The card path parks an order of its own for a 3-D Secure challenge, so
|
|
292
|
-
// it needs the same basket the flow was mounted for (FUT-1213).
|
|
293
|
-
basket={cart.identity}
|
|
294
|
-
validateApplePayMerchant={validateApplePayMerchant}
|
|
295
|
-
onResolved={c.handleResolved}
|
|
296
|
-
/>
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
252
|
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
301
|
-
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationHero, confirmationFooter, backActionEmphasis,
|
|
253
|
+
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationHero, confirmationFooter, backActionEmphasis, oneClick = false, paymentList } = props;
|
|
254
|
+
const ports = { createOrder: props.createOrder, saveBuyerContact: props.saveBuyerContact, onExitToMenu: props.onExitToMenu, onPaid: props.onPaid };
|
|
302
255
|
// Resolved for NO method on purpose (FUT-595): the Dados step opens before
|
|
303
256
|
// the picker, and the form is filled once — so it asks for the union of what
|
|
304
257
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
305
258
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
306
259
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
307
|
-
|
|
260
|
+
// The list asks a missing CPF inside the option (M11), so it opens on
|
|
261
|
+
// Pagamento as a buyer with one on file always has (FUT-465).
|
|
262
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile || Boolean(paymentList), buyerFields, tenantSlug, cart.identity);
|
|
308
263
|
const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
|
|
309
264
|
|
|
310
265
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
@@ -320,7 +275,10 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
320
275
|
|
|
321
276
|
return (
|
|
322
277
|
<CheckoutFrame payment={c.step === "payment"}>
|
|
323
|
-
|
|
278
|
+
{/* In the list, the host's own stepper is the chrome around Pagamento. */}
|
|
279
|
+
{paymentList && c.step === "payment" ? null : (
|
|
280
|
+
<FlowHeader copy={copy} step={c.step} completed={c.completed} onBack={c.back} />
|
|
281
|
+
)}
|
|
324
282
|
|
|
325
283
|
{c.step === "dados" ? (
|
|
326
284
|
<DadosStep
|
|
@@ -339,18 +297,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
339
297
|
/>
|
|
340
298
|
) : null}
|
|
341
299
|
|
|
342
|
-
{c.step === "payment" ?
|
|
343
|
-
<OneClickProvider armed={armed}>
|
|
344
|
-
<PagamentoStep
|
|
345
|
-
c={c}
|
|
346
|
-
cart={cart}
|
|
347
|
-
settlement={settlement}
|
|
348
|
-
providerConfig={providerConfig}
|
|
349
|
-
tenantSlug={tenantSlug}
|
|
350
|
-
validateApplePayMerchant={validateApplePayMerchant}
|
|
351
|
-
/>
|
|
352
|
-
</OneClickProvider>
|
|
353
|
-
) : null}
|
|
300
|
+
{c.step === "payment" ? <PaymentPhase props={props} c={c} armed={armed} /> : null}
|
|
354
301
|
|
|
355
302
|
{c.step === "status" ? (
|
|
356
303
|
<StatusStep
|
|
@@ -109,9 +109,9 @@ function isReadyToPayRequest(): Record<string, unknown> {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/** Step 4's request: the same card method, now carrying gateway + price. */
|
|
112
|
-
function paymentDataRequest(
|
|
112
|
+
export function paymentDataRequest(
|
|
113
113
|
params: GooglePayGatewayParams,
|
|
114
|
-
order: CheckoutOrder,
|
|
114
|
+
order: Pick<CheckoutOrder, "totalCents">,
|
|
115
115
|
): Record<string, unknown> {
|
|
116
116
|
return {
|
|
117
117
|
apiVersion: 2,
|
|
@@ -144,7 +144,7 @@ function paymentDataRequest(
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/** The buyer closed the sheet — a choice, not a failure to report. */
|
|
147
|
-
function sheetDismissed(error: unknown): boolean {
|
|
147
|
+
export function sheetDismissed(error: unknown): boolean {
|
|
148
148
|
return (
|
|
149
149
|
typeof error === "object" &&
|
|
150
150
|
error !== null &&
|
|
@@ -157,7 +157,7 @@ function sheetDismissed(error: unknown): boolean {
|
|
|
157
157
|
* the button exists at all. `api` is injectable for tests and harnesses;
|
|
158
158
|
* `undefined` means "load pay.js".
|
|
159
159
|
*/
|
|
160
|
-
function useGooglePayClient(
|
|
160
|
+
export function useGooglePayClient(
|
|
161
161
|
api: GooglePayApi | null | undefined,
|
|
162
162
|
environment: "TEST" | "PRODUCTION",
|
|
163
163
|
): GooglePaymentsClient | null {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useEffect, useRef, type JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { formatCpf, onlyDigits, validateCpf } from "../../../card";
|
|
5
|
+
import { fieldSatisfied } from "../buyer-fields";
|
|
6
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
7
|
+
import type { BuyerInfo, CheckoutCustomerField } from "../types";
|
|
8
|
+
import { useCheckoutComponents } from "../ui";
|
|
9
|
+
|
|
10
|
+
import { usePaymentListMode } from "./context";
|
|
11
|
+
import type { PaymentListCopy } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The buyer's own details the chosen option needs, asked INSIDE it and ABOVE
|
|
15
|
+
* the card fields (M11, the owner's "maybe.. we should ask cpf first"): only
|
|
16
|
+
* the ones the store's chain requires for this method and the buyer arrived
|
|
17
|
+
* without. A buyer with a CPF on file never sees the field.
|
|
18
|
+
*
|
|
19
|
+
* When the last of them is complete, `onComplete` fires once — the card
|
|
20
|
+
* option moves the focus to "Número do cartão" with it, so the buyer's next
|
|
21
|
+
* keystroke lands where the next thing to type is.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Copy = PaymentListCopy["buyer"];
|
|
25
|
+
|
|
26
|
+
function nameOf(field: CheckoutCustomerField, copy: Copy): string {
|
|
27
|
+
const names: Record<CheckoutCustomerField["key"], string> = {
|
|
28
|
+
taxId: copy.cpf,
|
|
29
|
+
phone: copy.phone,
|
|
30
|
+
name: copy.name,
|
|
31
|
+
email: copy.email,
|
|
32
|
+
};
|
|
33
|
+
return names[field.key];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The sentence under a field the buyer has started and not finished. */
|
|
37
|
+
function complaintOf(field: CheckoutCustomerField, value: string, copy: Copy, cpfInvalid: (v: string) => string | undefined): string | undefined {
|
|
38
|
+
if (!value.trim() || fieldSatisfied(field, value)) return undefined;
|
|
39
|
+
if (field.key === "taxId") return onlyDigits(value).length < 11 ? copy.cpfIncomplete : cpfInvalid(value);
|
|
40
|
+
if (field.key === "phone") return copy.phoneIncomplete;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PLACEHOLDERS: Partial<Record<CheckoutCustomerField["key"], string>> = {
|
|
45
|
+
taxId: "000.000.000-00",
|
|
46
|
+
phone: "(00) 00000-0000",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function BuyerInline({
|
|
50
|
+
asked,
|
|
51
|
+
buyer,
|
|
52
|
+
onBuyerChange,
|
|
53
|
+
copy,
|
|
54
|
+
provider,
|
|
55
|
+
onComplete,
|
|
56
|
+
}: {
|
|
57
|
+
asked: readonly CheckoutCustomerField[];
|
|
58
|
+
buyer: BuyerInfo;
|
|
59
|
+
onBuyerChange: (buyer: BuyerInfo) => void;
|
|
60
|
+
copy: Copy;
|
|
61
|
+
/** The store's provider, named in the "why" — `null` when it published no name. */
|
|
62
|
+
provider: string | null;
|
|
63
|
+
onComplete?: () => void;
|
|
64
|
+
}): JSX.Element | null {
|
|
65
|
+
const { Checkbox, Input, Text } = useCheckoutComponents();
|
|
66
|
+
const consent = usePaymentListMode()?.consent;
|
|
67
|
+
const cardCopy = useCheckoutCopy().card;
|
|
68
|
+
const complete = asked.every((field) => fieldSatisfied(field, buyer[field.key]));
|
|
69
|
+
const was = useRef(complete);
|
|
70
|
+
const latest = useRef(onComplete);
|
|
71
|
+
latest.current = onComplete;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (complete && !was.current) latest.current?.();
|
|
74
|
+
was.current = complete;
|
|
75
|
+
}, [complete]);
|
|
76
|
+
if (asked.length === 0) return null;
|
|
77
|
+
return (
|
|
78
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
79
|
+
{asked.map((field) => {
|
|
80
|
+
const value = buyer[field.key] ?? "";
|
|
81
|
+
const complaint = complaintOf(field, value, copy, (v) => validateCpf(v, cardCopy.fields));
|
|
82
|
+
// The label sits ABOVE the field, whole — "CPF (o PagBank pede para
|
|
83
|
+
// pagar pelo app)" (FN:182) — and a native <label> around both names
|
|
84
|
+
// the input with it; a floating label would cut the reason short.
|
|
85
|
+
return (
|
|
86
|
+
<Box key={field.key} component="label" sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
|
87
|
+
<Text variant="body" size="sm" as="span">
|
|
88
|
+
{nameOf(field, copy)}{" "}
|
|
89
|
+
<Text variant="body" size="sm" color="secondary" as="span">
|
|
90
|
+
{copy.why(provider)}
|
|
91
|
+
</Text>
|
|
92
|
+
</Text>
|
|
93
|
+
<Input
|
|
94
|
+
type={field.key === "phone" ? "tel" : "text"}
|
|
95
|
+
inputMode={field.key === "taxId" ? "numeric" : undefined}
|
|
96
|
+
variant="outlined"
|
|
97
|
+
size="md"
|
|
98
|
+
fullWidth
|
|
99
|
+
required
|
|
100
|
+
placeholder={PLACEHOLDERS[field.key]}
|
|
101
|
+
value={value}
|
|
102
|
+
error={Boolean(complaint)}
|
|
103
|
+
helperText={complaint}
|
|
104
|
+
onChange={(event) =>
|
|
105
|
+
onBuyerChange({
|
|
106
|
+
...buyer,
|
|
107
|
+
[field.key]: field.key === "taxId" ? formatCpf(event.target.value) : event.target.value,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
data-testid={`list-buyer-${field.key}`}
|
|
111
|
+
/>
|
|
112
|
+
</Box>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
{consent ? (
|
|
116
|
+
<Checkbox
|
|
117
|
+
label={consent.label}
|
|
118
|
+
checked={consent.checked}
|
|
119
|
+
onChange={(_event, checked) => consent.onChange(checked)}
|
|
120
|
+
data-testid="list-save-profile"
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
</Box>
|
|
124
|
+
);
|
|
125
|
+
}
|