@12-apps/payments-frontend 3.36.0 → 3.37.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.36.0",
3
+ "version": "3.37.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.",
@@ -199,6 +199,23 @@ export function useRefreshOnCartChanged(errorCode: string | null): void {
199
199
  }, [errorCode]);
200
200
  }
201
201
 
202
+ /** Where a refresh stands, for a step that may not have one (a settlement). */
203
+ function refreshStatus(refresh: OrderRefresh | undefined): { pending: boolean; error: string | null } {
204
+ return { pending: refresh?.pending ?? false, error: refresh?.error ?? null };
205
+ }
206
+
207
+ const PaymentStaleContext = createContext(false);
208
+
209
+ /**
210
+ * Whether the payment on screen was prepared for a cart that has since changed
211
+ * (FUT-3535). A PIX pane reads it to veil its QR and code and to stop the copy
212
+ * controls: the amount is IN the code, so a buyer who pays it now pays the old
213
+ * order. False outside a {@link CartChangedBoundary}.
214
+ */
215
+ export function usePaymentStale(): boolean {
216
+ return useContext(PaymentStaleContext);
217
+ }
218
+
202
219
  /**
203
220
  * The payment pane, with the step's warning above it when the cart moved, and
204
221
  * the refresh handed down to the card form. Nothing changes on screen while the
@@ -222,15 +239,19 @@ export function CartChangedBoundary({
222
239
  }): JSX.Element {
223
240
  const changed = useBasketChanged(order, basket) && !creating;
224
241
  const run = paymentRefresh(order, onGenerate, refresh?.run);
225
- const pending = refresh?.pending ?? false;
226
- const error = refresh?.error ?? null;
242
+ const { pending, error } = refreshStatus(refresh);
227
243
  // ONE tree shape whether or not the warning shows: wrapping the pane only
228
244
  // while it does would remount the card form the moment the refresh lands,
229
245
  // dropping what the buyer typed and the refusal that explains the new total.
230
246
  return (
231
247
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
232
248
  {(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
233
- <PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
249
+ {/* Stale while the warning stands, so the veil goes with it the moment the
250
+ new order lands. A PIX refresh mints a new code, which takes the old
251
+ one off the screen first, so a failed one leaves nothing to copy. */}
252
+ <PaymentStaleContext.Provider value={changed && run !== null}>
253
+ <PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
254
+ </PaymentStaleContext.Provider>
234
255
  </Box>
235
256
  );
236
257
  }
@@ -1,9 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
+ import { usePaymentStale } from "./cart-changed";
4
5
  import { useCheckoutCopy } from "./copy-context";
5
6
  import { CheckIcon, ContentCopyIcon, CreditCardIcon, QrCodeIcon, ScheduleIcon } from "./icons";
6
- import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
7
+ import { Emphasized, PIX_WIDE, PixQr, StaleVeil } from "./pix-parts";
7
8
  import type { PixAfterCopyCopy } from "./screens-copy";
