@12-apps/payments-frontend 3.37.0 → 3.39.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/card/types.ts +6 -0
  3. package/src/components/checkout/card-sources.ts +140 -0
  4. package/src/components/checkout/card-view.tsx +6 -2
  5. package/src/components/checkout/cart-changed.tsx +26 -15
  6. package/src/components/checkout/checkout-actions.ts +18 -0
  7. package/src/components/checkout/checkout-flow.tsx +24 -77
  8. package/src/components/checkout/google-pay-button.tsx +4 -4
  9. package/src/components/checkout/list/buyer-inline.tsx +125 -0
  10. package/src/components/checkout/list/charge-runners.tsx +175 -0
  11. package/src/components/checkout/list/choice-list.tsx +159 -0
  12. package/src/components/checkout/list/choice.ts +213 -0
  13. package/src/components/checkout/list/context.tsx +55 -0
  14. package/src/components/checkout/list/en-US.ts +63 -0
  15. package/src/components/checkout/list/list-bar.tsx +186 -0
  16. package/src/components/checkout/list/list-face.tsx +338 -0
  17. package/src/components/checkout/list/list-pix.tsx +206 -0
  18. package/src/components/checkout/list/online-content.tsx +144 -0
  19. package/src/components/checkout/list/option-row.tsx +125 -0
  20. package/src/components/checkout/list/payment-list-step.tsx +166 -0
  21. package/src/components/checkout/list/pt-BR.ts +79 -0
  22. package/src/components/checkout/list/types.ts +218 -0
  23. package/src/components/checkout/list/use-capability.ts +58 -0
  24. package/src/components/checkout/list/use-list-charge.ts +186 -0
  25. package/src/components/checkout/list/use-list-state.ts +152 -0
  26. package/src/components/checkout/payment-phase.tsx +119 -0
  27. package/src/components/checkout/pix-parts.tsx +3 -0
  28. package/src/components/checkout/pix-view.tsx +53 -20
  29. package/src/components/checkout/transport.ts +3 -1
  30. package/src/components/checkout/types.ts +9 -9
  31. package/src/components/checkout/use-card-checkout.ts +11 -78
  32. package/src/components/checkout/use-checkout-controller.ts +3 -1
  33. package/src/flows/create-payment-flows.tsx +7 -0
  34. package/src/flows/types.ts +7 -0
  35. package/src/index.ts +4 -5
  36. package/src/locales.ts +13 -0
