@12-apps/payments-frontend 3.32.0 → 3.34.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/checkout-flow.tsx +3 -1
- package/src/components/checkout/checkout-steps.tsx +48 -59
- package/src/components/checkout/icons.tsx +6 -0
- package/src/components/checkout/method-picker.tsx +51 -89
- package/src/components/checkout/payment-step-layout.tsx +65 -0
- package/src/components/checkout/pix-after-copy.tsx +182 -0
- package/src/components/checkout/pix-pane.tsx +225 -0
- package/src/components/checkout/pix-parts.tsx +79 -0
- package/src/components/checkout/pix-stage.tsx +34 -0
- package/src/components/checkout/pix-view.tsx +133 -124
- package/src/components/checkout/screens-copy.ts +77 -11
- package/src/components/checkout/screens-en-US.ts +39 -9
- package/src/components/checkout/screens-pt-BR.ts +38 -8
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.34.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.",
|
|
@@ -13,6 +13,7 @@ import { CheckoutCopyProvider } from "./copy-context";
|
|
|
13
13
|
import { OneClickProvider, useOneClick } from "./one-click";
|
|
14
14
|
import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
|
|
15
15
|
import type { CheckoutViewCopy } from "./view-copy";
|
|
16
|
+
import { STEP_COLUMN_MAX } from "./payment-step-layout";
|
|
16
17
|
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
17
18
|
|
|
18
19
|
/** Step ids are the flow's own contract; the labels beside them are host copy. */
|
|
@@ -340,7 +341,8 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
340
341
|
}
|
|
341
342
|
|
|
342
343
|
return (
|
|
343
|
-
|
|
344
|
+
// Pagamento alone takes the host's full width (its PIX pane, FUT-3367).
|
|
345
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 }, width: "100%", mx: "auto", maxWidth: c.step === "payment" ? "none" : STEP_COLUMN_MAX }}>
|
|
344
346
|
<CheckoutHeader copy={copy} step={c.step} onBack={c.back} />
|
|
345
347
|
|
|
346
348
|
<ProgressHeader copy={copy} step={c.step} completed={c.completed} />
|
|
@@ -6,6 +6,8 @@ import { displayTotals, PayBarTotal } from "./checkout-totals";
|
|
|
6
6
|
import { useCheckoutCopy } from "./copy-context";
|
|
7
7
|
import { useMethodChoice } from "./method-choice";
|
|
8
8
|
import { MethodPicker } from "./method-picker";
|
|
9
|
+
import { PixStageProvider } from "./pix-stage";
|
|
10
|
+
import { StepColumn, usePaymentPixStage } from "./payment-step-layout";
|
|
9
11
|
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
10
12
|
import { PayerSummary } from "./payer-summary";
|
|
11
13
|
import { resolveCheckoutScreen } from "./providers/registry";
|
|
@@ -228,22 +230,6 @@ interface PaymentStepProps {
|
|
|
228
230
|
onResolved: OnCheckoutResolved;
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
/**
|
|
232
|
-
* Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
|
|
233
|
-
* method auto-raises its order and reveals its UI (PIX QR / card form) with no
|
|
234
|
-
* intermediate tap; switching method clears the previous order (controller).
|
|
235
|
-
*
|
|
236
|
-
* ## Unless the choice is not ours to ask
|
|
237
|
-
*
|
|
238
|
-
* A store that finishes checkout on the provider's own page gets NO picker
|
|
239
|
-
* here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
|
|
240
|
-
* pagamento" instead, and pressing it selects the store's hand-off method —
|
|
241
|
-
* which is the same event a tile press is, so the auto-raise, the error panel
|
|
242
|
-
* and the retry below all keep working unchanged. Preselection is suppressed
|
|
243
|
-
* for the same flow, and deliberately: it exists to spare a buyer a tap that
|
|
244
|
-
* buys them nothing, but here the tap is the buyer's consent to LEAVE, and
|
|
245
|
-
* taking it for them would redirect a checkout the moment it rendered.
|
|
246
|
-
*/
|
|
247
233
|
export function PaymentStep({
|
|
248
234
|
method,
|
|
249
235
|
onMethodChange,
|
|
@@ -270,55 +256,58 @@ export function PaymentStep({
|
|
|
270
256
|
const config = providerConfig ?? null;
|
|
271
257
|
const choice = useMethodChoice(config, method, onMethodChange);
|
|
272
258
|
useAutoRaiseOrder(order, method, creating, createError, onGenerate);
|
|
259
|
+
const pixStage = usePaymentPixStage(choice, onMethodChange);
|
|
273
260
|
|
|
274
261
|
return (
|
|
275
|
-
<
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
262
|
+
<PixStageProvider stage={pixStage}>
|
|
263
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
264
|
+
{/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
|
|
265
|
+
than in a sticky bar of its own: this step's actions belong to the
|
|
266
|
+
pane below it — the PIX code, the card form's own pay bar, the
|
|
267
|
+
hand-off button — and a second bar would put two "pay" controls on
|
|
268
|
+
one screen. */}
|
|
269
|
+
<PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
|
|
282
270
|
|
|
283
|
-
|
|
284
|
-
|
|
271
|
+
{/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
|
|
272
|
+
<StepColumn>
|
|
273
|
+
<PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
|
|
274
|
+
</StepColumn>
|
|
285
275
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
onChange={onMethodChange}
|
|
290
|
-
cardUnavailable={choice.cardUnavailable}
|
|
291
|
-
offered={choice.offered}
|
|
292
|
-
pixByStore={config?.chain?.find((link) => link.methods.includes("PIX"))?.confirmation === "MANUAL"}
|
|
293
|
-
/>
|
|
294
|
-
)}
|
|
276
|
+
{choice.atProvider || pixStage.afterCopy ? null : (
|
|
277
|
+
<MethodPicker value={method} onChange={onMethodChange} cardUnavailable={choice.cardUnavailable} offered={choice.offered} />
|
|
278
|
+
)}
|
|
295
279
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
280
|
+
<StepColumn full={order?.method === "PIX"}>
|
|
281
|
+
<PaymentBody
|
|
282
|
+
order={order}
|
|
283
|
+
buyer={buyer}
|
|
284
|
+
providerConfig={config}
|
|
285
|
+
method={method}
|
|
286
|
+
tenantSlug={tenantSlug}
|
|
287
|
+
onResolved={onResolved}
|
|
288
|
+
onStart={choice.onStart}
|
|
289
|
+
creating={creating}
|
|
290
|
+
pollIntervalMs={pollIntervalMs}
|
|
291
|
+
freshInstrument={freshInstrument}
|
|
292
|
+
basket={basket}
|
|
293
|
+
validateApplePayMerchant={validateApplePayMerchant}
|
|
294
|
+
/>
|
|
295
|
+
</StepColumn>
|
|
310
296
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
297
|
+
<StepColumn>
|
|
298
|
+
<RaisingState
|
|
299
|
+
order={order}
|
|
300
|
+
method={method}
|
|
301
|
+
creating={creating && !choice.atProvider}
|
|
302
|
+
createError={createError}
|
|
303
|
+
errorField={errorField}
|
|
304
|
+
errorCode={errorCode}
|
|
305
|
+
onUseEmail={onUseEmail}
|
|
306
|
+
onGenerate={onGenerate}
|
|
307
|
+
/>
|
|
308
|
+
</StepColumn>
|
|
309
|
+
</Box>
|
|
310
|
+
</PixStageProvider>
|
|
322
311
|
);
|
|
323
312
|
}
|
|
324
313
|
|
|
@@ -63,3 +63,9 @@ export const PersonOutlineIcon = makeIcon([
|
|
|
63
63
|
export const CloseIcon = makeIcon([
|
|
64
64
|
'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
|
65
65
|
]);
|
|
66
|
+
|
|
67
|
+
export const QrCodeIcon = makeIcon([
|
|
68
|
+
'M3 11h8V3H3zm2-6h4v4H5zM3 21h8v-8H3zm2-6h4v4H5zm8-12v8h8V3zm6 6h-4V5h4zm0 10h2v2h-2zm-6-6h2v2h-2zm2 2h2v2h-2zm-2 2h2v2h-2zm2 2h2v2h-2zm2-2h2v2h-2zm0-4h2v2h-2zm2 2h2v2h-2z',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export const CheckIcon = makeIcon(['M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z']);
|
|
@@ -10,52 +10,42 @@ import { useCheckoutComponents } from "./ui";
|
|
|
10
10
|
interface MethodOption {
|
|
11
11
|
value: PaymentMethod;
|
|
12
12
|
label: string;
|
|
13
|
-
description: string;
|
|
14
13
|
icon: JSX.Element;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
* The two tiles, named by the HOST (FUT-760).
|
|
19
|
-
*
|
|
20
|
-
* Built per render rather than as a module constant, because the words are no
|
|
21
|
-
* longer this package's: a frozen `OPTIONS` array could only hold the origin
|
|
22
|
-
* host's Portuguese. The order and the icons are still ours — that is layout,
|
|
23
|
-
* not language.
|
|
24
|
-
*/
|
|
25
|
-
function methodOptions(copy: MethodPickerCopy, pixByStore: boolean): MethodOption[] {
|
|
26
|
-
// A Pix the store confirms by hand is not "aprovação imediata" (FUT-3232).
|
|
27
|
-
const pixDescription = (pixByStore ? copy.pixManualDescription : undefined) ?? copy.pixDescription;
|
|
16
|
+
function methodOptions(copy: MethodPickerCopy): MethodOption[] {
|
|
28
17
|
return [
|
|
29
|
-
{ value: "PIX", label: copy.pixLabel,
|
|
30
|
-
{
|
|
31
|
-
value: "CARD",
|
|
32
|
-
label: copy.cardLabel,
|
|
33
|
-
description: copy.cardDescription,
|
|
34
|
-
icon: <CreditCardIcon />,
|
|
35
|
-
},
|
|
18
|
+
{ value: "PIX", label: copy.pixLabel, icon: <PixIcon fontSize="small" /> },
|
|
19
|
+
{ value: "CARD", label: copy.cardLabel, icon: <CreditCardIcon fontSize="small" /> },
|
|
36
20
|
];
|
|
37
21
|
}
|
|
38
22
|
|
|
39
|
-
|
|
40
|
-
function tileColor(unavailable: boolean, selected: boolean, muted: string): string {
|
|
23
|
+
function tileColor(unavailable: boolean, selected: boolean): string {
|
|
41
24
|
if (unavailable) return "text.disabled";
|
|
42
|
-
return selected ? "primary.main" :
|
|
25
|
+
return selected ? "primary.main" : "text.primary";
|
|
43
26
|
}
|
|
44
27
|
|
|
45
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* One method: its icon and its name on one line (the 2026-10-06 Pix
|
|
30
|
+
* redesign). The line under the name went: it cost the phone a row above the
|
|
31
|
+
* Pix code, and the name says enough. A tile the store cannot charge is
|
|
32
|
+
* disabled, and the reason is part of its accessible name ("Cartão, …") as
|
|
33
|
+
* well as the caption under it.
|
|
34
|
+
*/
|
|
46
35
|
function MethodTile({
|
|
47
36
|
option,
|
|
48
37
|
selected,
|
|
49
38
|
unavailable,
|
|
39
|
+
reason,
|
|
50
40
|
onSelect,
|
|
51
41
|
}: {
|
|
52
42
|
option: MethodOption;
|
|
53
43
|
selected: boolean;
|
|
54
44
|
unavailable: boolean;
|
|
45
|
+
reason: string;
|
|
55
46
|
onSelect: () => void;
|
|
56
47
|
}): JSX.Element {
|
|
57
48
|
const { Text } = useCheckoutComponents();
|
|
58
|
-
const copy = useCheckoutCopy().screens.method;
|
|
59
49
|
const cursor = unavailable ? "not-allowed" : "pointer";
|
|
60
50
|
return (
|
|
61
51
|
<Box
|
|
@@ -63,24 +53,25 @@ function MethodTile({
|
|
|
63
53
|
type="button"
|
|
64
54
|
role="radio"
|
|
65
55
|
aria-checked={selected}
|
|
56
|
+
aria-label={unavailable ? `${option.label}, ${reason}` : undefined}
|
|
66
57
|
disabled={unavailable}
|
|
67
58
|
onClick={unavailable ? undefined : onSelect}
|
|
68
59
|
data-testid={`checkout-method-${option.value}`}
|
|
69
60
|
sx={{
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
width: "100%",
|
|
62
|
+
minHeight: 48,
|
|
72
63
|
display: "flex",
|
|
73
64
|
alignItems: "center",
|
|
65
|
+
justifyContent: "center",
|
|
74
66
|
gap: 1,
|
|
75
|
-
|
|
67
|
+
px: 1.5,
|
|
76
68
|
cursor,
|
|
77
|
-
borderRadius:
|
|
78
|
-
border: "2px solid",
|
|
69
|
+
borderRadius: 1.5,
|
|
70
|
+
border: selected ? "2px solid" : "1px solid",
|
|
79
71
|
borderColor: selected ? "primary.main" : "divider",
|
|
80
72
|
bgcolor: selected ? "action.selected" : "background.paper",
|
|
81
|
-
color: tileColor(unavailable, selected
|
|
73
|
+
color: tileColor(unavailable, selected),
|
|
82
74
|
opacity: unavailable ? 0.6 : 1,
|
|
83
|
-
textAlign: "left",
|
|
84
75
|
transition: "border-color 0.15s, background-color 0.15s",
|
|
85
76
|
"& *": { cursor },
|
|
86
77
|
"&:hover": { borderColor: unavailable ? "divider" : "primary.main" },
|
|
@@ -91,47 +82,16 @@ function MethodTile({
|
|
|
91
82
|
},
|
|
92
83
|
}}
|
|
93
84
|
>
|
|
94
|
-
<Box sx={{ display: "flex"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<Text variant="body" size="sm" weight="semibold" as="span">
|
|
99
|
-
{option.label}
|
|
100
|
-
</Text>
|
|
101
|
-
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
102
|
-
{unavailable ? copy.unavailableHere : option.description}
|
|
103
|
-
</Text>
|
|
104
|
-
</Box>
|
|
85
|
+
<Box sx={{ display: "flex" }}>{option.icon}</Box>
|
|
86
|
+
<Text variant="body" size="sm" weight={selected ? "bold" : "semibold"} as="span">
|
|
87
|
+
{option.label}
|
|
88
|
+
</Text>
|
|
105
89
|
</Box>
|
|
106
90
|
);
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
* Each option is a real focusable `<button role="radio">` (keyboard + AT
|
|
112
|
-
* operable), selected-state shown by the button fill itself — so there's no radio
|
|
113
|
-
* dot taking space and the two always sit side by side.
|
|
114
|
-
*
|
|
115
|
-
* `offered` derives the options from the chain's declared capabilities
|
|
116
|
-
* (FUT-698): a provider that cannot PIX must not offer PIX — the charge walk
|
|
117
|
-
* would refuse the method after the buyer already chose it. `null` (config
|
|
118
|
-
* still loading / fetch blip) fails OPEN and offers everything: the server
|
|
119
|
-
* still fails the charge closed.
|
|
120
|
-
*
|
|
121
|
-
* `cardUnavailable` DISABLES the card option with a caption saying so
|
|
122
|
-
* (FUT-697): a provider with no browser card path must not take the buyer to a
|
|
123
|
-
* form that fails at the last click — and a tile that silently vanished would
|
|
124
|
-
* leave an unexplained one-option "choice". The caller preselects the sole
|
|
125
|
-
* remaining method, so no extra tap is demanded either.
|
|
126
|
-
*/
|
|
127
|
-
function visibleOptions(
|
|
128
|
-
copy: MethodPickerCopy,
|
|
129
|
-
offered: PaymentMethod[] | null,
|
|
130
|
-
pixByStore: boolean,
|
|
131
|
-
): MethodOption[] {
|
|
132
|
-
return methodOptions(copy, pixByStore).filter(
|
|
133
|
-
(option) => offered === null || offered.includes(option.value),
|
|
134
|
-
);
|
|
93
|
+
function visibleOptions(copy: MethodPickerCopy, offered: PaymentMethod[] | null): MethodOption[] {
|
|
94
|
+
return methodOptions(copy).filter((option) => offered === null || offered.includes(option.value));
|
|
135
95
|
}
|
|
136
96
|
|
|
137
97
|
export function MethodPicker({
|
|
@@ -139,7 +99,6 @@ export function MethodPicker({
|
|
|
139
99
|
onChange,
|
|
140
100
|
cardUnavailable = false,
|
|
141
101
|
offered = null,
|
|
142
|
-
pixByStore = false,
|
|
143
102
|
}: {
|
|
144
103
|
/** The chosen method, or `null` before the buyer has picked one. */
|
|
145
104
|
value: PaymentMethod | null;
|
|
@@ -148,36 +107,39 @@ export function MethodPicker({
|
|
|
148
107
|
cardUnavailable?: boolean;
|
|
149
108
|
/** Methods the store's chain can charge, or `null` while unknown (fail open). */
|
|
150
109
|
offered?: PaymentMethod[] | null;
|
|
151
|
-
/** True ⇒ the chain's first PIX provider is one the STORE confirms by hand. */
|
|
152
|
-
pixByStore?: boolean;
|
|
153
110
|
}): JSX.Element {
|
|
154
111
|
const { Text } = useCheckoutComponents();
|
|
155
112
|
const copy = useCheckoutCopy().screens.method;
|
|
156
|
-
const options = visibleOptions(copy, offered
|
|
113
|
+
const options = visibleOptions(copy, offered);
|
|
157
114
|
return (
|
|
158
|
-
<Box
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const unavailable = option.value === "CARD" && cardUnavailable;
|
|
170
|
-
return (
|
|
115
|
+
<Box
|
|
116
|
+
role="radiogroup"
|
|
117
|
+
aria-label={copy.groupLabel}
|
|
118
|
+
data-testid="checkout-method"
|
|
119
|
+
sx={{ display: "grid", gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`, gap: 1 }}
|
|
120
|
+
>
|
|
121
|
+
{options.map((option) => {
|
|
122
|
+
const unavailable = option.value === "CARD" && cardUnavailable;
|
|
123
|
+
const reasonId = `method-reason-${option.value}`;
|
|
124
|
+
return (
|
|
125
|
+
<Box key={option.value} sx={{ display: "flex", flexDirection: "column", gap: 0.5, minWidth: 0 }}>
|
|
171
126
|
<MethodTile
|
|
172
|
-
key={option.value}
|
|
173
127
|
option={option}
|
|
174
128
|
selected={option.value === value && !unavailable}
|
|
175
129
|
unavailable={unavailable}
|
|
130
|
+
reason={copy.unavailableHere}
|
|
176
131
|
onSelect={() => onChange(option.value)}
|
|
177
132
|
/>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
133
|
+
{unavailable ? (
|
|
134
|
+
<Box aria-hidden data-testid={reasonId} sx={{ textAlign: "center" }}>
|
|
135
|
+
<Text variant="caption" size="xs" color="secondary" as="p" style={{ margin: 0 }}>
|
|
136
|
+
{copy.unavailableHere}
|
|
137
|
+
</Text>
|
|
138
|
+
</Box>
|
|
139
|
+
) : null}
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
181
143
|
</Box>
|
|
182
144
|
);
|
|
183
145
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useCallback, useMemo, useState, type JSX, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import type { PixStage } from "./pix-stage";
|
|
5
|
+
import type { PaymentMethod } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The width of the Dados and confirmation steps, and of every non-PIX pane:
|
|
9
|
+
* the content of the 720px column the checkout always had once its host's
|
|
10
|
+
* 24px gutters are taken (672). A host that widens the checkout for the PIX
|
|
11
|
+
* pane (FUT-3367) leaves everything else exactly as wide as before.
|
|
12
|
+
*/
|
|
13
|
+
export const STEP_COLUMN_MAX = 672;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The payment step's form column. The host may give this step more room than
|
|
17
|
+
* the others (the 2026-10-06 Pix redesign lets the PIX pane put its QR and code
|
|
18
|
+
* side by side), and only that pane uses it: the card form, wallets, hand-off,
|
|
19
|
+
* the payer line and the raising and error panels keep the 672px they
|
|
20
|
+
* always had. `full` lets the PIX pane through.
|
|
21
|
+
*/
|
|
22
|
+
export function StepColumn({ full = false, children }: { full?: boolean; children: ReactNode }): JSX.Element {
|
|
23
|
+
// `:empty` keeps a self-hiding child (the payer line, the raising state)
|
|
24
|
+
// from leaving a gap in the step's flex column.
|
|
25
|
+
return <Box sx={{ width: "100%", mx: "auto", maxWidth: full ? "none" : STEP_COLUMN_MAX, "&:empty": { display: "none" } }}>{children}</Box>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The payment step's half of the PIX pane's after-copy face (the 2026-10-06
|
|
30
|
+
* Pix redesign): the flag that hides the method picker, and the way to the
|
|
31
|
+
* card that replaces it — offered only when the store actually takes a card,
|
|
32
|
+
* since with the picker hidden it is the buyer's only route there.
|
|
33
|
+
*/
|
|
34
|
+
export function usePaymentPixStage(
|
|
35
|
+
choice: { offered: PaymentMethod[] | null; cardUnavailable: boolean },
|
|
36
|
+
onMethodChange: (method: PaymentMethod) => void,
|
|
37
|
+
): PixStage {
|
|
38
|
+
const [afterCopy, setAfterCopy] = useState(false);
|
|
39
|
+
const cardOffered = (choice.offered === null || choice.offered.includes("CARD")) && !choice.cardUnavailable;
|
|
40
|
+
const preferCard = useCallback(() => {
|
|
41
|
+
setAfterCopy(false);
|
|
42
|
+
onMethodChange("CARD");
|
|
43
|
+
}, [onMethodChange]);
|
|
44
|
+
return useMemo(
|
|
45
|
+
() => ({ afterCopy, setAfterCopy, preferCard: cardOffered ? preferCard : undefined }),
|
|
46
|
+
[afterCopy, cardOffered, preferCard],
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
|
|
52
|
+
* method auto-raises its order and reveals its UI (PIX QR / card form) with no
|
|
53
|
+
* intermediate tap; switching method clears the previous order (controller).
|
|
54
|
+
*
|
|
55
|
+
* ## Unless the choice is not ours to ask
|
|
56
|
+
*
|
|
57
|
+
* A store that finishes checkout on the provider's own page gets NO picker
|
|
58
|
+
* here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
|
|
59
|
+
* pagamento" instead, and pressing it selects the store's hand-off method —
|
|
60
|
+
* which is the same event a tile press is, so the auto-raise, the error panel
|
|
61
|
+
* and the retry below all keep working unchanged. Preselection is suppressed
|
|
62
|
+
* for the same flow, and deliberately: it exists to spare a buyer a tap that
|
|
63
|
+
* buys them nothing, but here the tap is the buyer's consent to LEAVE, and
|
|
64
|
+
* taking it for them would redirect a checkout the moment it rendered.
|
|
65
|
+
*/
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
+
import { CheckIcon, ContentCopyIcon, CreditCardIcon, QrCodeIcon, ScheduleIcon } from "./icons";
|
|
6
|
+
import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
|
|
7
|
+
import type { PixAfterCopyCopy } from "./screens-copy";
|
|
8
|
+
import type { PixCharge } from "./types";
|
|
9
|
+
import { useCheckoutComponents } from "./ui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The PIX pane after a copy (the 2026-10-06 Pix redesign): the buyer has left
|
|
13
|
+
* for their bank app and comes back to a screen that says what happens next —
|
|
14
|
+
* a three-step checklist with the one they are on in bold — instead of the
|
|
15
|
+
* code they already have.
|
|
16
|
+
*
|
|
17
|
+
* Every Pix gets it; whether the BANK or the STORE confirms changes only the
|
|
18
|
+
* words (`PixAfterCopyCopy`). Wide, a smaller QR stays on the right for a
|
|
19
|
+
* buyer who would rather scan after all; narrow, "Ver QR code" goes back to
|
|
20
|
+
* the pane on its QR tab.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type StepState = "done" | "current" | "pending";
|
|
24
|
+
|
|
25
|
+
function StepMark({ state, index }: { state: StepState; index: number }): JSX.Element {
|
|
26
|
+
const size = { width: 26, height: 26, [PIX_WIDE]: { width: 28, height: 28 } };
|
|
27
|
+
if (state === "done") {
|
|
28
|
+
return (
|
|
29
|
+
<Box sx={{ ...size, flex: "none", borderRadius: "50%", bgcolor: "success.main", color: "success.contrastText", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
30
|
+
<CheckIcon sx={{ fontSize: 16 }} />
|
|
31
|
+
</Box>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<Box
|
|
36
|
+
sx={{
|
|
37
|
+
...size,
|
|
38
|
+
flex: "none",
|
|
39
|
+
boxSizing: "border-box",
|
|
40
|
+
borderRadius: "50%",
|
|
41
|
+
border: "2px solid",
|
|
42
|
+
borderColor: state === "current" ? "primary.main" : "divider",
|
|
43
|
+
color: state === "current" ? "primary.main" : "text.secondary",
|
|
44
|
+
fontSize: "0.75rem",
|
|
45
|
+
fontWeight: 700,
|
|
46
|
+
display: "flex",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
justifyContent: "center",
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{index}
|
|
52
|
+
</Box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Step({ state, index, children }: { state: StepState; index: number; children: ReactNode }): JSX.Element {
|
|
57
|
+
const { Text } = useCheckoutComponents();
|
|
58
|
+
return (
|
|
59
|
+
<Box component="li" data-testid={`pix-after-copy-step-${index}`} data-state={state} sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
|
60
|
+
<StepMark state={state} index={index} />
|
|
61
|
+
<Text variant="body" size="sm" weight={state === "current" ? "bold" : undefined} color={state === "current" ? undefined : "secondary"} as="span">
|
|
62
|
+
{children}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The checklist. Step 2's wording follows the layout (an app, or internet banking too). */
|
|
69
|
+
function Checklist({ words, deadline }: { words: PixAfterCopyCopy; deadline: string }): JSX.Element {
|
|
70
|
+
return (
|
|
71
|
+
<Box
|
|
72
|
+
component="ol"
|
|
73
|
+
sx={{ m: 0, p: 2, listStyle: "none", border: "1px solid", borderColor: "divider", borderRadius: 1.5, display: "flex", flexDirection: "column", gap: 1.75, [PIX_WIDE]: { py: 2.25, px: 2.5, gap: 2 } }}
|
|
74
|
+
>
|
|
75
|
+
<Step state="done" index={1}>{words.stepCopied}</Step>
|
|
76
|
+
<Step state="current" index={2}>
|
|
77
|
+
<Box component="span" sx={{ [PIX_WIDE]: { display: "none" } }}>{words.stepPay(false)}</Box>
|
|
78
|
+
<Box component="span" sx={{ display: "none", [PIX_WIDE]: { display: "inline" } }}>{words.stepPay(true)}</Box>
|
|
79
|
+
</Step>
|
|
80
|
+
<Step state="pending" index={3}>{words.stepConfirm(deadline)}</Step>
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The clock, the title and the sentence: a centred stack narrow, a row wide. */
|
|
86
|
+
function Heading({ words, totalLabel }: { words: PixAfterCopyCopy; totalLabel: string }): JSX.Element {
|
|
87
|
+
const { Text } = useCheckoutComponents();
|
|
88
|
+
return (
|
|
89
|
+
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1.25, textAlign: "center", [PIX_WIDE]: { flexDirection: "row", gap: 2, textAlign: "left" } }}>
|
|
90
|
+
<Box sx={{ flex: "none", width: 64, height: 64, borderRadius: "50%", bgcolor: "action.selected", color: "primary.main", display: "flex", alignItems: "center", justifyContent: "center", [PIX_WIDE]: { width: 56, height: 56 } }}>
|
|
91
|
+
<ScheduleIcon sx={{ fontSize: 30 }} />
|
|
92
|
+
</Box>
|
|
93
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
|
94
|
+
{/* The screen's title outranks the checklist: 22px, 28px wide (the prototype's scale). */}
|
|
95
|
+
<Box sx={{ "& > *": { fontSize: "1.375rem", lineHeight: 1.25 }, [PIX_WIDE]: { "& > *": { fontSize: "1.75rem" } } }}>
|
|
96
|
+
<Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }} data-testid="pix-after-copy-title">
|
|
97
|
+
{words.title}
|
|
98
|
+
</Text>
|
|
99
|
+
</Box>
|
|
100
|
+
<Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0 }}>
|
|
101
|
+
<Box component="span" sx={{ [PIX_WIDE]: { display: "none" } }}>{words.body(totalLabel, false)}</Box>
|
|
102
|
+
<Box component="span" sx={{ display: "none", [PIX_WIDE]: { display: "inline" } }}>
|
|
103
|
+
<Emphasized text={words.body(totalLabel, true)} />
|
|
104
|
+
</Box>
|
|
105
|
+
</Text>
|
|
106
|
+
</Box>
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface PixAfterCopyProps {
|
|
112
|
+
pix: PixCharge;
|
|
113
|
+
words: PixAfterCopyCopy;
|
|
114
|
+
totalLabel: string;
|
|
115
|
+
deadline: string;
|
|
116
|
+
justCopied: boolean;
|
|
117
|
+
onCopyAgain: () => void;
|
|
118
|
+
onShowQr: () => void;
|
|
119
|
+
onPreferCard?: () => void;
|
|
120
|
+
/** The poll's line ("Verificando…", or the stalled panel that replaces it). */
|
|
121
|
+
footer: ReactNode;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The narrow layout's two secondaries side by side; wide, the card link joins the row. */
|
|
125
|
+
function Actions({ justCopied, onCopyAgain, onShowQr, onPreferCard }: Pick<PixAfterCopyProps, "justCopied" | "onCopyAgain" | "onShowQr" | "onPreferCard">): JSX.Element {
|
|
126
|
+
const { Button } = useCheckoutComponents();
|
|
127
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
128
|
+
return (
|
|
129
|
+
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
|
|
130
|
+
{/* Narrow, a grid cell; wide, its own width beside the link (the prototype's row). */}
|
|
131
|
+
<Box sx={{ minWidth: 0, [PIX_WIDE]: { flex: "none", "& > *": { width: "auto" } } }}>
|
|
132
|
+
<Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} dataTestId="pix-copy-again">
|
|
133
|
+
{justCopied ? copy.copiedAction : copy.copyAgainAction}
|
|
134
|
+
</Button>
|
|
135
|
+
</Box>
|
|
136
|
+
<Box sx={{ display: "contents", [PIX_WIDE]: { display: "none" } }}>
|
|
137
|
+
<Button variant="outline" color="neutral" size="md" fullWidth icon={<QrCodeIcon fontSize="small" />} onClick={onShowQr} dataTestId="pix-show-qr">
|
|
138
|
+
{copy.showQrAction}
|
|
139
|
+
</Button>
|
|
140
|
+
</Box>
|
|
141
|
+
{onPreferCard ? (
|
|
142
|
+
<Box sx={{ display: "none", [PIX_WIDE]: { display: "block" } }}>
|
|
143
|
+
<Button variant="text" color="primary" size="md" icon={<CreditCardIcon fontSize="small" />} onClick={onPreferCard} dataTestId="pix-prefer-card-wide">
|
|
144
|
+
{copy.preferCardAction}
|
|
145
|
+
</Button>
|
|
146
|
+
</Box>
|
|
147
|
+
) : null}
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
|
|
153
|
+
const { Button, Text } = useCheckoutComponents();
|
|
154
|
+
const copy = useCheckoutCopy().screens.pix;
|
|
155
|
+
const { pix, words, totalLabel, deadline, onPreferCard, footer } = props;
|
|
156
|
+
return (
|
|
157
|
+
<Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
|
|
158
|
+
<Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
|
|
159
|
+
<Heading words={words} totalLabel={totalLabel} />
|
|
160
|
+
<Checklist words={words} deadline={deadline} />
|
|
161
|
+
<Actions {...props} />
|
|
162
|
+
{footer}
|
|
163
|
+
{onPreferCard ? (
|
|
164
|
+
<Box sx={{ display: "flex", justifyContent: "center", [PIX_WIDE]: { display: "none" } }}>
|
|
165
|
+
<Button variant="text" color="primary" size="md" icon={<CreditCardIcon fontSize="small" />} onClick={onPreferCard} dataTestId="pix-prefer-card">
|
|
166
|
+
{copy.preferCardAction}
|
|
167
|
+
</Button>
|
|
168
|
+
</Box>
|
|
169
|
+
) : null}
|
|
170
|
+
</Box>
|
|
171
|
+
<Box sx={{ display: "none", [PIX_WIDE]: { display: "flex" }, flex: "1 1 240px", minWidth: 0, flexDirection: "column", alignItems: "center", gap: 1.5, p: 2.5, borderRadius: 1.75, border: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
|
172
|
+
<Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ margin: 0, textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
|
173
|
+
{copy.notPaidYet}
|
|
174
|
+
</Text>
|
|
175
|
+
<PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
|
|
176
|
+
<Text variant="body" size="xs" color="secondary" as="p" style={{ margin: 0, textAlign: "center" }}>
|
|
177
|
+
{copy.scanFromPhone}
|
|
178
|
+
</Text>
|
|
179
|
+
</Box>
|
|
180
|
+
</Box>
|
|
181
|
+
);
|
|
182
|
+
}
|