8
9
  import type { PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
@@ -128,11 +129,14 @@ interface PixAfterCopyProps {
128
129
  function Actions({ justCopied, onCopyAgain, onShowQr, onPreferCard }: Pick<PixAfterCopyProps, "justCopied" | "onCopyAgain" | "onShowQr" | "onPreferCard">): JSX.Element {
129
130
  const { Button } = useCheckoutComponents();
130
131
  const copy = useCheckoutCopy().screens.pix;
132
+ // The code already copied is the OLD order's once the cart changed
133
+ // (FUT-3535): copying it again would only hand the buyer the wrong amount.
134
+ const stale = usePaymentStale();
131
135
  return (
132
136
  <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, "& button, & button *": { fontWeight: 700 }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
133
137
  {/* Narrow, a grid cell; wide, its own width beside the link (the prototype's row). */}
134
138
  <Box sx={{ minWidth: 0, [PIX_WIDE]: { flex: "none", "& > *": { width: "auto" } } }}>
135
- <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} dataTestId="pix-copy-again">
139
+ <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} disabled={stale} dataTestId="pix-copy-again">
136
140
  {justCopied ? copy.copiedAction : copy.copyAgainAction}
137
141
  </Button>
138
142
  </Box>
@@ -156,6 +160,7 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
156
160
  const { Button, Text } = useCheckoutComponents();
157
161
  const copy = useCheckoutCopy().screens.pix;
158
162
  const { pix, words, totalLabel, deadline, onPreferCard, footer } = props;
163
+ const stale = usePaymentStale();
159
164
  return (
160
165
  <Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
161
166
  <Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
@@ -175,7 +180,9 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
175
180
  <Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ margin: 0, textTransform: "uppercase", letterSpacing: "0.04em" }}>
176
181
  {copy.notPaidYet}
177
182
  </Text>
178
- <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
183
+ <StaleVeil stale={stale}>
184
+ <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
185
+ </StaleVeil>
179
186
  <Text variant="body" size="xs" color="secondary" as="p" style={{ margin: 0, textAlign: "center" }}>
180
187
  {copy.scanFromPhone}
181
188
  </Text>
@@ -1,10 +1,11 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
+ import { usePaymentStale } from "./cart-changed";
4
5
  import { useCheckoutCopy } from "./copy-context";
5
6
  import { ContentCopyIcon } from "./icons";
6
7
  import { StickyPayBar, useCheckoutLayout } from "./payment-step-layout";
7
- import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
8
+ import { Emphasized, PIX_WIDE, PixQr, StaleVeil } from "./pix-parts";
8
9
  import type { PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
10
11
 
@@ -64,6 +65,7 @@ function ColumnHeading({
64
65
  function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
65
66
  const { Text } = useCheckoutComponents();
66
67
  const copy = useCheckoutCopy().screens.pix;
68
+ const stale = usePaymentStale();
67
69
  return (
68
70
  <Box
69
71
  data-testid="pix-qr-panel"
@@ -100,12 +102,14 @@ function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
100
102
  [PIX_WIDE]: { "& svg": { width: 232, height: 232 } },
101
103
  }}
102
104
  >
103
- <PixQr
104
- payload={pix.copyPaste}
105
- size={232}
106
- alt={copy.qrAlt}
107
- testId="pix-qr"
108
- />
105
+ <StaleVeil stale={stale}>
106
+ <PixQr
107
+ payload={pix.copyPaste}
108
+ size={232}
109
+ alt={copy.qrAlt}
110
+ testId="pix-qr"
111
+ />
112
+ </StaleVeil>
109
113
  </Box>
110
114
  <Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0, maxWidth: 280 }}>
111
115
  <Box component="span" sx={NARROW_ONLY}>{copy.qrTabCaption}</Box>
@@ -125,6 +129,9 @@ function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
125
129
  function CopyButton({ onCopy }: { onCopy: () => void }): JSX.Element {
126
130
  const { Button } = useCheckoutComponents();
127
131
  const copy = useCheckoutCopy().screens.pix;
132
+ // A changed cart makes this code the OLD order's (FUT-3535): nothing to copy
133
+ // until the step's "Atualizar pagamento" brings a new one.
134
+ const stale = usePaymentStale();
128
135
  return (
129
136
  <StickyPayBar>
130
137
  <Box
@@ -141,6 +148,7 @@ function CopyButton({ onCopy }: { onCopy: () => void }): JSX.Element {
141
148
  fullWidth
142
149
  icon={<ContentCopyIcon fontSize="small" />}
143
150
  onClick={onCopy}
151
+ disabled={stale}
144
152
  dataTestId="pix-copy"
145
153
  >
146
154
  {copy.copyAction}
@@ -162,6 +170,7 @@ function CopyPanel({
162
170
  }): JSX.Element {
163
171
  const { Text } = useCheckoutComponents();
164
172
  const copy = useCheckoutCopy().screens.pix;
173
+ const stale = usePaymentStale();
165
174
  return (
166
175
  <Box
167
176
  data-testid="pix-copy-panel"
@@ -179,21 +188,23 @@ function CopyPanel({
179
188
  (a tinted chip, its own border and padding) is undone inside it:
180
189
  otherwise a wrapped code reads as a box inside a box.
181
190
  */}
182
- <Box
183
- sx={{
184
- px: 2,
185
- py: 1.5,
186
- border: "1px solid",
187
- borderColor: "divider",
188
- borderRadius: 1.5,
189
- bgcolor: "action.hover",
190
- "&& > *": { bgcolor: "transparent", border: 0, borderRadius: 0, p: 0 },
191
- }}
192
- >
193
- <Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
194
- {pix.copyPaste}
195
- </Text>
196
- </Box>
191
+ <StaleVeil stale={stale}>
192
+ <Box
193
+ sx={{
194
+ px: 2,
195
+ py: 1.5,
196
+ border: "1px solid",
197
+ borderColor: "divider",
198
+ borderRadius: 1.5,
199
+ bgcolor: "action.hover",
200
+ "&& > *": { bgcolor: "transparent", border: 0, borderRadius: 0, p: 0 },
201
+ }}
202
+ >
203
+ <Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
204
+ {pix.copyPaste}
205
+ </Text>
206
+ </Box>
207
+ </StaleVeil>
197
208
  {inPanel ? <CopyButton onCopy={onCopy} /> : null}
198
209
  <Text
199
210
  variant="body"
@@ -1,7 +1,9 @@
1
1
  import { Box } from "@mui/material";
2
- import { Fragment, useCallback, useEffect, useRef, useState, type JSX } from "react";
2
+ import { Fragment, useCallback, useEffect, useRef, useState, type JSX, type ReactNode } from "react";
3
3
  import QRCode from "react-qr-code";
4
4
 
5
+ import { useCheckoutCopy } from "./copy-context";
6
+
5
7
  /**
6
8
  * The pieces the PIX pane and its after-copy face share (the 2026-10-06 Pix
7
9
  * redesign): the container-query switch, the QR, bold spans in a sentence, and
@@ -79,3 +81,47 @@ export function useClipboardCopy(text: string): { copy: () => Promise<boolean>;
79
81
  }, [text]);
80
82
  return { copy, justCopied };
81
83
  }
84
+
85
+ /**
86
+ * A PIX's QR or code, veiled while the cart has changed under it (FUT-3535):
87
+ * blurred past reading, out of reach of a pointer, a selection and a screen
88
+ * reader, with "Atualize para gerar um novo Pix" over it. The step's warning
89
+ * above says why and carries the control that brings a new code. One tree
90
+ * shape either way, so lifting the veil redraws nothing.
91
+ */
92
+ export function StaleVeil({ stale, children }: { stale: boolean; children: ReactNode }): JSX.Element {
93
+ const words = useCheckoutCopy().screens.cartChanged.pixVeil;
94
+ return (
95
+ <Box sx={{ position: "relative", minWidth: 0 }} data-testid={stale ? "pix-stale-veil" : undefined}>
96
+ <Box
97
+ aria-hidden={stale || undefined}
98
+ sx={stale ? { filter: "blur(6px)", opacity: 0.5, pointerEvents: "none", userSelect: "none" } : undefined}
99
+ >
100
+ {children}
101
+ </Box>
102
+ {stale ? (
103
+ <Box
104
+ sx={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", p: 1.5 }}
105
+ >
106
+ <Box
107
+ component="p"
108
+ sx={{
109
+ m: 0,
110
+ px: 1.5,
111
+ py: 0.75,
112
+ borderRadius: 999,
113
+ bgcolor: "background.paper",
114
+ boxShadow: 1,
115
+ color: "text.primary",
116
+ fontSize: "0.8125rem",
117
+ fontWeight: 600,
118
+ textAlign: "center",
119
+ }}
120
+ >
121
+ {words}
122
+ </Box>
123
+ </Box>
124
+ ) : null}
125
+ </Box>
126
+ );
127
+ }
@@ -284,6 +284,12 @@ export interface CartChangedCopy {
284
284
  refreshAction: string;
285
285
  /** The heading over a refresh that failed; the host's own sentence is the body. */
286
286
  refreshFailed: string;
287
+ /**
288
+ * Drawn over a PIX's QR and code while they are veiled (FUT-3535): the code
289
+ * on screen asks for the old amount, so it cannot be copied or scanned until
290
+ * the payment is updated.
291
+ */
292
+ pixVeil: string;
287
293
  }
288
294
 
289
295
  /** What the browser says when the wire itself failed. */
@@ -159,6 +159,7 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
159
159
  support: 'This payment was prepared for your previous cart. Update it to pay the right amount.',
160
160
  refreshAction: 'Update payment',
161
161
  refreshFailed: 'Could not update the payment',
162
+ pixVeil: 'Update to get a new Pix code',
162
163
  },
163
164
  validation: {
164
165
  taxIdInvalid: 'That CPF is not valid.',
@@ -145,6 +145,7 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
145
145
  support: 'O pagamento foi preparado para o carrinho anterior. Atualize para pagar o valor certo.',
146
146
  refreshAction: 'Atualizar pagamento',
147
147
  refreshFailed: 'Não foi possível atualizar o pagamento',
148
+ pixVeil: 'Atualize para gerar um novo Pix',
148
149
  },
149
150
  validation: {
150
151
  taxIdInvalid: 'CPF inválido.',