@@ -0,0 +1,79 @@
1
+ import type { PaymentListCopy } from "./types";
2
+
3
+ /**
4
+ * The article a provider's name takes in a pt-BR sentence — "o PagBank", "a
5
+ * InfinitePay". The names are the chain's own `displayName`s; one this table
6
+ * has never seen reads as a company ("a empresa"), which is the safer default.
7
+ */
8
+ const MASCULINE = new Set(["PagBank", "PagSeguro", "Stripe", "Itaú", "Mercado Pago"]);
9
+
10
+ function the(provider: string): string {
11
+ return `${MASCULINE.has(provider) ? "o" : "a"} ${provider}`;
12
+ }
13
+
14
+ /** "da InfinitePay" / "do PagBank". */
15
+ function of(provider: string): string {
16
+ return `${MASCULINE.has(provider) ? "do" : "da"} ${provider}`;
17
+ }
18
+
19
+ /** The one-list Pagamento step's words, pt-BR (FUT-3591, FUT-3592). */
20
+ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
21
+ title: "Como você quer pagar?",
22
+ onlineHeading: "Pagar agora, pelo app",
23
+ doorHeading: "Pagar na entrega",
24
+ pix: { label: "Pix", hint: "Gera o código para copiar e colar" },
25
+ card: {
26
+ label: "Cartão de crédito",
27
+ hintSaved: (card) => `${card} ou outro cartão`,
28
+ hintNew: "Digite os dados do cartão",
29
+ },
30
+ googlePay: { label: "Google Pay", hint: "Com o cartão salvo no seu Google" },
31
+ handOff: {
32
+ label: "Pix ou cartão",
33
+ hint: (provider) =>
34
+ provider ? `Você paga na página ${of(provider)} e volta` : "Você paga na página de pagamento e volta",
35
+ },
36
+ savedCard: (brand, last4) => `${brand ?? "Cartão"} final ${last4}`,
37
+ otherCard: "Outro cartão",
38
+ buyer: {
39
+ cpf: "CPF",
40
+ phone: "Celular",
41
+ name: "Nome",
42
+ email: "E-mail",
43
+ why: (provider) => (provider ? `(${the(provider)} pede para pagar pelo app)` : "(pedido para pagar pelo app)"),
44
+ cpfIncomplete: "O CPF tem 11 dígitos.",
45
+ phoneIncomplete: "O celular precisa de DDD e 9 dígitos.",
46
+ },
47
+ reasons: {
48
+ choose: "Escolha como pagar.",
49
+ cpf: "Digite seu CPF para pagar pelo app.",
50
+ phone: "Digite seu celular para pagar pelo app.",
51
+ details: "Preencha seus dados para pagar pelo app.",
52
+ chooseCard: "Escolha o cartão.",
53
+ cardDetails: "Preencha os dados do cartão.",
54
+ repricing: "Atualizando o valor…",
55
+ },
56
+ actions: {
57
+ choose: "Escolha como pagar",
58
+ card: "Pagar",
59
+ pix: "Gerar Pix",
60
+ googlePay: "Pagar com Google Pay",
61
+ handOff: (provider) => (provider ? `Ir para ${the(provider)}` : "Ir para o pagamento"),
62
+ },
63
+ payer: (name) => `Pagando como ${name} · CPF ✓`,
64
+ payerChange: "Alterar",
65
+ charging: (total, card) => `Pagando ${total} no ${card ?? "cartão informado"}…`,
66
+ chargingCovered: "Confirmando o pedido, pago com o seu saldo…",
67
+ cancel: "Cancelar",
68
+ pixFace: {
69
+ title: (total) => `Pague ${total} com Pix`,
70
+ validUntil: (time) =>
71
+ `Válido até ${time}. Copie o código e cole no app do seu banco, em Pix copia e cola.`,
72
+ showQr: "Ver QR code (pagar de outro celular)",
73
+ hideQr: "Esconder QR code",
74
+ changeMethod: "Trocar a forma de pagamento",
75
+ copyCode: "Copiar código Pix",
76
+ waiting: "Aguardando o pagamento do Pix…",
77
+ },
78
+ handOffWaiting: (provider) => (provider ? `Aguardando ${the(provider)}…` : "Aguardando o pagamento…"),
79
+ };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The Pagamento step as ONE list (FUT-3591): every way to pay — the ones the
3
+ * store charges through this package AND the host's own ways to pay at the
4
+ * door — as one radio group under two headings, with one bar whose button is
5
+ * the only thing that creates the order and the charge.
6
+ *
7
+ * Opt-in, through `CheckoutFlowProps.paymentList`. A host that passes nothing
8
+ * keeps the method tabs, the order summary and the raise-on-pick it has today
9
+ * — this is additive, not a replacement.
10
+ *
11
+ * ## The door options are the HOST's
12
+ *
13
+ * Paying at the door raises no charge, so nothing about it belongs to a
14
+ * payments package: which tenders a store takes, the troco question, the
15
+ * minimum order and the request that places the pedido are all the host's.
16
+ * The list only DRAWS them — a radio row in the same group, the host's
17
+ * content opened inside it, and the host's verb, reason and send on the bar's
18
+ * button while it is the chosen one. That is the slot `CheckoutDoorOption`
19
+ * describes.
20
+ */
21
+ import type { ReactNode } from "react";
22
+
23
+ /**
24
+ * One of the host's ways to pay at the door — "Dinheiro", "Maquininha".
25
+ *
26
+ * Every field is a fact the host already holds; the list never computes one.
27
+ * Rebuild the array whenever any of them changes (a hook that returns it is the
28
+ * natural shape): the list renders what it is handed on every render.
29
+ */
30
+ export interface CheckoutDoorOption {
31
+ /** Stable within the list; reported back through `onChoiceChange`. */
32
+ id: string;
33
+ /** The row's label — "Dinheiro". */
34
+ label: string;
35
+ /** The muted line under the label — "Crédito, débito, vale-refeição ou Pix". */
36
+ hint?: string;
37
+ /**
38
+ * Hide the hint while the option is chosen — for an option whose opened
39
+ * content says the same thing at length (the maquininha's brand lines).
40
+ */
41
+ hideHintWhileChosen?: boolean;
42
+ /**
43
+ * What opens INSIDE the row when it is chosen (the troco question, the
44
+ * tender chips, the brand lines). Rendered only while chosen, with no left
45
+ * indent: it starts where the row's own content starts.
46
+ */
47
+ content?: ReactNode;
48
+ /** The bar button's verb while this option is chosen — "Enviar pedido". */
49
+ action: string;
50
+ /** The amount beside the verb. Absent ⇒ the cart's own total. */
51
+ totalLabel?: string;
52
+ /**
53
+ * Why the button cannot send yet, in the host's words — or `null` when it
54
+ * can. Shown under the button, which stays disabled while it is set.
55
+ */
56
+ blocked: string | null;
57
+ /** The host's send is in flight: the button shows its busy state. */
58
+ pending?: boolean;
59
+ /** A refusal of the last send, in the host's words, shown under the button. */
60
+ error?: string | null;
61
+ /** Press of the bar's button while this option is chosen. Places the order. */
62
+ onSend: () => void;
63
+ }
64
+
65
+ /** The online ways to pay the list may offer, as the host is told about them. */
66
+ export type CheckoutOnlineChoice = "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
67
+
68
+ /** What the buyer has chosen in the list. */
69
+ export type CheckoutListChoice =
70
+ | { kind: "online"; method: CheckoutOnlineChoice }
71
+ | { kind: "door"; id: string };
72
+
73
+ /**
74
+ * A reason the HOST is holding the button, whatever is chosen — a step before
75
+ * this one is still open ("Escolha o horário no passo 2."). The button reads
76
+ * `action`, disabled, with `reason` under it.
77
+ */
78
+ export interface CheckoutListHold {
79
+ action: string;
80
+ reason: string;
81
+ }
82
+
83
+ /** `CheckoutFlowProps.paymentList` — see the module comment. */
84
+ export interface CheckoutPaymentList {
85
+ /** Every sentence the list draws. REQUIRED, with no default (FUT-760). */
86
+ copy: PaymentListCopy;
87
+ /**
88
+ * The first line of the bar, in the host's words — "2 itens · Taxa de
89
+ * entrega R$ 5,00". The list draws it as given.
90
+ */
91
+ summary?: ReactNode;
92
+ /**
93
+ * Host content drawn in the bar while an ONLINE option is chosen, under the
94
+ * summary — the storefront's "Usar meus R$ 5,00 de crédito na loja". A door
95
+ * option hides it: what it offers applies only to a payment made here.
96
+ */
97
+ onlineExtras?: ReactNode;
98
+ /** The "PAGAR NA ENTREGA" group. Absent or empty ⇒ no heading, no rows. */
99
+ door?: readonly CheckoutDoorOption[];
100
+ /** Held by the host — see {@link CheckoutListHold}. */
101
+ hold?: CheckoutListHold | null;
102
+ /** Told whenever the chosen option changes, including to nothing. */
103
+ onChoiceChange?: (choice: CheckoutListChoice | null) => void;
104
+ /**
105
+ * The amount an ONLINE payment will ask the provider for, in integer cents —
106
+ * the cart's total, or what is left of it after a balance the host holds
107
+ * (store credit). It is what Google Pay's sheet is priced with, since that
108
+ * sheet must open INSIDE the tap, before any order exists to read a price
109
+ * from. Never given, or zero (a balance covers everything) ⇒ no Google Pay
110
+ * row, and a Google Pay choice is dropped. Once given, a moment without it
111
+ * (the host re-pricing) keeps a chosen Google Pay with its button held —
112
+ * plainly, with `copy.reasons.repricing` — until it returns. The key is
113
+ * refused if the order raised behind the sheet asks the provider for another
114
+ * amount (`CheckoutOrder.amountDueCents`, else its total) — so a host that
115
+ * takes a balance off the charge MUST answer `amountDueCents`.
116
+ */
117
+ totalCents?: number;
118
+ /**
119
+ * The same amount, pre-formatted by the host ("R$ 22,00"), when it is not
120
+ * the cart's total — a balance the host holds pays part of it. The card's
121
+ * button, the 5-second bar and Google Pay's press name THIS while an online
122
+ * option is chosen; a door option and an empty choice keep the cart's total,
123
+ * since no balance pays at the door. Absent ⇒ the cart's total. It is also
124
+ * the amount the Pix face asks for.
125
+ *
126
+ * **A balance that covers everything** (`totalCents: 0`): the card option
127
+ * asks for no card, the bar says `copy.chargingCovered`, and the order the
128
+ * button raises must come back SETTLED by the host — paid from the balance,
129
+ * with no provider call — so the list goes to its paid screen. An order that
130
+ * comes back `AWAITING_PAYMENT` with `amountDueCents: 0` is never charged:
131
+ * the list returns to its rows.
132
+ */
133
+ amountDueLabel?: string;
134
+ }
135
+
136
+ /** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
137
+ export interface PaymentListCopy {
138
+ /** "Como você quer pagar?" */
139
+ title: string;
140
+ /** "Pagar agora, pelo app" — drawn in capitals by CSS. */
141
+ onlineHeading: string;
142
+ /** "Pagar na entrega" — drawn in capitals by CSS. */
143
+ doorHeading: string;
144
+ pix: { label: string; hint: string };
145
+ card: {
146
+ label: string;
147
+ /** "Visa final 4242 ou outro cartão" — the preselected saved card named. */
148
+ hintSaved: (card: string) => string;
149
+ /** "Digite os dados do cartão". */
150
+ hintNew: string;
151
+ };
152
+ googlePay: { label: string; hint: string };
153
+ handOff: {
154
+ label: string;
155
+ /** "Você paga na página da InfinitePay e volta" — `null` when the store published no name. */
156
+ hint: (provider: string | null) => string;
157
+ };
158
+ /** "Visa final 4242"; `brand` is `null` when the network is unknown ("Cartão final 4242"). */
159
+ savedCard: (brand: string | null, last4: string) => string;
160
+ /** "Outro cartão". */
161
+ otherCard: string;
162
+ /** The buyer's own details asked inside the option, when the store needs one the buyer has not given. */
163
+ buyer: {
164
+ cpf: string;
165
+ phone: string;
166
+ name: string;
167
+ email: string;
168
+ /** "(o PagBank pede para pagar pelo app)" — `null` when no name was published. */
169
+ why: (provider: string | null) => string;
170
+ cpfIncomplete: string;
171
+ phoneIncomplete: string;
172
+ };
173
+ /** Why the button is disabled — shown under it. */
174
+ reasons: {
175
+ choose: string;
176
+ cpf: string;
177
+ phone: string;
178
+ details: string;
179
+ chooseCard: string;
180
+ cardDetails: string;
181
+ /** Google Pay is chosen while the host re-prices (`totalCents` briefly absent). Absent ⇒ no sentence. */
182
+ repricing?: string;
183
+ };
184
+ /** The button's verb, beside the total. */
185
+ actions: {
186
+ choose: string;
187
+ card: string;
188
+ pix: string;
189
+ googlePay: string;
190
+ handOff: (provider: string | null) => string;
191
+ };
192
+ /** "Pagando como Ana Teste · CPF ✓". */
193
+ payer: (name: string) => string;
194
+ payerChange: string;
195
+ /** The 5-second bar: "Pagando R$ 27,00 no Visa final 4242…"; `card` is `null` for a card typed now. */
196
+ charging: (total: string, card: string | null) => string;
197
+ /**
198
+ * The 5-second bar when the host's balance pays the WHOLE amount
199
+ * (`totalCents` 0), so no card is charged and none is named — "Confirmando
200
+ * o pedido, pago com o seu saldo…". Absent ⇒ `charging` as usual.
201
+ */
202
+ chargingCovered?: string;
203
+ cancel: string;
204
+ pixFace: {
205
+ /** "Pague R$ 22,00 com Pix". */
206
+ title: (total: string) => string;
207
+ /** "Válido até 18:42. Copie o código e cole no app do seu banco, em Pix copia e cola." */
208
+ validUntil: (time: string) => string;
209
+ showQr: string;
210
+ hideQr: string;
211
+ changeMethod: string;
212
+ /** The bar's action, and the accessible name of the tappable code. */
213
+ copyCode: string;
214
+ waiting: string;
215
+ };
216
+ /** "Aguardando a InfinitePay…" */
217
+ handOffWaiting: (provider: string | null) => string;
218
+ }
@@ -0,0 +1,58 @@
1
+ import { useMemo, useState } from "react";
2
+
3
+ import { useGooglePayClient, type GooglePayGatewayParams, type GooglePaymentsClient } from "../google-pay-button";
4
+ import { cardPathAvailable, googlePayConfig, offeredMethods } from "../method-capability";
5
+ import { methodChosenAtProvider } from "../providers/registry";
6
+ import type { CheckoutProviderConfig } from "../types";
7
+
8
+ import type { OnlineCapability } from "./choice";
9
+
10
+ /** What the store's chain lets THIS browser offer in the list, Google Pay's client included. */
11
+ export interface ListCapability extends OnlineCapability {
12
+ /** Google Pay, ready to open its sheet — `null` when there is no row, or while the host re-prices. */
13
+ googlePay: { client: GooglePaymentsClient; params: GooglePayGatewayParams } | null;
14
+ }
15
+
16
+ /**
17
+ * The capability reads the tabs layout already makes (`method-capability.ts`,
18
+ * `providers/registry.ts`), plus the one the list adds: Google Pay as its own
19
+ * row, offered only when the chain head declares it AND this browser's
20
+ * `isReadyToPay` said yes — the same two gates the Google Pay button has
21
+ * always had (FUT-471), so the list never offers a wallet that cannot work.
22
+ *
23
+ * The sheet must open inside the tap, before any order exists, so it is priced
24
+ * with the host's `totalCents` — the amount DUE. A host that never priced it,
25
+ * or prices it at zero (a balance it holds covers everything), offers no row.
26
+ * Once priced, a moment without a price (the host re-pricing — the credit was
27
+ * toggled) keeps the row, and the list holds a chosen Google Pay's button,
28
+ * plainly, until the price returns.
29
+ *
30
+ * Apple Pay is not a row here: its sheet must open synchronously inside the
31
+ * tap, before any order could be raised for it. The tabs layout keeps it.
32
+ */
33
+ export function useListCapability(
34
+ config: CheckoutProviderConfig | null,
35
+ /** The host's price for the sheet — no price (or zero), no Google Pay row. */
36
+ totalCents: number | undefined,
37
+ ): ListCapability {
38
+ const declared = googlePayConfig(config) !== null;
39
+ // `null` tells the client hook not to load pay.js at all.
40
+ const client = useGooglePayClient(declared ? undefined : null, "TEST");
41
+ const priced = totalCents !== undefined && totalCents > 0;
42
+ // Priced once, and not since withdrawn by an explicit zero.
43
+ const [wasPriced, setWasPriced] = useState(false);
44
+ if (priced && !wasPriced) setWasPriced(true);
45
+ if (totalCents === 0 && wasPriced) setWasPriced(false);
46
+ const repricing = totalCents === undefined && wasPriced;
47
+ return useMemo(() => {
48
+ const params = googlePayConfig(config);
49
+ const usable = params && client ? { client, params } : null;
50
+ return {
51
+ handOff: methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config),
52
+ offered: offeredMethods(config),
53
+ cardUnavailable: !cardPathAvailable(config),
54
+ googlePayReady: usable !== null && (priced || repricing),
55
+ googlePay: priced ? usable : null,
56
+ };
57
+ }, [config, client, priced, repricing]);
58
+ }
@@ -0,0 +1,186 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import type { CheckoutOrder, PaymentMethod } from "../types";
4
+
5
+ /**
6
+ * How long the card's "Pagando R$ X no Visa final 4242…" bar takes to fill —
7
+ * the window in which "Cancelar" still sends nothing (FUT-3592, M10). The
8
+ * owner's own number: the bar replaces the confirmation dialog, so it is the
9
+ * buyer's whole chance to change their mind.
10
+ */
11
+ export const CHARGE_WINDOW_MS = 5_000;
12
+
13
+ /**
14
+ * Where a charge the bar's button started stands.
15
+ *
16
+ * - `idle` — the list, with the button.
17
+ * - `counting` — the card's 5-second bar; Cancelar still sends nothing.
18
+ * - `sending` — the order is being raised, then the instrument charged.
19
+ * - `waiting` — the charge was accepted and its confirmation is awaited.
20
+ */
21
+ export type ListChargePhase = "idle" | "counting" | "sending" | "waiting";
22
+
23
+ /** What a charge runs against: the card the buyer chose, or Google Pay's sheet. */
24
+ export type ListChargeKind = "CARD" | "GOOGLE_PAY";
25
+
26
+ /** The controller's slice this needs. */
27
+ interface RaisePorts {
28
+ order: CheckoutOrder | null;
29
+ creating: boolean;
30
+ createError: string | null;
31
+ setMethod: (method: PaymentMethod) => void;
32
+ startPayment: (method: PaymentMethod) => Promise<void>;
33
+ /** The amount the buyer is shown now (the cart's, or the balance's). */
34
+ cartTotal: string;
35
+ /** What an online payment asks the provider for now, when the host prices it (`paymentList.totalCents`). */
36
+ dueCents?: number;
37
+ }
38
+
39
+ export interface ListCharge {
40
+ phase: ListChargePhase;
41
+ kind: ListChargeKind | null;
42
+ /** The amount named when the button was pressed — the bar shows THIS, never a later one. */
43
+ pressedTotal: string | null;
44
+ /** Start a card charge (with its 5-second bar) or a Google Pay one (straight away). Only from idle. */
45
+ start: (kind: ListChargeKind, total: string) => void;
46
+ /** Cancelar, during the bar: nothing has been sent, and nothing will be. */
47
+ cancel: () => void;
48
+ /**
49
+ * Whether the charge may still go out — set by the list on every render. A
50
+ * hold the host raised, a reason the button would show, or a total that
51
+ * moved since the press stops the bar, and `send` checks it again.
52
+ */
53
+ setAllowed: (allowed: boolean) => void;
54
+ /** The runner reports the charge was accepted and is now awaited. */
55
+ accepted: () => void;
56
+ /** The runner, or the raise, gave up: back to the list as it was. */
57
+ settle: () => void;
58
+ /** The CARD order the runner charges — set once one is held for this charge. */
59
+ chargeable: CheckoutOrder | null;
60
+ }
61
+
62
+ /**
63
+ * A held CARD order still awaiting payment AND priced as the buyer now sees —
64
+ * reusable for the charge. A balance ticked or unticked moves what the charge
65
+ * asks for and not the order's total, so the amount due is compared too.
66
+ */
67
+ function reusable(order: CheckoutOrder | null, cartTotal: string, dueCents: number | undefined): boolean {
68
+ if (order?.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || order.totalLabel !== cartTotal) return false;
69
+ return dueCents === undefined || (order.amountDueCents ?? order.totalCents) === dueCents;
70
+ }
71
+
72
+ /**
73
+ * An awaiting CARD order that asks the provider for nothing: a balance the host
74
+ * holds covers it, and the host should have settled it (see `amountDueLabel`).
75
+ * It is never charged.
76
+ */
77
+ function owesNothing(order: CheckoutOrder | null): boolean {
78
+ return order?.method === "CARD" && order.status === "AWAITING_PAYMENT" && order.amountDueCents === 0;
79
+ }
80
+
81
+ /** The order `send` meant: the one it reused, or the CARD order raised to replace a stale one. */
82
+ function chargeTarget(
83
+ order: CheckoutOrder | null,
84
+ target: { reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null,
85
+ ): CheckoutOrder | null {
86
+ if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || owesNothing(order)) return null;
87
+ if ("reuse" in target) return target.reuse === order ? order : null;
88
+ return order !== target.replace ? order : null;
89
+ }
90
+
91
+ /**
92
+ * Back to the list when the press left nothing to charge: a raise that failed
93
+ * (the step's error panel says why), or a held order that owes the provider
94
+ * nothing — never charged, rather than waited on forever.
95
+ */
96
+ function useSettleWhenNothingToCharge(phase: ListChargePhase, kind: ListChargeKind | null, ports: RaisePorts, settle: () => void): void {
97
+ const { order, creating, createError } = ports;
98
+ useEffect(() => {
99
+ if (phase !== "sending" || creating) return;
100
+ if ((!order && createError) || (kind === "CARD" && owesNothing(order))) settle();
101
+ }, [phase, kind, creating, order, createError, settle]);
102
+ }
103
+
104
+ /**
105
+ * The bar's charge, after the button (FUT-3592): count down for a card, then
106
+ * raise the order — or reuse the CARD order already held, after a refusal the
107
+ * buyer is retrying, but only while it is priced as the cart now is (a credit
108
+ * toggled, an address changed: re-raise, never charge the old amount) — and
109
+ * hand it to the runner that charges it.
110
+ *
111
+ * Nothing is sent before `sending`: the timer is the only thing running while
112
+ * the bar fills, so Cancelar has nothing to undo.
113
+ */
114
+ export function useListCharge(ports: RaisePorts): ListCharge {
115
+ const [phase, setPhase] = useState<ListChargePhase>("idle");
116
+ const [kind, setKind] = useState<ListChargeKind | null>(null);
117
+ const [pressedTotal, setPressedTotal] = useState<string | null>(null);
118
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
119
+ const allowed = useRef(true);
120
+ // What `send` decided to charge: the held order it reused, or "a fresh one"
121
+ // — any CARD order other than the stale one it replaced.
122
+ const target = useRef<{ reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null>(null);
123
+ const latest = useRef(ports);
124
+ latest.current = ports;
125
+
126
+ const cancel = useCallback(() => {
127
+ if (timer.current) clearTimeout(timer.current);
128
+ timer.current = null;
129
+ setPhase("idle");
130
+ setKind(null);
131
+ setPressedTotal(null);
132
+ }, []);
133
+
134
+ const send = useCallback(() => {
135
+ timer.current = null;
136
+ // Checked again at the last moment: a hold raised between two renders
137
+ // must not let the bar's own timer charge past it.
138
+ if (!allowed.current) return cancel();
139
+ setPhase("sending");
140
+ const { order, cartTotal, dueCents } = latest.current;
141
+ if (reusable(order, cartTotal, dueCents) && order) {
142
+ target.current = { reuse: order };
143
+ return;
144
+ }
145
+ target.current = { replace: order };
146
+ latest.current.setMethod("CARD");
147
+ void latest.current.startPayment("CARD");
148
+ }, [cancel]);
149
+
150
+ const phaseRef = useRef(phase);
151
+ phaseRef.current = phase;
152
+ const start = useCallback(
153
+ (next: ListChargeKind, total: string) => {
154
+ if (phaseRef.current !== "idle") return;
155
+ phaseRef.current = next === "GOOGLE_PAY" ? "sending" : "counting";
156
+ setKind(next);
157
+ setPressedTotal(total);
158
+ if (next === "GOOGLE_PAY") return send();
159
+ setPhase("counting");
160
+ timer.current = setTimeout(send, CHARGE_WINDOW_MS);
161
+ },
162
+ [send],
163
+ );
164
+
165
+ const setAllowed = useCallback(
166
+ (next: boolean) => {
167
+ allowed.current = next;
168
+ if (!next && phaseRef.current === "counting") cancel();
169
+ },
170
+ [cancel],
171
+ );
172
+
173
+ const settle = cancel;
174
+ const accepted = useCallback(() => setPhase("waiting"), []);
175
+
176
+ useEffect(() => () => {
177
+ if (timer.current) clearTimeout(timer.current);
178
+ }, []);
179
+
180
+ const { order, creating } = ports;
181
+ useSettleWhenNothingToCharge(phase, kind, ports, settle);
182
+
183
+ const sending = phase === "sending" || phase === "waiting";
184
+ const chargeable = sending && !creating ? chargeTarget(order, target.current) : null;
185
+ return { phase, kind, pressedTotal, start, cancel, setAllowed, accepted, settle, chargeable };
186
+ }