@12-apps/payments-frontend 3.36.0 → 3.38.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 +1 -1
- package/src/components/checkout/card-sources.ts +131 -0
- package/src/components/checkout/card-view.tsx +6 -2
- package/src/components/checkout/cart-changed.tsx +50 -18
- package/src/components/checkout/checkout-actions.ts +18 -0
- package/src/components/checkout/checkout-flow.tsx +24 -77
- package/src/components/checkout/google-pay-button.tsx +4 -4
- package/src/components/checkout/list/buyer-inline.tsx +125 -0
- package/src/components/checkout/list/charge-runners.tsx +156 -0
- package/src/components/checkout/list/choice-list.tsx +156 -0
- package/src/components/checkout/list/choice.ts +193 -0
- package/src/components/checkout/list/context.tsx +55 -0
- package/src/components/checkout/list/en-US.ts +61 -0
- package/src/components/checkout/list/list-bar.tsx +186 -0
- package/src/components/checkout/list/list-face.tsx +315 -0
- package/src/components/checkout/list/list-pix.tsx +206 -0
- package/src/components/checkout/list/online-content.tsx +137 -0
- package/src/components/checkout/list/option-row.tsx +123 -0
- package/src/components/checkout/list/payment-list-step.tsx +166 -0
- package/src/components/checkout/list/pt-BR.ts +77 -0
- package/src/components/checkout/list/types.ts +187 -0
- package/src/components/checkout/list/use-capability.ts +53 -0
- package/src/components/checkout/list/use-list-charge.ts +161 -0
- package/src/components/checkout/list/use-list-state.ts +152 -0
- package/src/components/checkout/payment-phase.tsx +119 -0
- package/src/components/checkout/pix-after-copy.tsx +10 -3
- package/src/components/checkout/pix-pane.tsx +33 -22
- package/src/components/checkout/pix-parts.tsx +50 -1
- package/src/components/checkout/pix-view.tsx +53 -20
- package/src/components/checkout/screens-copy.ts +6 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +1 -0
- package/src/components/checkout/use-card-checkout.ts +11 -78
- package/src/components/checkout/use-checkout-controller.ts +3 -1
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/types.ts +7 -0
- package/src/index.ts +4 -5
- package/src/locales.ts +13 -0
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
},
|
|
55
|
+
actions: {
|
|
56
|
+
choose: "Escolha como pagar",
|
|
57
|
+
card: "Pagar",
|
|
58
|
+
pix: "Gerar Pix",
|
|
59
|
+
googlePay: "Pagar com Google Pay",
|
|
60
|
+
handOff: (provider) => (provider ? `Ir para ${the(provider)}` : "Ir para o pagamento"),
|
|
61
|
+
},
|
|
62
|
+
payer: (name) => `Pagando como ${name} · CPF ✓`,
|
|
63
|
+
payerChange: "Alterar",
|
|
64
|
+
charging: (total, card) => `Pagando ${total} no ${card ?? "cartão informado"}…`,
|
|
65
|
+
cancel: "Cancelar",
|
|
66
|
+
pixFace: {
|
|
67
|
+
title: (total) => `Pague ${total} com Pix`,
|
|
68
|
+
validUntil: (time) =>
|
|
69
|
+
`Válido até ${time}. Copie o código e cole no app do seu banco, em Pix copia e cola.`,
|
|
70
|
+
showQr: "Ver QR code (pagar de outro celular)",
|
|
71
|
+
hideQr: "Esconder QR code",
|
|
72
|
+
changeMethod: "Trocar a forma de pagamento",
|
|
73
|
+
copyCode: "Copiar código Pix",
|
|
74
|
+
waiting: "Aguardando o pagamento do Pix…",
|
|
75
|
+
},
|
|
76
|
+
handOffWaiting: (provider) => (provider ? `Aguardando ${the(provider)}…` : "Aguardando o pagamento…"),
|
|
77
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
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 the buyer is about to pay, in integer cents — what Google Pay's
|
|
106
|
+
* sheet is priced with, since that sheet must open INSIDE the tap, before any
|
|
107
|
+
* order exists to read a price from. Absent ⇒ no Google Pay row. The charge
|
|
108
|
+
* is refused if the order raised after the sheet does not cost the same.
|
|
109
|
+
*/
|
|
110
|
+
totalCents?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
|
|
114
|
+
export interface PaymentListCopy {
|
|
115
|
+
/** "Como você quer pagar?" */
|
|
116
|
+
title: string;
|
|
117
|
+
/** "Pagar agora, pelo app" — drawn in capitals by CSS. */
|
|
118
|
+
onlineHeading: string;
|
|
119
|
+
/** "Pagar na entrega" — drawn in capitals by CSS. */
|
|
120
|
+
doorHeading: string;
|
|
121
|
+
pix: { label: string; hint: string };
|
|
122
|
+
card: {
|
|
123
|
+
label: string;
|
|
124
|
+
/** "Visa final 4242 ou outro cartão" — the preselected saved card named. */
|
|
125
|
+
hintSaved: (card: string) => string;
|
|
126
|
+
/** "Digite os dados do cartão". */
|
|
127
|
+
hintNew: string;
|
|
128
|
+
};
|
|
129
|
+
googlePay: { label: string; hint: string };
|
|
130
|
+
handOff: {
|
|
131
|
+
label: string;
|
|
132
|
+
/** "Você paga na página da InfinitePay e volta" — `null` when the store published no name. */
|
|
133
|
+
hint: (provider: string | null) => string;
|
|
134
|
+
};
|
|
135
|
+
/** "Visa final 4242"; `brand` is `null` when the network is unknown ("Cartão final 4242"). */
|
|
136
|
+
savedCard: (brand: string | null, last4: string) => string;
|
|
137
|
+
/** "Outro cartão". */
|
|
138
|
+
otherCard: string;
|
|
139
|
+
/** The buyer's own details asked inside the option, when the store needs one the buyer has not given. */
|
|
140
|
+
buyer: {
|
|
141
|
+
cpf: string;
|
|
142
|
+
phone: string;
|
|
143
|
+
name: string;
|
|
144
|
+
email: string;
|
|
145
|
+
/** "(o PagBank pede para pagar pelo app)" — `null` when no name was published. */
|
|
146
|
+
why: (provider: string | null) => string;
|
|
147
|
+
cpfIncomplete: string;
|
|
148
|
+
phoneIncomplete: string;
|
|
149
|
+
};
|
|
150
|
+
/** Why the button is disabled — shown under it. */
|
|
151
|
+
reasons: {
|
|
152
|
+
choose: string;
|
|
153
|
+
cpf: string;
|
|
154
|
+
phone: string;
|
|
155
|
+
details: string;
|
|
156
|
+
chooseCard: string;
|
|
157
|
+
cardDetails: string;
|
|
158
|
+
};
|
|
159
|
+
/** The button's verb, beside the total. */
|
|
160
|
+
actions: {
|
|
161
|
+
choose: string;
|
|
162
|
+
card: string;
|
|
163
|
+
pix: string;
|
|
164
|
+
googlePay: string;
|
|
165
|
+
handOff: (provider: string | null) => string;
|
|
166
|
+
};
|
|
167
|
+
/** "Pagando como Ana Teste · CPF ✓". */
|
|
168
|
+
payer: (name: string) => string;
|
|
169
|
+
payerChange: string;
|
|
170
|
+
/** The 5-second bar: "Pagando R$ 27,00 no Visa final 4242…"; `card` is `null` for a card typed now. */
|
|
171
|
+
charging: (total: string, card: string | null) => string;
|
|
172
|
+
cancel: string;
|
|
173
|
+
pixFace: {
|
|
174
|
+
/** "Pague R$ 22,00 com Pix". */
|
|
175
|
+
title: (total: string) => string;
|
|
176
|
+
/** "Válido até 18:42. Copie o código e cole no app do seu banco, em Pix copia e cola." */
|
|
177
|
+
validUntil: (time: string) => string;
|
|
178
|
+
showQr: string;
|
|
179
|
+
hideQr: string;
|
|
180
|
+
changeMethod: string;
|
|
181
|
+
/** The bar's action, and the accessible name of the tappable code. */
|
|
182
|
+
copyCode: string;
|
|
183
|
+
waiting: string;
|
|
184
|
+
};
|
|
185
|
+
/** "Aguardando a InfinitePay…" */
|
|
186
|
+
handOffWaiting: (provider: string | null) => string;
|
|
187
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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`; without one there is no row.
|
|
25
|
+
*
|
|
26
|
+
* Apple Pay is not a row here: its sheet must open synchronously inside the
|
|
27
|
+
* tap, before any order could be raised for it. The tabs layout keeps it.
|
|
28
|
+
*/
|
|
29
|
+
export function useListCapability(
|
|
30
|
+
config: CheckoutProviderConfig | null,
|
|
31
|
+
/** The host's price for the sheet — no price, no Google Pay row. */
|
|
32
|
+
totalCents: number | undefined,
|
|
33
|
+
): ListCapability {
|
|
34
|
+
const declared = googlePayConfig(config) !== null;
|
|
35
|
+
// `null` tells the client hook not to load pay.js at all.
|
|
36
|
+
const client = useGooglePayClient(declared ? undefined : null, "TEST");
|
|
37
|
+
// Once the host has priced the sheet, a moment without a price (it is
|
|
38
|
+
// re-pricing — the credit was toggled) keeps the row and holds its button;
|
|
39
|
+
// only a host that never priced it offers no row.
|
|
40
|
+
const [priced, setPriced] = useState(false);
|
|
41
|
+
if (totalCents !== undefined && !priced) setPriced(true);
|
|
42
|
+
return useMemo(() => {
|
|
43
|
+
const params = googlePayConfig(config);
|
|
44
|
+
const usable = params && client ? { client, params } : null;
|
|
45
|
+
return {
|
|
46
|
+
handOff: methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config),
|
|
47
|
+
offered: offeredMethods(config),
|
|
48
|
+
cardUnavailable: !cardPathAvailable(config),
|
|
49
|
+
googlePayReady: usable !== null && (totalCents !== undefined || priced),
|
|
50
|
+
googlePay: totalCents === undefined ? null : usable,
|
|
51
|
+
};
|
|
52
|
+
}, [config, client, totalCents, priced]);
|
|
53
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListCharge {
|
|
38
|
+
phase: ListChargePhase;
|
|
39
|
+
kind: ListChargeKind | null;
|
|
40
|
+
/** The amount named when the button was pressed — the bar shows THIS, never a later one. */
|
|
41
|
+
pressedTotal: string | null;
|
|
42
|
+
/** Start a card charge (with its 5-second bar) or a Google Pay one (straight away). Only from idle. */
|
|
43
|
+
start: (kind: ListChargeKind, total: string) => void;
|
|
44
|
+
/** Cancelar, during the bar: nothing has been sent, and nothing will be. */
|
|
45
|
+
cancel: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the charge may still go out — set by the list on every render. A
|
|
48
|
+
* hold the host raised, a reason the button would show, or a total that
|
|
49
|
+
* moved since the press stops the bar, and `send` checks it again.
|
|
50
|
+
*/
|
|
51
|
+
setAllowed: (allowed: boolean) => void;
|
|
52
|
+
/** The runner reports the charge was accepted and is now awaited. */
|
|
53
|
+
accepted: () => void;
|
|
54
|
+
/** The runner, or the raise, gave up: back to the list as it was. */
|
|
55
|
+
settle: () => void;
|
|
56
|
+
/** The CARD order the runner charges — set once one is held for this charge. */
|
|
57
|
+
chargeable: CheckoutOrder | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A held CARD order still awaiting payment AND priced as the buyer now sees — reusable for the charge. */
|
|
61
|
+
function reusable(order: CheckoutOrder | null, cartTotal: string): boolean {
|
|
62
|
+
return order?.method === "CARD" && order.status === "AWAITING_PAYMENT" && order.totalLabel === cartTotal;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The order `send` meant: the one it reused, or the CARD order raised to replace a stale one. */
|
|
66
|
+
function chargeTarget(
|
|
67
|
+
order: CheckoutOrder | null,
|
|
68
|
+
target: { reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null,
|
|
69
|
+
): CheckoutOrder | null {
|
|
70
|
+
if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT") return null;
|
|
71
|
+
if ("reuse" in target) return target.reuse === order ? order : null;
|
|
72
|
+
return order !== target.replace ? order : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bar's charge, after the button (FUT-3592): count down for a card, then
|
|
77
|
+
* raise the order — or reuse the CARD order already held, after a refusal the
|
|
78
|
+
* buyer is retrying, but only while it is priced as the cart now is (a credit
|
|
79
|
+
* toggled, an address changed: re-raise, never charge the old amount) — and
|
|
80
|
+
* hand it to the runner that charges it.
|
|
81
|
+
*
|
|
82
|
+
* Nothing is sent before `sending`: the timer is the only thing running while
|
|
83
|
+
* the bar fills, so Cancelar has nothing to undo.
|
|
84
|
+
*/
|
|
85
|
+
export function useListCharge(ports: RaisePorts): ListCharge {
|
|
86
|
+
const [phase, setPhase] = useState<ListChargePhase>("idle");
|
|
87
|
+
const [kind, setKind] = useState<ListChargeKind | null>(null);
|
|
88
|
+
const [pressedTotal, setPressedTotal] = useState<string | null>(null);
|
|
89
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
90
|
+
const allowed = useRef(true);
|
|
91
|
+
// What `send` decided to charge: the held order it reused, or "a fresh one"
|
|
92
|
+
// — any CARD order other than the stale one it replaced.
|
|
93
|
+
const target = useRef<{ reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null>(null);
|
|
94
|
+
const latest = useRef(ports);
|
|
95
|
+
latest.current = ports;
|
|
96
|
+
|
|
97
|
+
const cancel = useCallback(() => {
|
|
98
|
+
if (timer.current) clearTimeout(timer.current);
|
|
99
|
+
timer.current = null;
|
|
100
|
+
setPhase("idle");
|
|
101
|
+
setKind(null);
|
|
102
|
+
setPressedTotal(null);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const send = useCallback(() => {
|
|
106
|
+
timer.current = null;
|
|
107
|
+
// Checked again at the last moment: a hold raised between two renders
|
|
108
|
+
// must not let the bar's own timer charge past it.
|
|
109
|
+
if (!allowed.current) return cancel();
|
|
110
|
+
setPhase("sending");
|
|
111
|
+
const { order, cartTotal } = latest.current;
|
|
112
|
+
if (reusable(order, cartTotal) && order) {
|
|
113
|
+
target.current = { reuse: order };
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
target.current = { replace: order };
|
|
117
|
+
latest.current.setMethod("CARD");
|
|
118
|
+
void latest.current.startPayment("CARD");
|
|
119
|
+
}, [cancel]);
|
|
120
|
+
|
|
121
|
+
const phaseRef = useRef(phase);
|
|
122
|
+
phaseRef.current = phase;
|
|
123
|
+
const start = useCallback(
|
|
124
|
+
(next: ListChargeKind, total: string) => {
|
|
125
|
+
if (phaseRef.current !== "idle") return;
|
|
126
|
+
phaseRef.current = next === "GOOGLE_PAY" ? "sending" : "counting";
|
|
127
|
+
setKind(next);
|
|
128
|
+
setPressedTotal(total);
|
|
129
|
+
if (next === "GOOGLE_PAY") return send();
|
|
130
|
+
setPhase("counting");
|
|
131
|
+
timer.current = setTimeout(send, CHARGE_WINDOW_MS);
|
|
132
|
+
},
|
|
133
|
+
[send],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const setAllowed = useCallback(
|
|
137
|
+
(next: boolean) => {
|
|
138
|
+
allowed.current = next;
|
|
139
|
+
if (!next && phaseRef.current === "counting") cancel();
|
|
140
|
+
},
|
|
141
|
+
[cancel],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const settle = cancel;
|
|
145
|
+
const accepted = useCallback(() => setPhase("waiting"), []);
|
|
146
|
+
|
|
147
|
+
useEffect(() => () => {
|
|
148
|
+
if (timer.current) clearTimeout(timer.current);
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
// A raise that failed leaves nothing to charge: back to the list, where the
|
|
152
|
+
// step's error panel says why.
|
|
153
|
+
const { order, creating, createError } = ports;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (phase === "sending" && !creating && !order && createError) settle();
|
|
156
|
+
}, [phase, creating, order, createError, settle]);
|
|
157
|
+
|
|
158
|
+
const sending = phase === "sending" || phase === "waiting";
|
|
159
|
+
const chargeable = sending && !creating ? chargeTarget(order, target.current) : null;
|
|
160
|
+
return { phase, kind, pressedTotal, start, cancel, setAllowed, accepted, settle, chargeable };
|
|
161
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
|
|
3
|
+
import { NEW_CARD, type CardDetails, type CardFieldErrors, type SavedCard } from "../../../card";
|
|
4
|
+
import { useSavedCardList } from "../card-sources";
|
|
5
|
+
import { useCheckoutCopy } from "../copy-context";
|
|
6
|
+
import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig } from "../types";
|
|
7
|
+
|
|
8
|
+
import { askedFields, cardComplete, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
|
|
9
|
+
import type { CheckoutListChoice } from "./types";
|
|
10
|
+
|
|
11
|
+
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
12
|
+
|
|
13
|
+
/** The card the buyer pays with, as the list holds it before any order exists. */
|
|
14
|
+
export interface ListCard {
|
|
15
|
+
saved: SavedCard[];
|
|
16
|
+
/** A saved card's id, `NEW_CARD` for the fields, or `null` when none is chosen. */
|
|
17
|
+
selection: string | null;
|
|
18
|
+
setSelection: (selection: string | null) => void;
|
|
19
|
+
card: CardDetails;
|
|
20
|
+
setCard: Dispatch<SetStateAction<CardDetails>>;
|
|
21
|
+
fieldErrors: CardFieldErrors;
|
|
22
|
+
setFieldErrors: Dispatch<SetStateAction<CardFieldErrors>>;
|
|
23
|
+
saveCard: boolean;
|
|
24
|
+
setSaveCard: (save: boolean) => void;
|
|
25
|
+
complete: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Everything the one list holds, and nothing it raises. */
|
|
29
|
+
export interface ListState {
|
|
30
|
+
rows: OnlineRow[];
|
|
31
|
+
choice: CheckoutListChoice | null;
|
|
32
|
+
choose: (next: CheckoutListChoice) => void;
|
|
33
|
+
card: ListCard;
|
|
34
|
+
/** The buyer's own details the chosen online option must ask for (M11). */
|
|
35
|
+
asked: CheckoutCustomerField[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The card the buyer is typing or has picked. It outlives every order raised
|
|
40
|
+
* under it: the charge reads it when the 5-second bar fills, and a refusal
|
|
41
|
+
* brings the buyer back to it as they left it.
|
|
42
|
+
*/
|
|
43
|
+
function useListCard(saved: SavedCard[]): ListCard {
|
|
44
|
+
const fieldCopy = useCheckoutCopy().card.fields;
|
|
45
|
+
const [selection, setSelection] = useState<string | null>(null);
|
|
46
|
+
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
47
|
+
const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
|
|
48
|
+
const [saveCard, setSaveCard] = useState(false);
|
|
49
|
+
const complete = useMemo(() => cardComplete(card, fieldCopy), [card, fieldCopy]);
|
|
50
|
+
return { saved, selection, setSelection, card, setCard, fieldErrors, setFieldErrors, saveCard, setSaveCard, complete };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Preselect ONCE, when the saved cards answer — never over a choice the buyer
|
|
55
|
+
* already made, and never again after they made one (M7).
|
|
56
|
+
*/
|
|
57
|
+
function usePreselect(
|
|
58
|
+
loaded: boolean,
|
|
59
|
+
rows: readonly OnlineRow[],
|
|
60
|
+
saved: readonly SavedCard[],
|
|
61
|
+
fresh: boolean,
|
|
62
|
+
apply: (choice: CheckoutListChoice, selection: string | null) => void,
|
|
63
|
+
touched: { current: boolean },
|
|
64
|
+
): void {
|
|
65
|
+
const done = useRef(false);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (done.current || touched.current || !loaded) return;
|
|
68
|
+
done.current = true;
|
|
69
|
+
const pick = preselection(rows, saved, fresh);
|
|
70
|
+
if (pick) apply(pick.choice, pick.selection);
|
|
71
|
+
}, [loaded, rows, saved, fresh, apply, touched]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The card chosen again: the head of the saved list — or, for a buyer back from
|
|
76
|
+
* a refused card (FUT-1145), no saved card at all (the head may be the one that
|
|
77
|
+
* failed), or the fields when nothing is saved.
|
|
78
|
+
*/
|
|
79
|
+
function reopenedSelection(cards: readonly SavedCard[], fresh: boolean): string | null {
|
|
80
|
+
const head = cards[0];
|
|
81
|
+
if (!head) return NEW_CARD;
|
|
82
|
+
return fresh ? null : head.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one list's state (FUT-3591). Picking an option changes THIS and nothing
|
|
87
|
+
* else: no order, no charge, no request — the bar's button is the only thing
|
|
88
|
+
* that raises (M7). The host hears about every change through
|
|
89
|
+
* `onChoiceChange`, which is how its door options learn they are chosen.
|
|
90
|
+
*/
|
|
91
|
+
export function useListState(input: {
|
|
92
|
+
config: CheckoutProviderConfig | null;
|
|
93
|
+
capability: OnlineCapability;
|
|
94
|
+
tenantSlug: string | undefined;
|
|
95
|
+
/** Back here after a refused card (FUT-1145): no saved card is selected for them. */
|
|
96
|
+
fresh: boolean;
|
|
97
|
+
arrivedWith: BuyerInfo | undefined;
|
|
98
|
+
taxIdOnFile: boolean;
|
|
99
|
+
onChoiceChange?: (choice: CheckoutListChoice | null) => void;
|
|
100
|
+
}): ListState {
|
|
101
|
+
const { capability } = input;
|
|
102
|
+
const rows = useMemo(() => onlineRows(capability), [capability]);
|
|
103
|
+
const { cards, loaded } = useSavedCardList(input.tenantSlug);
|
|
104
|
+
const card = useListCard(cards);
|
|
105
|
+
const [choice, setChoice] = useState<CheckoutListChoice | null>(null);
|
|
106
|
+
const touched = useRef(false);
|
|
107
|
+
const { setSelection } = card;
|
|
108
|
+
const apply = useCallback(
|
|
109
|
+
(next: CheckoutListChoice, selection: string | null) => {
|
|
110
|
+
setChoice(next);
|
|
111
|
+
if (selection !== null) setSelection(selection);
|
|
112
|
+
},
|
|
113
|
+
[setSelection],
|
|
114
|
+
);
|
|
115
|
+
usePreselect(loaded, rows, cards, input.fresh, apply, touched);
|
|
116
|
+
const choose = useCallback(
|
|
117
|
+
(next: CheckoutListChoice) => {
|
|
118
|
+
touched.current = true;
|
|
119
|
+
setChoice((previous) => {
|
|
120
|
+
// Coming back to the card from another option starts on the head of
|
|
121
|
+
// the saved list again (FN:420), or on the fields when there is none;
|
|
122
|
+
// what was typed in them is kept.
|
|
123
|
+
const reopensCard = next.kind === "online" && next.method === "CARD" && !(previous?.kind === "online" && previous.method === "CARD");
|
|
124
|
+
if (reopensCard) setSelection(reopenedSelection(cards, input.fresh));
|
|
125
|
+
return next;
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
[cards, setSelection, input.fresh],
|
|
129
|
+
);
|
|
130
|
+
// A choice the chain no longer offers (a config that resolved to a hand-off
|
|
131
|
+
// store after the card was preselected) is no choice at all.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (choice?.kind === "online" && !rows.some((row) => row.method === choice.method && !row.disabled)) setChoice(null);
|
|
134
|
+
}, [choice, rows]);
|
|
135
|
+
const { onChoiceChange } = input;
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
onChoiceChange?.(choice);
|
|
138
|
+
}, [choice, onChoiceChange]);
|
|
139
|
+
const asked = useMemo(
|
|
140
|
+
() =>
|
|
141
|
+
choice?.kind === "online"
|
|
142
|
+
? askedFields({
|
|
143
|
+
chain: input.config?.chain,
|
|
144
|
+
method: raisedMethod(choice.method, capability.offered),
|
|
145
|
+
arrivedWith: input.arrivedWith,
|
|
146
|
+
taxIdOnFile: input.taxIdOnFile,
|
|
147
|
+
})
|
|
148
|
+
: [],
|
|
149
|
+
[choice, input.config, capability.offered, input.arrivedWith, input.taxIdOnFile],
|
|
150
|
+
);
|
|
151
|
+
return { rows, choice, choose, card, asked };
|
|
152
|
+
}
|