@12-apps/payments-frontend 3.5.0 → 3.7.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.
@@ -4,6 +4,7 @@ import type { JSX, ReactNode } from "react";
4
4
  import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
5
5
  import type { OrderStatus } from "./types";
6
6
  import { useCheckoutComponents } from "./ui";
7
+ import type { PaymentStatusCopy } from "./view-copy";
7
8
 
8
9
  /**
9
10
  * The last screen of checkout.
@@ -28,67 +29,38 @@ import { useCheckoutComponents } from "./ui";
28
29
  * screen says and how it is arranged has changed.
29
30
  */
30
31
 
31
- /** Everything the hero block needs, per outcome. */
32
- interface Outcome {
33
- heading: string;
34
- support: string;
32
+ /**
33
+ * The per-outcome VISUAL grammar — icon and semantic tone. The heading and
34
+ * supporting line beside them come from {@link PaymentStatusCopy}: an icon is
35
+ * the component's own vocabulary, a sentence never is. (The FAILED support
36
+ * line's job — say "nothing was charged" plainly and first — and the
37
+ * timed-out wait's "do not pay again" now live with the host's words, where
38
+ * FUT-556's reasoning is documented on the copy port.)
39
+ */
40
+ interface OutcomeVisual {
35
41
  icon: JSX.Element;
36
42
  /** Semantic theme token — never a raw colour. */
37
43
  tone: "success" | "danger" | "warning" | "neutral";
38
44
  }
39
45
 
40
- const OUTCOME: Record<OrderStatus, Outcome> = {
41
- PAID: {
42
- heading: "Pedido confirmado",
43
- support: "Recebemos seu pagamento e 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
- },
46
+ const OUTCOME_VISUAL: Record<OrderStatus, OutcomeVisual> = {
47
+ PAID: { icon: <CheckCircleOutlineIcon fontSize="large" />, tone: "success" },
48
+ AWAITING_PAYMENT: { icon: <ScheduleIcon fontSize="large" />, tone: "neutral" },
49
+ FAILED: { icon: <ErrorOutlineIcon fontSize="large" />, tone: "danger" },
50
+ EXPIRED: { icon: <ScheduleIcon fontSize="large" />, tone: "warning" },
67
51
  };
68
52
 
69
- /**
70
- * What the screen says once it has stopped asking (FUT-556).
71
- *
72
- * NOT an `OrderStatus`: the order really is still AWAITING_PAYMENT and may yet
73
- * settle — the scheduled reconciliation keeps asking the provider long after
74
- * this tab is gone. What ran out is the WAIT, not the order, and saying
75
- * otherwise would be the more expensive lie: a buyer told "não concluído" who
76
- * has in fact paid will either pay twice or call the store.
77
- *
78
- * So it leads with what is true and unglamorous — we have not heard yet — and
79
- * spends its remaining words on the one instruction that matters, which is not
80
- * to pay again.
81
- */
82
- const AWAITING_TIMED_OUT: Outcome = {
83
- heading: "Ainda não recebemos a confirmação",
84
- support:
85
- "Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
86
- "não pague de novo. Você pode fechar esta tela.",
87
- icon: <ScheduleIcon fontSize="large" />,
88
- tone: "warning",
53
+ const OUTCOME_COPY_KEY: Record<OrderStatus, keyof Pick<
54
+ PaymentStatusCopy,
55
+ "paid" | "awaiting" | "failed" | "expired"
56
+ >> = {
57
+ PAID: "paid",
58
+ AWAITING_PAYMENT: "awaiting",
59
+ FAILED: "failed",
60
+ EXPIRED: "expired",
89
61
  };
90
62
 
91
- const TONE_COLOR: Record<Outcome["tone"], string> = {
63
+ const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
92
64
  success: "success.main",
93
65
  danger: "error.main",
94
66
  warning: "warning.main",
@@ -109,14 +81,22 @@ function orderReference(orderId: string): string {
109
81
 
110
82
  /** The headline block: icon, outcome, and one supporting line. */
111
83
  function OutcomeHero({
84
+ copy,
112
85
  status,
113
86
  timedOut = false,
114
87
  }: {
88
+ copy: PaymentStatusCopy;
115
89
  status: OrderStatus;
116
90
  timedOut?: boolean;
117
91
  }): JSX.Element {
118
92
  const { Text } = useCheckoutComponents();
119
- const outcome = timedOut && status === "AWAITING_PAYMENT" ? AWAITING_TIMED_OUT : OUTCOME[status];
93
+ const timedOutWait = timedOut && status === "AWAITING_PAYMENT";
94
+ // The timed-out wait keeps AWAITING's neutral clock icon but WARNING's tone:
95
+ // the order is not resolved, and calm-but-alert is the visual for that.
96
+ const visual = timedOutWait
97
+ ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
98
+ : OUTCOME_VISUAL[status];
99
+ const outcome = timedOutWait ? copy.awaitingTimedOut : copy[OUTCOME_COPY_KEY[status]];
120
100
  return (
121
101
  <Box
122
102
  // `payment-paid` is load-bearing for the storefront journeys — it is how
@@ -133,7 +113,7 @@ function OutcomeHero({
133
113
  }
134
114
  sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
135
115
  >
136
- <Box sx={{ color: TONE_COLOR[outcome.tone], display: "flex" }}>{outcome.icon}</Box>
116
+ <Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
137
117
  <Text variant="heading" size="md" weight="bold" as="h2">
138
118
  {outcome.heading}
139
119
  </Text>
@@ -165,10 +145,12 @@ function Fact({ label, value, testId }: { label: string; value: string; testId?:
165
145
  * outcome these facts are either untrue or not yet knowable.
166
146
  */
167
147
  function PaidFacts({
148
+ copy,
168
149
  totalLabel,
169
150
  orderId,
170
151
  buyerEmail,
171
152
  }: {
153
+ copy: PaymentStatusCopy;
172
154
  totalLabel: string;
173
155
  orderId?: string;
174
156
  buyerEmail?: string;
@@ -185,22 +167,24 @@ function PaidFacts({
185
167
  bgcolor: "action.hover",
186
168
  }}
187
169
  >
188
- <Fact label="Valor pago" value={totalLabel} testId="payment-amount" />
170
+ <Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
189
171
  {orderId ? (
190
- <Fact label="Pedido" value={`#${orderReference(orderId)}`} testId="payment-reference" />
172
+ <Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
191
173
  ) : null}
192
- {buyerEmail ? <Fact label="Comprovante enviado para" value={buyerEmail} /> : null}
174
+ {buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
193
175
  </Box>
194
176
  );
195
177
  }
196
178
 
197
179
  /** The next-action row: retry / regenerate for failures, always back-to-menu. */
198
180
  function StatusActions({
181
+ copy,
199
182
  status,
200
183
  onRetry,
201
184
  onRegenerate,
202
185
  onBackToMenu,
203
186
  }: {
187
+ copy: PaymentStatusCopy;
204
188
  status: OrderStatus;
205
189
  onRetry?: () => void;
206
190
  onRegenerate?: () => void;
@@ -211,12 +195,12 @@ function StatusActions({
211
195
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
212
196
  {status === "FAILED" && onRetry ? (
213
197
  <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
214
- Tentar novamente
198
+ {copy.retryAction}
215
199
  </Button>
216
200
  ) : null}
217
201
  {status === "EXPIRED" && onRegenerate ? (
218
202
  <Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
219
- Gerar novo código
203
+ {copy.regenerateAction}
220
204
  </Button>
221
205
  ) : null}
222
206
  <Button
@@ -228,13 +212,14 @@ function StatusActions({
228
212
  onClick={onBackToMenu}
229
213
  dataTestId="payment-back-to-menu"
230
214
  >
231
- Voltar ao cardápio
215
+ {copy.backAction}
232
216
  </Button>
233
217
  </Box>
234
218
  );
235
219
  }
236
220
 
237
221
  export function PaymentStatus({
222
+ copy,
238
223
  status,
239
224
  totalLabel,
240
225
  orderId,
@@ -245,6 +230,8 @@ export function PaymentStatus({
245
230
  paidExtra,
246
231
  awaitingTimedOut = false,
247
232
  }: {
233
+ /** Every sentence and label this screen renders — the HOST's words. */
234
+ copy: PaymentStatusCopy;
248
235
  status: OrderStatus | null;
249
236
  totalLabel: string;
250
237
  /** The created order, when there is one — absent before the charge is raised. */
@@ -278,10 +265,10 @@ export function PaymentStatus({
278
265
  data-timed-out={awaitingTimedOut ? "true" : undefined}
279
266
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
280
267
  >
281
- <OutcomeHero status={effective} timedOut={awaitingTimedOut} />
268
+ <OutcomeHero copy={copy} status={effective} timedOut={awaitingTimedOut} />
282
269
 
283
270
  {effective === "PAID" ? (
284
- <PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
271
+ <PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
285
272
  ) : null}
286
273
 
287
274
  {effective === "PAID" ? paidExtra : null}
@@ -291,6 +278,7 @@ export function PaymentStatus({
291
278
  ) : null}
292
279
 
293
280
  <StatusActions
281
+ copy={copy}
294
282
  status={effective}
295
283
  onRetry={onRetry}
296
284
  onRegenerate={onRegenerate}
@@ -23,8 +23,16 @@ import { Box } from "@mui/material";
23
23
  import type { JSX } from "react";
24
24
 
25
25
  import { useCheckoutComponents } from "./ui";
26
+ import type { PaymentsUnavailableCopy } from "./view-copy";
26
27
 
27
28
  interface PaymentsUnavailableProps {
29
+ /**
30
+ * Every sentence this screen can say — the HOST's words (see
31
+ * `./view-copy`). Until 6.0.0 the two remedies and the button were compiled
32
+ * in, in one application's Portuguese, and a host that dutifully passed the
33
+ * flows copy port still got them.
34
+ */
35
+ copy: PaymentsUnavailableCopy;
28
36
  /**
29
37
  * Whether THIS shopper can settle with a waiter — mesas on AND a mesa of
30
38
  * their own. Decides which remedy is offered.
@@ -37,6 +45,7 @@ interface PaymentsUnavailableProps {
37
45
  }
38
46
 
39
47
  export function PaymentsUnavailable({
48
+ copy,
40
49
  waiterAvailable,
41
50
  onCallWaiter,
42
51
  calling = false,
@@ -47,8 +56,8 @@ export function PaymentsUnavailable({
47
56
  <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-payments-disabled">
48
57
  <Alert
49
58
  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."
59
+ title={copy.title}
60
+ description={copy.body}
52
61
  showIcon
53
62
  />
54
63
  </Box>
@@ -59,8 +68,8 @@ export function PaymentsUnavailable({
59
68
  <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-call-waiter">
60
69
  <Alert
61
70
  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."
71
+ title={copy.remedyTitle}
72
+ description={copy.remedyBody}
64
73
  showIcon
65
74
  />
66
75
  {onCallWaiter ? (
@@ -71,7 +80,7 @@ export function PaymentsUnavailable({
71
80
  onClick={onCallWaiter}
72
81
  dataTestId="checkout-call-waiter-button"
73
82
  >
74
- {calling ? "Chamando..." : "Chamar garçom"}
83
+ {calling ? copy.callingAction : copy.callAction}
75
84
  </Button>
76
85
  ) : null}
77
86
  </Box>
@@ -19,45 +19,11 @@
19
19
  */
20
20
  import type { JSX } from "react";
21
21
 
22
- import type { CheckoutProviderConfig } from "../types";
23
-
22
+ import { handsBuyerOver } from "./hands-over";
24
23
  import { HostedLinkScreen } from "./hosted-link";
25
24
  import { PixAndCardScreen } from "./pix-and-card";
26
25
  import type { ProviderCheckoutScreenProps } from "./types";
27
26
 
28
- /** Schemes that give the BROWSER a card form of its own. */
29
- const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
30
-
31
- /**
32
- * Whether this store hands the buyer over instead of collecting here — the
33
- * frontend twin of the server's `usesHostedCheckout`, deliberately written to
34
- * the same three rules so the pane and the walk cannot disagree.
35
- *
36
- * Note this is NOT `!cardPathAvailable(config)`. That helper answers a
37
- * different question — "is a card offerable at all" — and it answers TRUE for
38
- * a hand-off store, because typing the card on the provider's page is still a
39
- * card path. Inverting it therefore sends the hosted store to the on-page
40
- * screen and the on-page store to the hand-off, which is exactly backwards.
41
- *
42
- * The rules, in order:
43
- * - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
44
- * says how the browser turns a PAN into an instrument, and a PIX charge
45
- * has no instrument to mint. A store with no card-capable entry is not
46
- * hosted — this is the FUT-747 correction, and getting it wrong routed the
47
- * simplest store there is (one PIX-only provider honestly declaring
48
- * `NONE`) into a hand-off it had no link for.
49
- * - Hosted only when NOBODY who takes a card takes it here.
50
- * - No chain served (an older host, a still-loading config) ⇒ not hosted,
51
- * which is what this checkout did before there was a chain to read.
52
- */
53
- function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
54
- const chain = config?.chain;
55
- if (!chain || chain.length === 0) return false;
56
- const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
57
- if (cardCapable.length === 0) return false;
58
- return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
59
- }
60
-
61
27
  export function CapabilityDefaultScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
62
28
  return handsBuyerOver(props.config) ? (
63
29
  <HostedLinkScreen {...props} />
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Whether a store finishes checkout on the PROVIDER's own page.
3
+ *
4
+ * Its own module because two callers need the same answer and must never be
5
+ * able to disagree about it: {@link CapabilityDefaultScreen}, which picks the
6
+ * pane for a provider that declared no screen, and the shell's picker gate,
7
+ * which decides whether the buyer is asked PIX-or-card here at all. It lived
8
+ * inside `capability-default.tsx` while there was one caller.
9
+ */
10
+ import type { CheckoutProviderConfig } from "../types";
11
+
12
+ /** Schemes that give the BROWSER a card form of its own. */
13
+ const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
14
+
15
+ /**
16
+ * Whether this store hands the buyer over instead of collecting here — the
17
+ * frontend twin of the server's `usesHostedCheckout`, deliberately written to
18
+ * the same three rules so the pane and the walk cannot disagree.
19
+ *
20
+ * Note this is NOT `!cardPathAvailable(config)`. That helper answers a
21
+ * different question — "is a card offerable at all" — and it answers TRUE for
22
+ * a hand-off store, because typing the card on the provider's page is still a
23
+ * card path. Inverting it therefore sends the hosted store to the on-page
24
+ * screen and the on-page store to the hand-off, which is exactly backwards.
25
+ *
26
+ * The rules, in order:
27
+ * - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
28
+ * says how the browser turns a PAN into an instrument, and a PIX charge
29
+ * has no instrument to mint. A store with no card-capable entry is not
30
+ * hosted — this is the FUT-747 correction, and getting it wrong routed the
31
+ * simplest store there is (one PIX-only provider honestly declaring
32
+ * `NONE`) into a hand-off it had no link for.
33
+ * - Hosted only when NOBODY who takes a card takes it here.
34
+ * - No chain served (an older host, a still-loading config) ⇒ not hosted,
35
+ * which is what this checkout did before there was a chain to read.
36
+ */
37
+ export function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
38
+ const chain = config?.chain;
39
+ if (!chain || chain.length === 0) return false;
40
+ const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
41
+ if (cardCapable.length === 0) return false;
42
+ return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
43
+ }
@@ -15,26 +15,112 @@
15
15
  * is leaving this page and parking the payable for the return leg matters more
16
16
  * than painting a pane they will not see. `hosted-return.ts` picks it back up.
17
17
  *
18
- * So the whole job here is the moment BEFORE that: the buyer has chosen a
19
- * method and the redirect is being prepared. Previously the pane rendered
20
- * `null` through that moment, which reads as a checkout that has stalled — the
21
- * screen tells them where they are going instead. Nothing here touches the
22
- * money path; the navigation and the parking are unchanged.
18
+ * So the whole job here is the moment BEFORE that: telling the buyer where
19
+ * they are about to go, and giving them the one action that takes them.
20
+ *
21
+ * ## Why the ACTION lives here and not in the shell's picker
22
+ *
23
+ * The shell hides its PIX/card picker for this screen (`methodChosenAtProvider`
24
+ * in `./registry.ts`), so the screen owns the affordance that starts the
25
+ * charge. That is the point of the whole arrangement: the method question is
26
+ * binding on the provider's page, not on ours — every answer mints the same
27
+ * link — so asking it twice tells the buyer their first answer was thrown
28
+ * away. One button that says where it leads is the honest version of that
29
+ * screen, and it also removes the moment where the pane rendered `null` under
30
+ * a picker, which reads as a checkout that has stalled.
23
31
  */
24
32
  import { Box } from "@mui/material";
25
33
  import type { JSX } from "react";
26
34
 
35
+ import { offeredMethods } from "../method-capability";
36
+ import type { CheckoutProviderConfig } from "../types";
27
37
  import { useCheckoutComponents } from "../ui";
28
38
 
29
39
  import type { ProviderCheckoutScreenProps } from "./types";
30
40
 
31
- export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.Element | null {
32
- const { Text, LoadingState } = useCheckoutComponents();
41
+ /**
42
+ * How the store's own page is named to the buyer — "à página segura da
43
+ * InfinitePay", or the provider-neutral phrasing when no name was published.
44
+ *
45
+ * The neutral form is not a lesser fallback to be tidied away later: a host one
46
+ * release behind serves no `displayName`, and the buyer of that store gets a
47
+ * true sentence rather than our internal id ("infinitepay") dressed up as a
48
+ * brand.
49
+ */
50
+ function destinationLabel(config: CheckoutProviderConfig | null): string {
51
+ const name = config?.chain?.[0]?.displayName?.trim();
52
+ return name ? `à página de pagamento da ${name}` : "à página de pagamento segura do provedor";
53
+ }
33
54
 
34
- // Nothing chosen yet ⇒ the shell is still showing the picker. Same as every
35
- // other screen: the pane stays out of the way until there is something to say.
36
- if (!method) return null;
55
+ /**
56
+ * What the buyer will be asked to choose between once they get there, so the
57
+ * sentence promises exactly what the provider's page offers.
58
+ *
59
+ * Read from the same declaration the picker used to render, which is what
60
+ * keeps this honest for a provider that takes only one of the two: a hosted
61
+ * PIX-only store must not promise a card.
62
+ */
63
+ function methodsPhrase(config: CheckoutProviderConfig | null): string | null {
64
+ const offered = offeredMethods(config);
65
+ const pix = offered === null || offered.includes("PIX");
66
+ const card = offered === null || offered.includes("CARD");
67
+ if (pix && card) return "PIX ou cartão";
68
+ if (pix) return "PIX";
69
+ if (card) return "cartão";
70
+ return null;
71
+ }
72
+
73
+ /** The full "where you are going and what happens there" sentence. */
74
+ function handoffMessage(config: CheckoutProviderConfig | null): string {
75
+ const methods = methodsPhrase(config);
76
+ const choice = methods ? `, onde você escolhe pagar com ${methods}` : "";
77
+ return `Você será levado ${destinationLabel(config)}${choice}.`;
78
+ }
79
+
80
+ /** The invitation: what happens next, and the one button that starts it. */
81
+ function HandOffInvite({
82
+ config,
83
+ onStart,
84
+ }: {
85
+ config: CheckoutProviderConfig | null;
86
+ onStart: () => void;
87
+ }): JSX.Element {
88
+ const { Button, Text } = useCheckoutComponents();
89
+ return (
90
+ <Box
91
+ data-testid="checkout-handoff-invite"
92
+ sx={{ display: "flex", flexDirection: "column", gap: 2 }}
93
+ >
94
+ <Text variant="body" size="md" as="p">
95
+ {handoffMessage(config)}
96
+ </Text>
97
+ <Text variant="caption" size="sm" as="p" color="secondary">
98
+ Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
99
+ </Text>
100
+ <Button
101
+ variant="solid"
102
+ color="primary"
103
+ size="lg"
104
+ fullWidth
105
+ onClick={onStart}
106
+ dataTestId="checkout-handoff-start"
107
+ >
108
+ Seguir para o pagamento
109
+ </Button>
110
+ </Box>
111
+ );
112
+ }
37
113
 
114
+ /**
115
+ * The moment after the button: the charge is being raised, then we navigate.
116
+ *
117
+ * Says the SAME sentence the invite did, from the same helper. It used to word
118
+ * the destination differently — "a página segura do provedor" against the
119
+ * invite's named one — so the screen appeared to change its mind about where
120
+ * the buyer was going at the exact moment they committed to going there.
121
+ */
122
+ function HandOffPending({ config }: { config: CheckoutProviderConfig | null }): JSX.Element {
123
+ const { Text, LoadingState } = useCheckoutComponents();
38
124
  return (
39
125
  <Box
40
126
  data-testid="checkout-handoff-pending"
@@ -42,11 +128,26 @@ export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.E
42
128
  >
43
129
  <LoadingState variant="spinner" message="Preparando o pagamento" size="md" />
44
130
  <Text variant="body" size="md" as="p">
45
- Você será levado à página segura do provedor para concluir o pagamento.
131
+ {handoffMessage(config)}
46
132
  </Text>
47
133
  <Text variant="caption" size="sm" as="p" color="secondary">
48
- Assim que terminar, você volta para cá e nós confirmamos o pedido.
134
+ Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
49
135
  </Text>
50
136
  </Box>
51
137
  );
52
138
  }
139
+
140
+ export function HostedLinkScreen({
141
+ method,
142
+ config,
143
+ onStart,
144
+ }: ProviderCheckoutScreenProps): JSX.Element | null {
145
+ // A method is only ever set here once the buyer has committed — either by
146
+ // pressing the CTA below, or (on a host whose shell still renders a picker)
147
+ // by choosing a tile. Both mean the same thing: the hand-off is underway.
148
+ if (method) return <HandOffPending config={config} />;
149
+ // No method and no CTA to offer ⇒ the shell is still showing its picker, and
150
+ // the pane stays out of the way exactly as every other screen does.
151
+ if (!onStart) return null;
152
+ return <HandOffInvite config={config} onStart={onStart} />;
153
+ }
@@ -26,8 +26,10 @@
26
26
  * to an empty pane.
27
27
  */
28
28
  import { CapabilityDefaultScreen } from "./capability-default";
29
+ import { handsBuyerOver } from "./hands-over";
29
30
  import { HostedLinkScreen } from "./hosted-link";
30
31
  import { PixAndCardScreen } from "./pix-and-card";
32
+ import type { CheckoutProviderConfig } from "../types";
31
33
  import type { ProviderCheckoutScreen } from "./types";
32
34
 
33
35
  /**
@@ -76,3 +78,31 @@ export function resolveCheckoutScreen(
76
78
  ): ProviderCheckoutScreen {
77
79
  return screenFor(chainHeadScreen) ?? CapabilityDefaultScreen;
78
80
  }
81
+
82
+ /**
83
+ * Does the buyer choose PIX-or-card on the PROVIDER's page rather than on ours?
84
+ *
85
+ * The shell asks this before rendering its method picker, and hides the picker
86
+ * when the answer is yes (FUT-596 follow-up). A hand-off store asks the
87
+ * question twice otherwise: once here, where the answer changes nothing —
88
+ * every method mints the same checkout link — and again on the provider's own
89
+ * page, where it is finally binding. The first ask is not merely redundant, it
90
+ * is misleading: the buyer who picked PIX here has picked nothing, and finding
91
+ * the same two options waiting for them on another site reads as a checkout
92
+ * that lost their answer.
93
+ *
94
+ * Resolved in the SAME order as {@link resolveCheckoutScreen} — a declared id
95
+ * wins, and an undeclared or unknown one falls back to the capability read —
96
+ * so the picker and the pane can never disagree about which flow this is. That
97
+ * is why the test is an identity check against the hand-off screen rather than
98
+ * a second table of ids: a table would be the copy that drifts, and it would
99
+ * drift the moment a new hand-off adapter declared its own id.
100
+ */
101
+ export function methodChosenAtProvider(
102
+ chainHeadScreen: string | null | undefined,
103
+ config: CheckoutProviderConfig | null,
104
+ ): boolean {
105
+ const declared = screenFor(chainHeadScreen);
106
+ if (declared) return declared === HostedLinkScreen;
107
+ return handsBuyerOver(config);
108
+ }
@@ -46,8 +46,26 @@ export interface ProviderCheckoutScreenProps {
46
46
  * those readings call the helpers themselves.
47
47
  */
48
48
  config: CheckoutProviderConfig | null;
49
- /** What the shell's picker currently has selected; `null` before a choice. */
49
+ /**
50
+ * What the shell currently has selected; `null` before a choice.
51
+ *
52
+ * `null` is also the resting state of a screen that OWNS the choice (see
53
+ * {@link ProviderCheckoutScreenProps.onStart}): nothing is selected until
54
+ * the buyer presses that screen's own CTA.
55
+ */
50
56
  method: PaymentMethod | null;
57
+ /**
58
+ * Present ⇒ this screen owns the "how do I start paying" affordance, because
59
+ * the shell has hidden its method picker for it (FUT-596 follow-up). Calling
60
+ * it commits the buyer to the store's hand-off method and raises the charge,
61
+ * exactly as choosing a tile in the picker does.
62
+ *
63
+ * Absent for every screen where the picker is still on the page — those
64
+ * screens must not grow a second way to start the same charge.
65
+ */
66
+ onStart?: () => void;
67
+ /** A charge is being raised right now — the shell's own busy flag. */
68
+ creating?: boolean;
51
69
  /** Scopes the saved-card list to the store being paid. */
52
70
  tenantSlug?: string;
53
71
  /** The shell's polling cadence, passed through so tests can shorten it. */