@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.
Files changed (42) hide show
  1. package/package.json +7 -4
  2. package/src/__tests__/provider-priority-list.test.tsx +2 -2
  3. package/src/__tests__/slugged-provider.test.tsx +108 -0
  4. package/src/card/cpf.ts +42 -0
  5. package/src/card/fields.tsx +254 -0
  6. package/src/card/format.ts +103 -0
  7. package/src/card/index.ts +42 -0
  8. package/src/card/stripe-token.ts +81 -0
  9. package/src/card/tokenize.test.ts +194 -0
  10. package/src/card/tokenize.ts +327 -0
  11. package/src/card/types.ts +54 -0
  12. package/src/components/PaymentProviderSettings.tsx +30 -4
  13. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
  14. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
  15. package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
  16. package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
  17. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
  18. package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
  19. package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
  20. package/src/components/checkout/buyer-info-form.tsx +138 -0
  21. package/src/components/checkout/card-view.tsx +128 -0
  22. package/src/components/checkout/checkout-flow.tsx +201 -0
  23. package/src/components/checkout/checkout-steps.tsx +366 -0
  24. package/src/components/checkout/client.ts +157 -0
  25. package/src/components/checkout/hosted-return.ts +92 -0
  26. package/src/components/checkout/icons.tsx +61 -0
  27. package/src/components/checkout/method-capability.ts +69 -0
  28. package/src/components/checkout/method-picker.tsx +153 -0
  29. package/src/components/checkout/mui-defaults.tsx +218 -0
  30. package/src/components/checkout/payer-summary.tsx +81 -0
  31. package/src/components/checkout/payment-status.tsx +256 -0
  32. package/src/components/checkout/payments-unavailable.tsx +79 -0
  33. package/src/components/checkout/pix-view.tsx +179 -0
  34. package/src/components/checkout/types.ts +223 -0
  35. package/src/components/checkout/ui.tsx +171 -0
  36. package/src/components/checkout/use-card-checkout.ts +346 -0
  37. package/src/components/checkout/use-checkout-controller.ts +252 -0
  38. package/src/components/checkout/use-payment-polling.ts +93 -0
  39. package/src/components/settings-state.ts +45 -2
  40. package/src/index.ts +74 -1
  41. package/src/result.ts +11 -0
  42. 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
+ }