@12-apps/payments-frontend 1.0.0 → 1.2.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 +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- package/src/components/CheckoutFlow.tsx +0 -169
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
|
|
5
|
+
import type { OrderStatus } from "./types";
|
|
6
|
+
import { useCheckoutComponents } from "./ui";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The last screen of checkout.
|
|
10
|
+
*
|
|
11
|
+
* It used to be a status page: the heading said "Pagamento" — the step the
|
|
12
|
+
* buyer had just finished — with a small success strip underneath and a chip
|
|
13
|
+
* repeating what the strip already said. Three things were wrong with that for
|
|
14
|
+
* somebody who has just handed over money:
|
|
15
|
+
*
|
|
16
|
+
* - it never said the thing they came for. "Pedido confirmado" is the outcome;
|
|
17
|
+
* "Pagamento" is a step, and reading it back after paying leaves the buyer
|
|
18
|
+
* wondering whether anything happened.
|
|
19
|
+
* - the confirmation carried no facts they could use later — no reference to
|
|
20
|
+
* quote, no note of where the receipt went.
|
|
21
|
+
* - success and failure looked the same shape, so the screen's most important
|
|
22
|
+
* distinction was carried only by a small coloured strip.
|
|
23
|
+
*
|
|
24
|
+
* Each state now leads with its own outcome, and the paid one carries the
|
|
25
|
+
* amount, the order reference and the receipt's destination.
|
|
26
|
+
*
|
|
27
|
+
* The FLOW is untouched: same steps, same actions, same polling. Only what the
|
|
28
|
+
* screen says and how it is arranged has changed.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Everything the hero block needs, per outcome. */
|
|
32
|
+
interface Outcome {
|
|
33
|
+
heading: string;
|
|
34
|
+
support: string;
|
|
35
|
+
icon: JSX.Element;
|
|
36
|
+
/** Semantic theme token — never a raw colour. */
|
|
37
|
+
tone: "success" | "danger" | "warning" | "neutral";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const OUTCOME: Record<OrderStatus, Outcome> = {
|
|
41
|
+
PAID: {
|
|
42
|
+
heading: "Pedido confirmado",
|
|
43
|
+
support: "Recebemos seu pagamento e já registramos o pedido.",
|
|
44
|
+
icon: <CheckCircleOutlineIcon fontSize="large" />,
|
|
45
|
+
tone: "success",
|
|
46
|
+
},
|
|
47
|
+
AWAITING_PAYMENT: {
|
|
48
|
+
heading: "Confirmando seu pagamento",
|
|
49
|
+
support: "Isso costuma levar alguns segundos. Pode deixar esta tela aberta.",
|
|
50
|
+
icon: <ScheduleIcon fontSize="large" />,
|
|
51
|
+
tone: "neutral",
|
|
52
|
+
},
|
|
53
|
+
FAILED: {
|
|
54
|
+
heading: "Pagamento não concluído",
|
|
55
|
+
// Said plainly and first: the fear on this screen is having been charged
|
|
56
|
+
// for an order that failed.
|
|
57
|
+
support: "Nenhum valor foi cobrado. Você pode tentar novamente.",
|
|
58
|
+
icon: <ErrorOutlineIcon fontSize="large" />,
|
|
59
|
+
tone: "danger",
|
|
60
|
+
},
|
|
61
|
+
EXPIRED: {
|
|
62
|
+
heading: "O código expirou",
|
|
63
|
+
support: "Nenhum valor foi cobrado. Gere um novo código para continuar.",
|
|
64
|
+
icon: <ScheduleIcon fontSize="large" />,
|
|
65
|
+
tone: "warning",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const TONE_COLOR: Record<Outcome["tone"], string> = {
|
|
70
|
+
success: "success.main",
|
|
71
|
+
danger: "error.main",
|
|
72
|
+
warning: "warning.main",
|
|
73
|
+
neutral: "text.secondary",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The buyer's quotable reference.
|
|
78
|
+
*
|
|
79
|
+
* The order id is a uuid — unreadable over a phone call and impossible to copy
|
|
80
|
+
* by eye — so the screen shows its first block, uppercased. It is the real id's
|
|
81
|
+
* own prefix rather than a second number, so support can still find the order
|
|
82
|
+
* from what the buyer reads out.
|
|
83
|
+
*/
|
|
84
|
+
function orderReference(orderId: string): string {
|
|
85
|
+
return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The headline block: icon, outcome, and one supporting line. */
|
|
89
|
+
function OutcomeHero({ status }: { status: OrderStatus }): JSX.Element {
|
|
90
|
+
const { Text } = useCheckoutComponents();
|
|
91
|
+
const outcome = OUTCOME[status];
|
|
92
|
+
return (
|
|
93
|
+
<Box
|
|
94
|
+
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
95
|
+
// they assert the buyer actually got there.
|
|
96
|
+
data-testid={status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`}
|
|
97
|
+
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
98
|
+
>
|
|
99
|
+
<Box sx={{ color: TONE_COLOR[outcome.tone], display: "flex" }}>{outcome.icon}</Box>
|
|
100
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
101
|
+
{outcome.heading}
|
|
102
|
+
</Text>
|
|
103
|
+
<Text variant="body" size="sm" as="p" style={{ opacity: 0.75 }}>
|
|
104
|
+
{outcome.support}
|
|
105
|
+
</Text>
|
|
106
|
+
</Box>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One label/value row of the paid receipt block. */
|
|
111
|
+
function Fact({ label, value, testId }: { label: string; value: string; testId?: string }): JSX.Element {
|
|
112
|
+
const { Text } = useCheckoutComponents();
|
|
113
|
+
return (
|
|
114
|
+
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
|
|
115
|
+
<Text variant="body" size="sm" as="span" style={{ opacity: 0.75 }}>
|
|
116
|
+
{label}
|
|
117
|
+
</Text>
|
|
118
|
+
<Text variant="body" size="sm" weight="bold" as="span" data-testid={testId}>
|
|
119
|
+
{value}
|
|
120
|
+
</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a paid buyer will want later: how much left their account, which order
|
|
127
|
+
* it was, and where the receipt went. Rendered only for PAID — on any other
|
|
128
|
+
* outcome these facts are either untrue or not yet knowable.
|
|
129
|
+
*/
|
|
130
|
+
function PaidFacts({
|
|
131
|
+
totalLabel,
|
|
132
|
+
orderId,
|
|
133
|
+
buyerEmail,
|
|
134
|
+
}: {
|
|
135
|
+
totalLabel: string;
|
|
136
|
+
orderId?: string;
|
|
137
|
+
buyerEmail?: string;
|
|
138
|
+
}): JSX.Element {
|
|
139
|
+
return (
|
|
140
|
+
<Box
|
|
141
|
+
data-testid="payment-receipt"
|
|
142
|
+
sx={{
|
|
143
|
+
display: "flex",
|
|
144
|
+
flexDirection: "column",
|
|
145
|
+
gap: 1,
|
|
146
|
+
p: 2,
|
|
147
|
+
borderRadius: 2,
|
|
148
|
+
bgcolor: "action.hover",
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<Fact label="Valor pago" value={totalLabel} testId="payment-amount" />
|
|
152
|
+
{orderId ? (
|
|
153
|
+
<Fact label="Pedido" value={`#${orderReference(orderId)}`} testId="payment-reference" />
|
|
154
|
+
) : null}
|
|
155
|
+
{buyerEmail ? <Fact label="Comprovante enviado para" value={buyerEmail} /> : null}
|
|
156
|
+
</Box>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The next-action row: retry / regenerate for failures, always back-to-menu. */
|
|
161
|
+
function StatusActions({
|
|
162
|
+
status,
|
|
163
|
+
onRetry,
|
|
164
|
+
onRegenerate,
|
|
165
|
+
onBackToMenu,
|
|
166
|
+
}: {
|
|
167
|
+
status: OrderStatus;
|
|
168
|
+
onRetry?: () => void;
|
|
169
|
+
onRegenerate?: () => void;
|
|
170
|
+
onBackToMenu: () => void;
|
|
171
|
+
}): JSX.Element {
|
|
172
|
+
const { Button } = useCheckoutComponents();
|
|
173
|
+
return (
|
|
174
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
175
|
+
{status === "FAILED" && onRetry ? (
|
|
176
|
+
<Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
|
|
177
|
+
Tentar novamente
|
|
178
|
+
</Button>
|
|
179
|
+
) : null}
|
|
180
|
+
{status === "EXPIRED" && onRegenerate ? (
|
|
181
|
+
<Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
|
|
182
|
+
Gerar novo código
|
|
183
|
+
</Button>
|
|
184
|
+
) : null}
|
|
185
|
+
<Button
|
|
186
|
+
// Full width and last, so the thumb lands on the same place in every
|
|
187
|
+
// outcome instead of hunting a button that moves with the state.
|
|
188
|
+
variant={status === "PAID" ? "solid" : "outline"}
|
|
189
|
+
color={status === "PAID" ? "primary" : "neutral"}
|
|
190
|
+
size="lg"
|
|
191
|
+
onClick={onBackToMenu}
|
|
192
|
+
dataTestId="payment-back-to-menu"
|
|
193
|
+
>
|
|
194
|
+
Voltar ao cardápio
|
|
195
|
+
</Button>
|
|
196
|
+
</Box>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function PaymentStatus({
|
|
201
|
+
status,
|
|
202
|
+
totalLabel,
|
|
203
|
+
orderId,
|
|
204
|
+
buyerEmail,
|
|
205
|
+
onRetry,
|
|
206
|
+
onRegenerate,
|
|
207
|
+
onBackToMenu,
|
|
208
|
+
paidExtra,
|
|
209
|
+
}: {
|
|
210
|
+
status: OrderStatus | null;
|
|
211
|
+
totalLabel: string;
|
|
212
|
+
/** The created order, when there is one — absent before the charge is raised. */
|
|
213
|
+
orderId?: string;
|
|
214
|
+
/** Where the receipt was sent, when known. */
|
|
215
|
+
buyerEmail?: string;
|
|
216
|
+
onRetry?: () => void;
|
|
217
|
+
onRegenerate?: () => void;
|
|
218
|
+
onBackToMenu: () => void;
|
|
219
|
+
/**
|
|
220
|
+
* Host content rendered under the paid receipt, and ONLY on PAID — the
|
|
221
|
+
* storefront puts its PWA install invite here (FUT-640): a customer who has
|
|
222
|
+
* just completed an order knows exactly what they would be installing, which
|
|
223
|
+
* is the difference between an offer and an interruption.
|
|
224
|
+
*/
|
|
225
|
+
paidExtra?: ReactNode;
|
|
226
|
+
}): JSX.Element {
|
|
227
|
+
const { LoadingState } = useCheckoutComponents();
|
|
228
|
+
const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<Box
|
|
232
|
+
data-testid="payment-status"
|
|
233
|
+
data-status={effective}
|
|
234
|
+
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
235
|
+
>
|
|
236
|
+
<OutcomeHero status={effective} />
|
|
237
|
+
|
|
238
|
+
{effective === "PAID" ? (
|
|
239
|
+
<PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
240
|
+
) : null}
|
|
241
|
+
|
|
242
|
+
{effective === "PAID" ? paidExtra : null}
|
|
243
|
+
|
|
244
|
+
{effective === "AWAITING_PAYMENT" ? (
|
|
245
|
+
<LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
|
|
246
|
+
) : null}
|
|
247
|
+
|
|
248
|
+
<StatusActions
|
|
249
|
+
status={effective}
|
|
250
|
+
onRetry={onRetry}
|
|
251
|
+
onRegenerate={onRegenerate}
|
|
252
|
+
onBackToMenu={onBackToMenu}
|
|
253
|
+
/>
|
|
254
|
+
</Box>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a shopper sees when the store cannot take money online.
|
|
3
|
+
*
|
|
4
|
+
* A store with no active payment provider used to render the full payment step
|
|
5
|
+
* — PIX and Cartão side by side — and only fail at order creation, with a
|
|
6
|
+
* "tente novamente em instantes" that no amount of retrying could fix. The
|
|
7
|
+
* store is not momentarily busy; it simply does not charge online.
|
|
8
|
+
*
|
|
9
|
+
* The remedy depends on how the store operates, so the two cases are answered
|
|
10
|
+
* differently rather than with one vague apology:
|
|
11
|
+
*
|
|
12
|
+
* waiter reachable — someone is there to take payment: offer the waiter.
|
|
13
|
+
* not reachable — nobody is; say plainly that this store does not charge
|
|
14
|
+
* online, so the shopper stops waiting for a payment screen.
|
|
15
|
+
*
|
|
16
|
+
* "Reachable" is a property of THIS shopper, not of the store: a mesas
|
|
17
|
+
* restaurant also serves the balcão, and a shopper with no mesa has no comanda
|
|
18
|
+
* for a waiter to close. Offering them the waiter sent them to a comanda screen
|
|
19
|
+
* whose own mesa gate bounced them straight back to the menu — a dead end
|
|
20
|
+
* dressed up as the remedy. So the caller decides, from the shopper's mesa.
|
|
21
|
+
*/
|
|
22
|
+
import { Box } from "@mui/material";
|
|
23
|
+
import type { JSX } from "react";
|
|
24
|
+
|
|
25
|
+
import { useCheckoutComponents } from "./ui";
|
|
26
|
+
|
|
27
|
+
interface PaymentsUnavailableProps {
|
|
28
|
+
/**
|
|
29
|
+
* Whether THIS shopper can settle with a waiter — mesas on AND a mesa of
|
|
30
|
+
* their own. Decides which remedy is offered.
|
|
31
|
+
*/
|
|
32
|
+
waiterAvailable: boolean;
|
|
33
|
+
/** Called when the shopper asks for a waiter. Omitted ⇒ no button. */
|
|
34
|
+
onCallWaiter?: () => void;
|
|
35
|
+
/** True while the waiter request is in flight. */
|
|
36
|
+
calling?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PaymentsUnavailable({
|
|
40
|
+
waiterAvailable,
|
|
41
|
+
onCallWaiter,
|
|
42
|
+
calling = false,
|
|
43
|
+
}: PaymentsUnavailableProps): JSX.Element {
|
|
44
|
+
const { Alert, Button } = useCheckoutComponents();
|
|
45
|
+
if (!waiterAvailable) {
|
|
46
|
+
return (
|
|
47
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-payments-disabled">
|
|
48
|
+
<Alert
|
|
49
|
+
variant="info"
|
|
50
|
+
title="Pagamento online indisponível"
|
|
51
|
+
description="Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido."
|
|
52
|
+
showIcon
|
|
53
|
+
/>
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-call-waiter">
|
|
60
|
+
<Alert
|
|
61
|
+
variant="info"
|
|
62
|
+
title="Pagamento com o garçom"
|
|
63
|
+
description="Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa."
|
|
64
|
+
showIcon
|
|
65
|
+
/>
|
|
66
|
+
{onCallWaiter ? (
|
|
67
|
+
<Button
|
|
68
|
+
variant="solid"
|
|
69
|
+
size="lg"
|
|
70
|
+
disabled={calling}
|
|
71
|
+
onClick={onCallWaiter}
|
|
72
|
+
dataTestId="checkout-call-waiter-button"
|
|
73
|
+
>
|
|
74
|
+
{calling ? "Chamando..." : "Chamar garçom"}
|
|
75
|
+
</Button>
|
|
76
|
+
) : null}
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useEffect, useState, type JSX } from "react";
|
|
3
|
+
import QRCode from "react-qr-code";
|
|
4
|
+
|
|
5
|
+
import { ContentCopyIcon } from "./icons";
|
|
6
|
+
import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
|
|
7
|
+
import { useCheckoutComponents } from "./ui";
|
|
8
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PIX payment view: a scannable QR (rendered client-side from the "copia e cola"
|
|
12
|
+
* payload), the copyable code, and a live pending indicator. Polls the order in
|
|
13
|
+
* the background and hands a terminal status up to the parent, which then shows
|
|
14
|
+
* the payment-status screen.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The copyable "copia e cola" strip with its copy button. */
|
|
18
|
+
function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
19
|
+
const { Button, Text } = useCheckoutComponents();
|
|
20
|
+
const [copied, setCopied] = useState(false);
|
|
21
|
+
|
|
22
|
+
const copyCode = async (): Promise<void> => {
|
|
23
|
+
try {
|
|
24
|
+
await navigator.clipboard?.writeText(pix.copyPaste);
|
|
25
|
+
setCopied(true);
|
|
26
|
+
setTimeout(() => setCopied(false), 2000);
|
|
27
|
+
} catch {
|
|
28
|
+
/* clipboard unavailable — the code is still visible to copy manually */
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
sx={{
|
|
35
|
+
display: "flex",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
gap: 1,
|
|
38
|
+
width: "100%",
|
|
39
|
+
maxWidth: 420,
|
|
40
|
+
p: 1,
|
|
41
|
+
borderRadius: 1,
|
|
42
|
+
border: "1px solid",
|
|
43
|
+
borderColor: "divider",
|
|
44
|
+
bgcolor: "background.default",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<Text
|
|
48
|
+
variant="code"
|
|
49
|
+
size="xs"
|
|
50
|
+
as="span"
|
|
51
|
+
data-testid="pix-code"
|
|
52
|
+
style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
|
53
|
+
>
|
|
54
|
+
{pix.copyPaste}
|
|
55
|
+
</Text>
|
|
56
|
+
<Button
|
|
57
|
+
variant="outline"
|
|
58
|
+
color="neutral"
|
|
59
|
+
size="sm"
|
|
60
|
+
icon={<ContentCopyIcon fontSize="small" />}
|
|
61
|
+
onClick={() => {
|
|
62
|
+
void copyCode();
|
|
63
|
+
}}
|
|
64
|
+
dataTestId="pix-copy"
|
|
65
|
+
>
|
|
66
|
+
{copied ? "Copiado!" : "Copiar"}
|
|
67
|
+
</Button>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The live footer: poll error, or the pulsing "awaiting payment" indicator. */
|
|
73
|
+
function PixPollFooter({ error }: { error: string | null }): JSX.Element {
|
|
74
|
+
const { Alert, Text } = useCheckoutComponents();
|
|
75
|
+
if (error) {
|
|
76
|
+
return (
|
|
77
|
+
<Alert
|
|
78
|
+
variant="danger"
|
|
79
|
+
title="Não foi possível confirmar o pagamento"
|
|
80
|
+
description={error}
|
|
81
|
+
showIcon
|
|
82
|
+
data-testid="pix-poll-error"
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "text.secondary" }}>
|
|
88
|
+
<LoadingDot />
|
|
89
|
+
<Text variant="caption" size="xs" color="secondary" as="span" data-testid="pix-awaiting">
|
|
90
|
+
Aguardando pagamento…
|
|
91
|
+
</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function PixView({
|
|
97
|
+
order,
|
|
98
|
+
onResolved,
|
|
99
|
+
pollIntervalMs = 2500,
|
|
100
|
+
}: {
|
|
101
|
+
order: CheckoutOrder;
|
|
102
|
+
onResolved: (status: OrderStatus) => void;
|
|
103
|
+
pollIntervalMs?: number;
|
|
104
|
+
}): JSX.Element {
|
|
105
|
+
const { Text } = useCheckoutComponents();
|
|
106
|
+
const { status, error } = usePaymentPolling(order.orderId, { intervalMs: pollIntervalMs });
|
|
107
|
+
|
|
108
|
+
// Bubble a terminal status up once, so the parent can advance to the status step.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (status && status !== "AWAITING_PAYMENT") {
|
|
111
|
+
onResolved(status);
|
|
112
|
+
}
|
|
113
|
+
}, [status, onResolved]);
|
|
114
|
+
|
|
115
|
+
const pix = order.pix;
|
|
116
|
+
if (!pix) {
|
|
117
|
+
return (
|
|
118
|
+
<Text variant="body" size="sm" color="danger" as="p" data-testid="pix-missing">
|
|
119
|
+
Não foi possível gerar o código PIX.
|
|
120
|
+
</Text>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const validUntil = new Date(pix.expiresAt).toLocaleTimeString("pt-BR", {
|
|
125
|
+
hour: "2-digit",
|
|
126
|
+
minute: "2-digit",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Box
|
|
131
|
+
data-testid="pix-view"
|
|
132
|
+
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, textAlign: "center" }}
|
|
133
|
+
>
|
|
134
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
135
|
+
Pague com PIX
|
|
136
|
+
</Text>
|
|
137
|
+
<Text variant="body" size="sm" color="secondary" as="p">
|
|
138
|
+
Escaneie o QR code no app do seu banco ou copie o código. Total {order.totalLabel}.
|
|
139
|
+
</Text>
|
|
140
|
+
|
|
141
|
+
<Box
|
|
142
|
+
data-testid="pix-qr"
|
|
143
|
+
role="img"
|
|
144
|
+
aria-label="QR Code PIX para pagamento"
|
|
145
|
+
sx={{ bgcolor: "background.paper", p: 2, borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
|
146
|
+
>
|
|
147
|
+
<QRCode value={pix.copyPaste} size={200} />
|
|
148
|
+
</Box>
|
|
149
|
+
|
|
150
|
+
<PixCodeBox pix={pix} />
|
|
151
|
+
|
|
152
|
+
<Text variant="caption" size="xs" color="secondary" as="p" data-testid="pix-expiry">
|
|
153
|
+
Válido até {validUntil}. A confirmação é automática.
|
|
154
|
+
</Text>
|
|
155
|
+
|
|
156
|
+
<PixPollFooter error={error} />
|
|
157
|
+
</Box>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Small pulsing dot indicating the background poll is live. */
|
|
162
|
+
function LoadingDot(): JSX.Element {
|
|
163
|
+
return (
|
|
164
|
+
<Box
|
|
165
|
+
aria-hidden
|
|
166
|
+
sx={{
|
|
167
|
+
width: 8,
|
|
168
|
+
height: 8,
|
|
169
|
+
borderRadius: "50%",
|
|
170
|
+
bgcolor: "primary.main",
|
|
171
|
+
animation: "pixPulse 1.2s ease-in-out infinite",
|
|
172
|
+
"@keyframes pixPulse": {
|
|
173
|
+
"0%, 100%": { opacity: 0.3 },
|
|
174
|
+
"50%": { opacity: 1 },
|
|
175
|
+
},
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|