@12-apps/payments-frontend 3.4.0 → 3.6.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.
@@ -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. */
@@ -250,6 +250,17 @@ export interface CheckoutCustomerField {
250
250
  */
251
251
  export interface CheckoutChainLink {
252
252
  provider: string;
253
+ /**
254
+ * The provider's own name as a BUYER should read it ("InfinitePay"), which
255
+ * `GET /api/checkout/config` publishes per entry.
256
+ *
257
+ * Optional, and the DEGRADE DIRECTION IS "SAY NOTHING": an older host serves
258
+ * no name, and a hand-off screen then describes where the buyer is going
259
+ * without naming it, rather than printing the internal `provider` id — a
260
+ * buyer shown "infinitepay" learns less than one shown nothing, and learns
261
+ * it in a vocabulary that is ours rather than theirs.
262
+ */
263
+ displayName?: string | null;
253
264
  tokenization: "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT";
254
265
  publicKey: string | null;
255
266
  mockTokenization: boolean;
package/src/index.ts CHANGED
@@ -252,6 +252,39 @@ export {
252
252
  */
253
253
  export type { PaymentEnvironment } from '@12-apps/payments-backend';
254
254
 
255
+ // ---------------------------------------------------------------------------
256
+ // The REDIRECT ACTIVATION protocol (FUT-463, packaged by FUT-763) — proving a
257
+ // connection can charge, for a provider whose payer pays on its own page.
258
+ //
259
+ // `renderVerification` above stays what it was: the package decides where the
260
+ // step appears and the host owns the screen. What moved is the protocol behind
261
+ // it — resume-on-mount, the return trip's ids, the refusal/expiry/transport
262
+ // distinctions, the bounded wait. Every one of those was learned from a payment
263
+ // that went wrong, and no second host should have to learn them again.
264
+ // ---------------------------------------------------------------------------
265
+ export {
266
+ useRedirectActivation,
267
+ type RedirectActivation,
268
+ type RedirectActivationOptions,
269
+ } from './activation/use-redirect-activation';
270
+ export { type RedirectActivationCopy } from './activation/copy';
271
+ export {
272
+ creationFailure,
273
+ postActivation,
274
+ refusedByProvider,
275
+ settleActivationPoll,
276
+ type ActivationClock,
277
+ type ActivationPendingBody,
278
+ type ActivationPollBody,
279
+ type RedirectActivationState,
280
+ type SettlePollIo,
281
+ } from './activation/redirect-state';
282
+ export {
283
+ clearReturnedSettlement,
284
+ takeReturnedSettlement,
285
+ RETURNED_SETTLEMENT_KEY,
286
+ } from './activation/returned-settlement';
287
+
255
288
  // ---------------------------------------------------------------------------
256
289
  // The connect ROUND TRIP's other end (FUT-763): what the OAuth callback
257
290
  // redirected back with, taken out of the address bar once. The codes are a