@12-apps/payments-frontend 3.34.4 → 3.36.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/fields.tsx +33 -14
- package/src/components/checkout/card-view.tsx +24 -10
- package/src/components/checkout/cart-changed.tsx +273 -0
- package/src/components/checkout/checkout-flow.tsx +65 -81
- package/src/components/checkout/checkout-frame.tsx +175 -0
- package/src/components/checkout/checkout-steps.tsx +57 -83
- package/src/components/checkout/icons.tsx +5 -0
- package/src/components/checkout/method-tabs.tsx +249 -0
- package/src/components/checkout/payment-status-parts.tsx +13 -3
- package/src/components/checkout/payment-step-frame.tsx +206 -0
- package/src/components/checkout/payment-step-layout.tsx +169 -14
- package/src/components/checkout/payment-summary-phone.tsx +158 -0
- package/src/components/checkout/payment-summary.tsx +290 -0
- package/src/components/checkout/pix-after-copy.tsx +1 -1
- package/src/components/checkout/pix-pane.tsx +172 -147
- package/src/components/checkout/pix-parts.tsx +6 -4
- package/src/components/checkout/pix-view.tsx +37 -17
- package/src/components/checkout/screens-copy.ts +48 -3
- package/src/components/checkout/screens-en-US.ts +17 -0
- package/src/components/checkout/screens-pt-BR.ts +17 -0
- package/src/components/checkout/start-payment.ts +48 -19
- package/src/components/checkout/use-checkout-controller.ts +6 -2
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.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.",
|
package/src/card/fields.tsx
CHANGED
|
@@ -3,6 +3,10 @@ import type { JSX } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { useCheckoutCopy } from "../components/checkout/copy-context";
|
|
5
5
|
import { useCheckoutComponents } from "../components/checkout/ui";
|
|
6
|
+
import {
|
|
7
|
+
StickyPayBar,
|
|
8
|
+
useCheckoutLayout,
|
|
9
|
+
} from "../components/checkout/payment-step-layout";
|
|
6
10
|
|
|
7
11
|
import {
|
|
8
12
|
cvvLength,
|
|
@@ -110,7 +114,9 @@ function ExpiryCvvFields({
|
|
|
110
114
|
const { Input } = useCheckoutComponents();
|
|
111
115
|
const copy = useCheckoutCopy().card.fields;
|
|
112
116
|
return (
|
|
113
|
-
|
|
117
|
+
// Side by side while "Validade (MM/AA)" fits whole; stacked below that
|
|
118
|
+
// (a phone's Pagamento card, FUT-3525) rather than cut to "Validade (MM/…".
|
|
119
|
+
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, "& > *": { flex: "1 1 170px" } }}>
|
|
114
120
|
<Input
|
|
115
121
|
label={copy.expiryLabel}
|
|
116
122
|
type="text"
|
|
@@ -216,7 +222,14 @@ export function NewCardForm({
|
|
|
216
222
|
);
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* The primary "Pagar" action.
|
|
227
|
+
*
|
|
228
|
+
* On the Pagamento step it is the step's pay bar (FUT-3525): pinned to the
|
|
229
|
+
* bottom of the viewport with the total beside it on a phone, in the form on
|
|
230
|
+
* a computer. Composed anywhere else it keeps the look it always had —
|
|
231
|
+
* pinned to the bottom on a small screen, in the form on a wide one.
|
|
232
|
+
*/
|
|
220
233
|
export function CardPayBar({
|
|
221
234
|
totalLabel,
|
|
222
235
|
submitting,
|
|
@@ -227,6 +240,23 @@ export function CardPayBar({
|
|
|
227
240
|
onPay: () => void;
|
|
228
241
|
}): JSX.Element {
|
|
229
242
|
const { Button } = useCheckoutComponents();
|
|
243
|
+
const inStep = useCheckoutLayout() !== null;
|
|
244
|
+
const button = (
|
|
245
|
+
<Button
|
|
246
|
+
variant="solid"
|
|
247
|
+
color="primary"
|
|
248
|
+
size="lg"
|
|
249
|
+
fullWidth
|
|
250
|
+
loading={submitting}
|
|
251
|
+
disabled={submitting}
|
|
252
|
+
onClick={onPay}
|
|
253
|
+
dataTestId="card-pay"
|
|
254
|
+
>
|
|
255
|
+
Pagar {totalLabel}
|
|
256
|
+
</Button>
|
|
257
|
+
);
|
|
258
|
+
if (inStep)
|
|
259
|
+
return <StickyPayBar dataTestId="card-pay-bar">{button}</StickyPayBar>;
|
|
230
260
|
return (
|
|
231
261
|
<Box
|
|
232
262
|
data-testid="card-pay-bar"
|
|
@@ -242,18 +272,7 @@ export function CardPayBar({
|
|
|
242
272
|
borderColor: "divider",
|
|
243
273
|
}}
|
|
244
274
|
>
|
|
245
|
-
|
|
246
|
-
variant="solid"
|
|
247
|
-
color="primary"
|
|
248
|
-
size="lg"
|
|
249
|
-
fullWidth
|
|
250
|
-
loading={submitting}
|
|
251
|
-
disabled={submitting}
|
|
252
|
-
onClick={onPay}
|
|
253
|
-
dataTestId="card-pay"
|
|
254
|
-
>
|
|
255
|
-
Pagar {totalLabel}
|
|
256
|
-
</Button>
|
|
275
|
+
{button}
|
|
257
276
|
</Box>
|
|
258
277
|
);
|
|
259
278
|
}
|
|
@@ -8,15 +8,30 @@ import {
|
|
|
8
8
|
type CardTokenizationConfig,
|
|
9
9
|
} from "../../card";
|
|
10
10
|
|
|
11
|
+
import { useRefreshOnCartChanged } from "./cart-changed";
|
|
11
12
|
import { useCheckoutCopy } from "./copy-context";
|
|
12
13
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
13
14
|
import { StalledWait } from "./stalled-wait";
|
|
14
15
|
import type { CheckoutBasketIdentity } from "./basket";
|
|
15
16
|
import type { CardChainLink } from "./method-capability";
|
|
16
17
|
import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "./types";
|
|
18
|
+
import { useCheckoutLayout } from "./payment-step-layout";
|
|
17
19
|
import { useCheckoutComponents } from "./ui";
|
|
18
20
|
import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
|
|
19
21
|
|
|
22
|
+
/** The pane's heading — left out inside the Pagamento step, where the "Cartão" tab names the pane (FUT-3525). */
|
|
23
|
+
function CardHeading(): JSX.Element | null {
|
|
24
|
+
const { Text } = useCheckoutComponents();
|
|
25
|
+
const copy = useCheckoutCopy().screens.card;
|
|
26
|
+
const inStep = useCheckoutLayout() !== null;
|
|
27
|
+
if (inStep) return null;
|
|
28
|
+
return (
|
|
29
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
30
|
+
{copy.heading}
|
|
31
|
+
</Text>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Post-submit confirmation state: timeout > error > spinner.
|
|
22
37
|
*
|
|
@@ -179,8 +194,6 @@ export function CardView({
|
|
|
179
194
|
*/
|
|
180
195
|
basket?: CheckoutBasketIdentity;
|
|
181
196
|
}): JSX.Element {
|
|
182
|
-
const { Text } = useCheckoutComponents();
|
|
183
|
-
const copy = useCheckoutCopy().screens.card;
|
|
184
197
|
const cc = useCardCheckout(
|
|
185
198
|
order,
|
|
186
199
|
buyer,
|
|
@@ -192,21 +205,22 @@ export function CardView({
|
|
|
192
205
|
freshInstrument,
|
|
193
206
|
{ tenantSlug, basket },
|
|
194
207
|
);
|
|
195
|
-
// A charge NOBODY can confirm yet is not a decline (FUT-563)
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// de novo" and pushes the buyer toward exactly the retry it forbids.
|
|
208
|
+
// A charge NOBODY can confirm yet is not a decline (FUT-563): a provider may
|
|
209
|
+
// hold the money, and "Não foi possível pagar" over "não pague de novo"
|
|
210
|
+
// pushes the buyer toward exactly the retry it forbids.
|
|
199
211
|
const unresolved = cc.errorCode === UNRESOLVED_CODE;
|
|
212
|
+
useRefreshOnCartChanged(cc.errorCode); // re-priced by the host (FUT-1139)
|
|
200
213
|
|
|
201
214
|
if (cc.submitted) {
|
|
202
215
|
return <SubmittedState card={cc} />;
|
|
203
216
|
}
|
|
204
217
|
|
|
205
218
|
return (
|
|
206
|
-
<Box
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
219
|
+
<Box
|
|
220
|
+
data-testid="card-view"
|
|
221
|
+
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
|
|
222
|
+
>
|
|
223
|
+
<CardHeading />
|
|
210
224
|
|
|
211
225
|
<CardInstrumentFields card={cc} />
|
|
212
226
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type JSX,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
import type { CheckoutBasketIdentity } from "./basket";
|
|
14
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
15
|
+
import type {
|
|
16
|
+
BuyerInfo,
|
|
17
|
+
CheckoutOrder,
|
|
18
|
+
CreateOrderRequest,
|
|
19
|
+
CreateOrderResult,
|
|
20
|
+
PaymentMethod,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { useCheckoutComponents } from "./ui";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* THE CART MOVED UNDER A PREPARED PAYMENT (FUT-1139).
|
|
26
|
+
*
|
|
27
|
+
* An order is raised the moment the buyer picks a method, and it prices the
|
|
28
|
+
* cart as it stood THEN. Another tab can add a line, a store can move a price,
|
|
29
|
+
* a product can stop selling — and the card charge or the PIX code on screen
|
|
30
|
+
* still asks for the old amount. Paying it settles the old order and the
|
|
31
|
+
* server closes the cart, so whatever changed was neither charged nor kept.
|
|
32
|
+
*
|
|
33
|
+
* Two halves, both here:
|
|
34
|
+
*
|
|
35
|
+
* - the SERVER refuses a card charge whose order no longer matches the cart,
|
|
36
|
+
* with {@link CART_CHANGED_CODE}, after re-pricing the order to the cart as it
|
|
37
|
+
* stands. The card form then re-reads that order, so the pay bar shows the
|
|
38
|
+
* amount that will actually be charged before the buyer presses it again;
|
|
39
|
+
* - the SCREEN notices on its own that the basket it was raised from is not the
|
|
40
|
+
* basket in front of it (the host's signature, `./basket.ts`), and says so
|
|
41
|
+
* with one control that brings the payment up to date. That is the only
|
|
42
|
+
* warning a PIX buyer can get: a QR code is paid outside the app.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The refusal a host answers when the order no longer prices the cart. */
|
|
46
|
+
export const CART_CHANGED_CODE = "CART_CHANGED";
|
|
47
|
+
|
|
48
|
+
interface RaisedFrom {
|
|
49
|
+
order: CheckoutOrder;
|
|
50
|
+
signature: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the basket in front of the checkout is no longer the one `order` was
|
|
55
|
+
* raised from.
|
|
56
|
+
*
|
|
57
|
+
* The basket is recorded when the ORDER changes — a re-raise or a refresh
|
|
58
|
+
* hands back a new object, so it is re-recorded and the warning clears — and
|
|
59
|
+
* only once the host's cart has answered (`ready`): a loading cart reports the
|
|
60
|
+
* EMPTY signature, and recording that would read every real cart as changed.
|
|
61
|
+
* A host that passes no basket never sees the warning, which is the behaviour
|
|
62
|
+
* before this existed.
|
|
63
|
+
*/
|
|
64
|
+
export function useBasketChanged(
|
|
65
|
+
order: CheckoutOrder | null,
|
|
66
|
+
basket: CheckoutBasketIdentity | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
const [raised, setRaised] = useState<RaisedFrom | null>(null);
|
|
69
|
+
const ready = basket?.ready === true;
|
|
70
|
+
const signature = basket?.signature ?? null;
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setRaised((prev) => {
|
|
73
|
+
if (order === null) return null;
|
|
74
|
+
if (prev?.order === order || !ready) return prev;
|
|
75
|
+
return { order, signature };
|
|
76
|
+
});
|
|
77
|
+
// A cart change re-runs this and keeps `prev`: only a NEW order records, so
|
|
78
|
+
// the raised basket never follows the cart it is compared with.
|
|
79
|
+
}, [order, ready, signature]);
|
|
80
|
+
if (order === null || raised === null || raised.order !== order || !ready) return false;
|
|
81
|
+
return raised.signature !== signature;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The step's "bring this payment up to date", with what the buyer must see of it. */
|
|
85
|
+
export interface OrderRefresh {
|
|
86
|
+
run: () => void;
|
|
87
|
+
/** A refresh is on its way — the control waits rather than firing twice. */
|
|
88
|
+
pending: boolean;
|
|
89
|
+
/** The last refresh failed, in the host's words; null otherwise. */
|
|
90
|
+
error: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Re-price the order IN PLACE: ask the host for the order again and swap it in
|
|
95
|
+
* without dropping the one on screen first.
|
|
96
|
+
*
|
|
97
|
+
* Unlike raising a payment (`./start-payment.ts`), nothing is cleared before
|
|
98
|
+
* the answer: the card form keeps what the buyer typed, and the refusal that
|
|
99
|
+
* sent us here stays on screen while the amount under it corrects itself. The
|
|
100
|
+
* answer is routed exactly as a raise is (`route`, which is
|
|
101
|
+
* `routeRaisedOrder`), so a re-priced order that is already PAID or must finish
|
|
102
|
+
* on the provider's page goes where a raised one would.
|
|
103
|
+
*
|
|
104
|
+
* Three guards, each against something a review found:
|
|
105
|
+
*
|
|
106
|
+
* - one refresh at a time — a second tap while one is out does nothing;
|
|
107
|
+
* - an answer for an order the screen has moved on from (the buyer switched
|
|
108
|
+
* method, or raised the payment again) is DROPPED rather than resurrected;
|
|
109
|
+
* - a failure is KEPT here and shown by {@link CartChangedBoundary}, because the
|
|
110
|
+
* step's own create-error slot is hidden whenever an order is on screen.
|
|
111
|
+
*/
|
|
112
|
+
export function useOrderRefresh(input: {
|
|
113
|
+
order: CheckoutOrder | null;
|
|
114
|
+
buyer: BuyerInfo;
|
|
115
|
+
saveProfile: boolean;
|
|
116
|
+
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
117
|
+
route: (order: CheckoutOrder) => void;
|
|
118
|
+
}): OrderRefresh {
|
|
119
|
+
const { order, buyer, saveProfile, createOrder, route } = input;
|
|
120
|
+
const offline = useCheckoutCopy().screens.transport.offline;
|
|
121
|
+
const [pending, setPending] = useState(false);
|
|
122
|
+
const [error, setError] = useState<string | null>(null);
|
|
123
|
+
const current = useRef(order);
|
|
124
|
+
const inFlight = useRef(false);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
current.current = order;
|
|
127
|
+
}, [order]);
|
|
128
|
+
// A failure is about the order it was raised for: once another is on screen
|
|
129
|
+
// (a method switch, a fresh raise) it is no longer true, and must not stay up.
|
|
130
|
+
const orderId = order?.orderId;
|
|
131
|
+
const method = order?.method;
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setError(null);
|
|
134
|
+
}, [orderId, method]);
|
|
135
|
+
const run = useCallback(() => {
|
|
136
|
+
if (!order || inFlight.current) return;
|
|
137
|
+
inFlight.current = true;
|
|
138
|
+
setPending(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
void createOrder({ method: order.method, buyer, saveProfile })
|
|
141
|
+
.catch((): CreateOrderResult => ({ ok: false, error: { code: "NETWORK", message: offline, field: null } }))
|
|
142
|
+
.then((result) => {
|
|
143
|
+
inFlight.current = false;
|
|
144
|
+
setPending(false);
|
|
145
|
+
// The same order still on screen — by id and method, since a re-priced
|
|
146
|
+
// order comes back as a new object for the same id.
|
|
147
|
+
const now = current.current;
|
|
148
|
+
if (!now || now.orderId !== order.orderId || now.method !== order.method) return;
|
|
149
|
+
if (!result.ok) setError(result.error.message);
|
|
150
|
+
else route(result.data);
|
|
151
|
+
});
|
|
152
|
+
}, [order, buyer, saveProfile, createOrder, route, offline]);
|
|
153
|
+
return { run, pending, error };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How this step brings a payment up to date with the cart (FUT-1139): a PIX
|
|
158
|
+
* code is minted again — the amount is IN the code — and a card order is
|
|
159
|
+
* re-priced in place so the form keeps what was typed.
|
|
160
|
+
*/
|
|
161
|
+
export function paymentRefresh(
|
|
162
|
+
order: CheckoutOrder | null,
|
|
163
|
+
onGenerate: (method: PaymentMethod) => void,
|
|
164
|
+
onRefreshOrder: (() => void) | undefined,
|
|
165
|
+
): (() => void) | null {
|
|
166
|
+
if (!order) return null;
|
|
167
|
+
if (order.method === "CARD" && onRefreshOrder) return onRefreshOrder;
|
|
168
|
+
return () => onGenerate(order.method);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const PaymentRefreshContext = createContext<(() => void) | null>(null);
|
|
172
|
+
|
|
173
|
+
/** Hands the payment panes the step's "bring this payment up to date". */
|
|
174
|
+
export function PaymentRefreshProvider({
|
|
175
|
+
refresh,
|
|
176
|
+
children,
|
|
177
|
+
}: {
|
|
178
|
+
refresh: (() => void) | null;
|
|
179
|
+
children: ReactNode;
|
|
180
|
+
}): JSX.Element {
|
|
181
|
+
return <PaymentRefreshContext.Provider value={refresh}>{children}</PaymentRefreshContext.Provider>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The card pane's half: a charge the host refused with
|
|
186
|
+
* {@link CART_CHANGED_CODE} re-reads the order, once per refusal.
|
|
187
|
+
*/
|
|
188
|
+
export function useRefreshOnCartChanged(errorCode: string | null): void {
|
|
189
|
+
const refresh = useContext(PaymentRefreshContext);
|
|
190
|
+
// Held in a ref: the step hands down a fresh closure on every render, and
|
|
191
|
+
// keying the effect on it would re-fire the refresh after each re-price for
|
|
192
|
+
// as long as the refusal stays on screen.
|
|
193
|
+
const latest = useRef(refresh);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
latest.current = refresh;
|
|
196
|
+
}, [refresh]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (errorCode === CART_CHANGED_CODE) latest.current?.();
|
|
199
|
+
}, [errorCode]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The payment pane, with the step's warning above it when the cart moved, and
|
|
204
|
+
* the refresh handed down to the card form. Nothing changes on screen while the
|
|
205
|
+
* basket is the one the order was raised from.
|
|
206
|
+
*/
|
|
207
|
+
export function CartChangedBoundary({
|
|
208
|
+
order,
|
|
209
|
+
basket,
|
|
210
|
+
creating,
|
|
211
|
+
onGenerate,
|
|
212
|
+
refresh,
|
|
213
|
+
children,
|
|
214
|
+
}: {
|
|
215
|
+
order: CheckoutOrder | null;
|
|
216
|
+
/** Absent for a settlement: it pays a balance, not the cart, so its basket is no signal. */
|
|
217
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
218
|
+
creating: boolean;
|
|
219
|
+
onGenerate: (method: PaymentMethod) => void;
|
|
220
|
+
refresh: OrderRefresh | undefined;
|
|
221
|
+
children: ReactNode;
|
|
222
|
+
}): JSX.Element {
|
|
223
|
+
const changed = useBasketChanged(order, basket) && !creating;
|
|
224
|
+
const run = paymentRefresh(order, onGenerate, refresh?.run);
|
|
225
|
+
const pending = refresh?.pending ?? false;
|
|
226
|
+
const error = refresh?.error ?? null;
|
|
227
|
+
// ONE tree shape whether or not the warning shows: wrapping the pane only
|
|
228
|
+
// while it does would remount the card form the moment the refresh lands,
|
|
229
|
+
// dropping what the buyer typed and the refusal that explains the new total.
|
|
230
|
+
return (
|
|
231
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
232
|
+
{(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
|
|
233
|
+
<PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
|
|
234
|
+
</Box>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** "Seu carrinho mudou" — what the step says, and the one thing to do about it. */
|
|
239
|
+
function CartChangedNotice({
|
|
240
|
+
onRefresh,
|
|
241
|
+
pending = false,
|
|
242
|
+
error = null,
|
|
243
|
+
}: {
|
|
244
|
+
onRefresh: () => void;
|
|
245
|
+
pending?: boolean;
|
|
246
|
+
/** A refresh that failed, in the host's words: said, with the same control to try again. */
|
|
247
|
+
error?: string | null;
|
|
248
|
+
}): JSX.Element {
|
|
249
|
+
const { Alert, Button } = useCheckoutComponents();
|
|
250
|
+
const copy = useCheckoutCopy().screens.cartChanged;
|
|
251
|
+
return (
|
|
252
|
+
<Box data-testid="checkout-cart-changed" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
253
|
+
{error ? (
|
|
254
|
+
<Alert variant="danger" title={copy.refreshFailed} description={error} showIcon data-testid="checkout-cart-changed-error" />
|
|
255
|
+
) : (
|
|
256
|
+
<Alert variant="warning" title={copy.heading} description={copy.support} showIcon />
|
|
257
|
+
)}
|
|
258
|
+
<Box>
|
|
259
|
+
<Button
|
|
260
|
+
variant="solid"
|
|
261
|
+
color="primary"
|
|
262
|
+
size="md"
|
|
263
|
+
onClick={onRefresh}
|
|
264
|
+
loading={pending}
|
|
265
|
+
disabled={pending}
|
|
266
|
+
dataTestId="checkout-cart-changed-refresh"
|
|
267
|
+
>
|
|
268
|
+
{copy.refreshAction}
|
|
269
|
+
</Button>
|
|
270
|
+
</Box>
|
|
271
|
+
</Box>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -1,29 +1,20 @@
|
|
|
1
|
-
import { Box } from "@mui/material";
|
|
2
1
|
import { useMemo, type JSX, type ReactNode } from "react";
|
|
3
2
|
|
|
4
3
|
import type { CheckoutBasketIdentity } from "./basket";
|
|
5
4
|
import { buyerFieldsFor } from "./buyer-fields";
|
|
6
5
|
import { EmptyCart, PaymentStep } from "./checkout-steps";
|
|
7
6
|
import { DadosStep } from "./dados-step";
|
|
8
|
-
import { ArrowBackIcon } from "./icons";
|
|
9
7
|
import { PaymentStatus } from "./payment-status";
|
|
10
8
|
import type { BackActionEmphasis } from "./payment-status-parts";
|
|
11
9
|
import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
|
|
12
10
|
import { CheckoutCopyProvider } from "./copy-context";
|
|
13
11
|
import { OneClickProvider, useOneClick } from "./one-click";
|
|
14
|
-
import { CheckoutComponentsProvider,
|
|
12
|
+
import { CheckoutComponentsProvider, type CheckoutComponents } from "./ui";
|
|
15
13
|
import type { CheckoutViewCopy } from "./view-copy";
|
|
16
|
-
import {
|
|
14
|
+
import { CheckoutFrame, FlowHeader } from "./checkout-frame";
|
|
15
|
+
import type { CheckoutDeliverySummary, CheckoutSummaryLine } from "./payment-summary";
|
|
17
16
|
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
18
17
|
|
|
19
|
-
/** Step ids are the flow's own contract; the labels beside them are host copy. */
|
|
20
|
-
function stepperSteps(copy: CheckoutViewCopy): { id: string; label: string }[] {
|
|
21
|
-
return [
|
|
22
|
-
{ id: "dados", label: copy.steps.dados },
|
|
23
|
-
{ id: "payment", label: copy.steps.payment },
|
|
24
|
-
{ id: "status", label: copy.steps.status },
|
|
25
|
-
];
|
|
26
|
-
}
|
|
27
18
|
|
|
28
19
|
/** What the flow reads off the host's cart — display facts, never money math. */
|
|
29
20
|
export interface CheckoutCartView {
|
|
@@ -33,6 +24,19 @@ export interface CheckoutCartView {
|
|
|
33
24
|
totalItems: number;
|
|
34
25
|
/** Host-rendered discount itemization under the pay-bar total (FUT-246). */
|
|
35
26
|
discountLines?: ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* The cart's lines, worded by the host, for the order summary the Pagamento
|
|
29
|
+
* step shows beside the payment on a computer (FUT-3525). Absent, the
|
|
30
|
+
* summary shows the total alone.
|
|
31
|
+
*/
|
|
32
|
+
lines?: CheckoutSummaryLine[];
|
|
33
|
+
/**
|
|
34
|
+
* Where the order is delivered, and the host's way back to change it — set
|
|
35
|
+
* only for a delivery (FUT-3525). Absent, no address is shown.
|
|
36
|
+
*/
|
|
37
|
+
delivery?: CheckoutDeliverySummary;
|
|
38
|
+
/** The host's note control, shown in the Pagamento step's summary (FUT-3525). */
|
|
39
|
+
orderNote?: ReactNode;
|
|
36
40
|
/**
|
|
37
41
|
* WHICH basket this is, so a payment raised from another one cannot resume
|
|
38
42
|
* itself over it (FUT-1213).
|
|
@@ -147,36 +151,6 @@ function statusTotalLabel(
|
|
|
147
151
|
return order?.totalLabel ?? settlement?.totalLabel ?? cart.totalLabel;
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
/**
|
|
151
|
-
* The slim checkout header — the flow's only nav: a step-aware back link
|
|
152
|
-
* (Pagamento → Dados, else the menu).
|
|
153
|
-
*
|
|
154
|
-
* Deliberately nothing else. A "limpar carrinho" trash used to sit on the right
|
|
155
|
-
* of the Dados step; it was removed because emptying the cart is a CART action
|
|
156
|
-
* and belongs where the cart is edited — the drawer, which already offers a
|
|
157
|
-
* per-line remove. Offering it here put a destructive control next to the form
|
|
158
|
-
* a buyer is filling in, one tap away from the field they are typing into.
|
|
159
|
-
*/
|
|
160
|
-
function CheckoutHeader({ copy, step, onBack }: { copy: CheckoutViewCopy; step: string; onBack: () => void }): JSX.Element {
|
|
161
|
-
const { Button } = useCheckoutComponents();
|
|
162
|
-
return (
|
|
163
|
-
<Box sx={{ minHeight: 36, display: "flex", alignItems: "center", gap: 1 }}>
|
|
164
|
-
<Button variant="text" color="neutral" size="sm" icon={<ArrowBackIcon fontSize="small" />} iconPosition="left" onClick={onBack} dataTestId="checkout-back">
|
|
165
|
-
{step === "dados" ? copy.dados.keepShopping : copy.dados.back}
|
|
166
|
-
</Button>
|
|
167
|
-
</Box>
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Compact 50px progress header so the form stays above the fold on mobile. */
|
|
172
|
-
function ProgressHeader({ copy, step, completed }: { copy: CheckoutViewCopy; step: string; completed: Set<string> }): JSX.Element {
|
|
173
|
-
const { Stepper } = useCheckoutComponents();
|
|
174
|
-
return (
|
|
175
|
-
<Box sx={{ height: 50, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
|
176
|
-
<Stepper steps={stepperSteps(copy)} activeId={step} completed={completed} orientation="horizontal" size="sm" data-testid="checkout-stepper" />
|
|
177
|
-
</Box>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
154
|
|
|
181
155
|
/**
|
|
182
156
|
* The 3-step flow body, below the slot provider. Ported from the storefront's
|
|
@@ -297,6 +271,7 @@ function PagamentoStep({
|
|
|
297
271
|
errorField={c.errorField}
|
|
298
272
|
errorCode={c.errorCode}
|
|
299
273
|
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
274
|
+
refresh={c.refresh}
|
|
300
275
|
onUseEmail={c.payWithEmail}
|
|
301
276
|
// Set only for a skipped-Dados flow (the controller decides); the payer
|
|
302
277
|
// block hides itself when it is absent.
|
|
@@ -307,8 +282,11 @@ function PagamentoStep({
|
|
|
307
282
|
cartTotals={cart}
|
|
308
283
|
totalOverride={settlementTotalOverride(settlement)}
|
|
309
284
|
discountLines={cart.discountLines}
|
|
310
|
-
//
|
|
311
|
-
|
|
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).
|
|
312
290
|
freshInstrument={c.freshInstrument}
|
|
313
291
|
// The card path parks an order of its own for a 3-D Secure challenge, so
|
|
314
292
|
// it needs the same basket the flow was mounted for (FUT-1213).
|
|
@@ -341,46 +319,52 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
341
319
|
}
|
|
342
320
|
|
|
343
321
|
return (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
<CheckoutHeader copy={copy} step={c.step} onBack={c.back} />
|
|
347
|
-
|
|
348
|
-
<ProgressHeader copy={copy} step={c.step} completed={c.completed} />
|
|
322
|
+
<CheckoutFrame payment={c.step === "payment"}>
|
|
323
|
+
<FlowHeader copy={copy} step={c.step} completed={c.completed} onBack={c.back} />
|
|
349
324
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
325
|
+
{c.step === "dados" ? (
|
|
326
|
+
<DadosStep
|
|
327
|
+
copy={copy.dados}
|
|
328
|
+
buyer={c.buyer}
|
|
329
|
+
onBuyerChange={c.setBuyer}
|
|
330
|
+
saveProfile={c.saveProfile}
|
|
331
|
+
onSaveProfileChange={c.setSaveProfile}
|
|
332
|
+
createError={c.createError}
|
|
333
|
+
errorField={c.errorField}
|
|
334
|
+
onContinue={c.goToPayment}
|
|
335
|
+
cartTotals={cart}
|
|
336
|
+
buyerFields={buyerFields}
|
|
337
|
+
discountLines={cart.discountLines}
|
|
338
|
+
totalOverride={settlementTotalOverride(settlement)}
|
|
339
|
+
/>
|
|
340
|
+
) : null}
|
|
366
341
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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}
|
|
379
354
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
355
|
+
{c.step === "status" ? (
|
|
356
|
+
<StatusStep
|
|
357
|
+
copy={copy}
|
|
358
|
+
c={c}
|
|
359
|
+
settlement={settlement}
|
|
360
|
+
cart={cart}
|
|
361
|
+
confirmationExtra={confirmationExtra}
|
|
362
|
+
confirmationHero={confirmationHero}
|
|
363
|
+
confirmationFooter={confirmationFooter}
|
|
364
|
+
backActionEmphasis={backActionEmphasis}
|
|
365
|
+
/>
|
|
366
|
+
) : null}
|
|
367
|
+
</CheckoutFrame>
|
|
384
368
|
);
|
|
385
369
|
}
|
|
386
370
|
|