@12-apps/payments-frontend 1.7.0 → 1.8.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 +17 -6
- package/src/components/checkout/buyer-fields.ts +117 -0
- package/src/components/checkout/buyer-info-form.tsx +154 -85
- package/src/components/checkout/card-instruments.ts +65 -21
- package/src/components/checkout/checkout-flow.tsx +9 -2
- package/src/components/checkout/checkout-steps.tsx +58 -26
- package/src/components/checkout/client-context.tsx +57 -0
- package/src/components/checkout/client.ts +18 -110
- package/src/components/checkout/method-capability.ts +28 -6
- package/src/components/checkout/navigate-context.tsx +40 -0
- package/src/components/checkout/transport.ts +191 -0
- package/src/components/checkout/types.ts +27 -0
- package/src/components/checkout/ui.tsx +15 -3
- package/src/components/checkout/use-card-checkout.ts +30 -13
- package/src/components/checkout/use-checkout-controller.ts +64 -20
- package/src/components/checkout/use-payment-polling.ts +7 -3
- package/src/flows/copy.ts +57 -0
- package/src/flows/create-payment-flows.tsx +196 -0
- package/src/flows/runtime.tsx +149 -0
- package/src/flows/screens-buyer.tsx +170 -0
- package/src/flows/screens-hosted.tsx +149 -0
- package/src/flows/screens-pay.tsx +178 -0
- package/src/flows/types.ts +179 -0
- package/src/index.ts +35 -0
|
@@ -198,6 +198,17 @@ export interface ChargeCardInput {
|
|
|
198
198
|
taxId?: string;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
|
|
202
|
+
export interface CheckoutCustomerField {
|
|
203
|
+
key: "name" | "email" | "taxId" | "phone";
|
|
204
|
+
/** Which validation rule applies. `MOBILE` is the strictly narrower `PHONE`. */
|
|
205
|
+
type: "NAME" | "EMAIL" | "PHONE" | "MOBILE" | "CPF";
|
|
206
|
+
/** The provider refuses (or its own page stalls) without this field. */
|
|
207
|
+
required: boolean;
|
|
208
|
+
/** Methods this spec applies to; omitted means every method the adapter takes. */
|
|
209
|
+
methods?: ("PIX" | "CARD" | "BOLETO")[];
|
|
210
|
+
}
|
|
211
|
+
|
|
201
212
|
/**
|
|
202
213
|
* ONE enabled provider of the store's chain (FUT-563), as the buyer's config
|
|
203
214
|
* answers it. The same protocol facts as the head, stated per provider —
|
|
@@ -209,6 +220,22 @@ export interface CheckoutChainLink {
|
|
|
209
220
|
publicKey: string | null;
|
|
210
221
|
mockTokenization: boolean;
|
|
211
222
|
methods: ("PIX" | "CARD" | "BOLETO")[];
|
|
223
|
+
/**
|
|
224
|
+
* What this provider needs to know about the buyer (FUT-595).
|
|
225
|
+
*
|
|
226
|
+
* `GET /api/checkout/config` has published this since FUT-740; this mirror
|
|
227
|
+
* type simply dropped it, so the buyer form could not be honest about what
|
|
228
|
+
* the chain requires and a missing field was only discoverable as a 400 AFTER
|
|
229
|
+
* the buyer had finished filling it in — the third FUT-740 critical, one
|
|
230
|
+
* layer up.
|
|
231
|
+
*
|
|
232
|
+
* Optional, and the DEGRADE DIRECTION IS LOAD-BEARING: absent (an older host,
|
|
233
|
+
* a hand-written config) must mean today's behaviour — CPF required — never
|
|
234
|
+
* "this chain asks for nothing". Asking for nothing produces a form the buyer
|
|
235
|
+
* completes and a charge the server then refuses for a document they were
|
|
236
|
+
* never shown a field for.
|
|
237
|
+
*/
|
|
238
|
+
customerSchema?: CheckoutCustomerField[];
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
/**
|
|
@@ -145,7 +145,18 @@ const CheckoutComponentsContext = createContext<CheckoutComponents>(defaultCheck
|
|
|
145
145
|
/**
|
|
146
146
|
* Fills the checkout's component slots for everything rendered beneath it.
|
|
147
147
|
* Partial on purpose: a host overrides only the slots its design system
|
|
148
|
-
* covers and inherits the
|
|
148
|
+
* covers and inherits the rest.
|
|
149
|
+
*
|
|
150
|
+
* Inherits from the NEAREST provider above, not from the raw-MUI defaults.
|
|
151
|
+
* That distinction is the whole contract once these nest — and they do nest:
|
|
152
|
+
* `createPaymentFlows` fills the slots once at the factory, and the
|
|
153
|
+
* `CheckoutFlow` inside its mount opens a provider of its own. Merging over
|
|
154
|
+
* the defaults there re-imposed raw MUI on a host that had already stated its
|
|
155
|
+
* design system, silently, one level in. Merging over the inherited set makes
|
|
156
|
+
* a nested provider ADD to what is above it, which is what "partial" has to
|
|
157
|
+
* mean for the surface to agree with itself. The context's own default is
|
|
158
|
+
* still `defaultCheckoutComponents`, so the outermost provider is unchanged:
|
|
159
|
+
* an unfilled slot is raw MUI exactly as before.
|
|
149
160
|
*/
|
|
150
161
|
export function CheckoutComponentsProvider({
|
|
151
162
|
components,
|
|
@@ -154,9 +165,10 @@ export function CheckoutComponentsProvider({
|
|
|
154
165
|
components?: Partial<CheckoutComponents>;
|
|
155
166
|
children: ReactNode;
|
|
156
167
|
}): JSX.Element {
|
|
168
|
+
const inherited = useCheckoutComponents();
|
|
157
169
|
const value = useMemo<CheckoutComponents>(
|
|
158
|
-
() => ({ ...
|
|
159
|
-
[components],
|
|
170
|
+
() => ({ ...inherited, ...components }),
|
|
171
|
+
[inherited, components],
|
|
160
172
|
);
|
|
161
173
|
return (
|
|
162
174
|
<CheckoutComponentsContext.Provider value={value}>
|
|
@@ -17,13 +17,14 @@ import {
|
|
|
17
17
|
} from "../../card";
|
|
18
18
|
import { ok, type Result } from "../../result";
|
|
19
19
|
|
|
20
|
-
import { resolveNewCardToken, type CardInstruments } from "./card-instruments";
|
|
21
20
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from "./
|
|
21
|
+
resolveNewCardToken,
|
|
22
|
+
type CardInstruments,
|
|
23
|
+
type RefreshBrowserKey,
|
|
24
|
+
} from "./card-instruments";
|
|
25
|
+
import { useCheckoutClientApi } from "./client-context";
|
|
26
26
|
import { rememberHostedOrder } from "./hosted-return";
|
|
27
|
+
import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
|
|
27
28
|
import type { CardChainLink } from "./method-capability";
|
|
28
29
|
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
29
30
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
@@ -43,10 +44,11 @@ function useSavedCards(tenantSlug: string | undefined): {
|
|
|
43
44
|
} {
|
|
44
45
|
const [savedCards, setSavedCards] = useState<SavedCard[]>([]);
|
|
45
46
|
const [selection, setSelection] = useState<string>(NEW_CARD);
|
|
47
|
+
const client = useCheckoutClientApi();
|
|
46
48
|
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
let active = true;
|
|
49
|
-
void
|
|
51
|
+
void client.listInstruments(tenantSlug).then((cards) => {
|
|
50
52
|
if (!active) return;
|
|
51
53
|
setSavedCards(cards);
|
|
52
54
|
const first = cards[0];
|
|
@@ -55,7 +57,7 @@ function useSavedCards(tenantSlug: string | undefined): {
|
|
|
55
57
|
return () => {
|
|
56
58
|
active = false;
|
|
57
59
|
};
|
|
58
|
-
}, [tenantSlug]);
|
|
60
|
+
}, [tenantSlug, client]);
|
|
59
61
|
|
|
60
62
|
return { savedCards, selection, setSelection };
|
|
61
63
|
}
|
|
@@ -76,19 +78,20 @@ function useCardPublicKey(
|
|
|
76
78
|
setPublicKey: (key: string) => void;
|
|
77
79
|
} {
|
|
78
80
|
const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
|
|
81
|
+
const client = useCheckoutClientApi();
|
|
79
82
|
const refreshable =
|
|
80
83
|
config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
|
|
81
84
|
|
|
82
85
|
useEffect(() => {
|
|
83
86
|
if (config.publicKey !== null || !refreshable) return undefined;
|
|
84
87
|
let active = true;
|
|
85
|
-
void
|
|
88
|
+
void client.refreshBrowserKey({ orderId }).then((result) => {
|
|
86
89
|
if (active && result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
|
|
87
90
|
});
|
|
88
91
|
return () => {
|
|
89
92
|
active = false;
|
|
90
93
|
};
|
|
91
|
-
}, [orderId, config.publicKey, refreshable]);
|
|
94
|
+
}, [orderId, config.publicKey, refreshable, client]);
|
|
92
95
|
|
|
93
96
|
return { publicKey, setPublicKey };
|
|
94
97
|
}
|
|
@@ -151,9 +154,16 @@ const CARD_AWAITING_POLL_CAP = 36;
|
|
|
151
154
|
* redirect provider's link takes (FUT-556): the return lands back on this
|
|
152
155
|
* checkout route, where the hosted-resume machinery polls the parked order.
|
|
153
156
|
*/
|
|
154
|
-
function handOverToChallenge(
|
|
157
|
+
function handOverToChallenge(
|
|
158
|
+
order: CheckoutOrder,
|
|
159
|
+
url: string,
|
|
160
|
+
navigate: CheckoutNavigate,
|
|
161
|
+
): void {
|
|
162
|
+
// PARK FIRST. The navigation may not come back to a live SPA at all, and a
|
|
163
|
+
// return trip that finds nothing parked lands the buyer on a blank
|
|
164
|
+
// confirmation after they have paid.
|
|
155
165
|
rememberHostedOrder(order);
|
|
156
|
-
|
|
166
|
+
navigate(url);
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
/** The buyer's card is invalid — block the submit and show which field. */
|
|
@@ -178,6 +188,8 @@ async function resolveInstruments(input: {
|
|
|
178
188
|
config: CardTokenizationConfig;
|
|
179
189
|
onKeyRefreshed: (key: string) => void;
|
|
180
190
|
providerChain: readonly CardChainLink[];
|
|
191
|
+
/** The bound key-refresh call (FUT-741) — the self-heal must hit OUR mount. */
|
|
192
|
+
refreshKey: RefreshBrowserKey;
|
|
181
193
|
}): Promise<Result<CardInstruments>> {
|
|
182
194
|
const { form } = input;
|
|
183
195
|
if (!form.usingNewCard) return ok({ token: form.selection });
|
|
@@ -188,6 +200,8 @@ async function resolveInstruments(input: {
|
|
|
188
200
|
input.onKeyRefreshed,
|
|
189
201
|
form.saveCard,
|
|
190
202
|
input.providerChain,
|
|
203
|
+
undefined,
|
|
204
|
+
input.refreshKey,
|
|
191
205
|
);
|
|
192
206
|
}
|
|
193
207
|
|
|
@@ -212,6 +226,8 @@ function useCardSubmit(
|
|
|
212
226
|
// The active key: resolved by the checkout config, overridden by the
|
|
213
227
|
// rotated-key self-heal for the rest of the session.
|
|
214
228
|
const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
|
|
229
|
+
const client = useCheckoutClientApi();
|
|
230
|
+
const navigate = useCheckoutNavigate();
|
|
215
231
|
|
|
216
232
|
const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
|
|
217
233
|
enabled: submitted,
|
|
@@ -235,6 +251,7 @@ function useCardSubmit(
|
|
|
235
251
|
config: { ...providerConfig, publicKey },
|
|
236
252
|
onKeyRefreshed: setPublicKey,
|
|
237
253
|
providerChain,
|
|
254
|
+
refreshKey: client.refreshBrowserKey,
|
|
238
255
|
});
|
|
239
256
|
if (!resolved.ok) {
|
|
240
257
|
setError(resolved.error);
|
|
@@ -242,7 +259,7 @@ function useCardSubmit(
|
|
|
242
259
|
return;
|
|
243
260
|
}
|
|
244
261
|
|
|
245
|
-
const charged = await
|
|
262
|
+
const charged = await client.charge({
|
|
246
263
|
orderId: order.orderId,
|
|
247
264
|
token: resolved.data.token,
|
|
248
265
|
tokensByProvider: resolved.data.tokensByProvider,
|
|
@@ -261,7 +278,7 @@ function useCardSubmit(
|
|
|
261
278
|
// 3-D Secure (FUT-698): the buyer must finish on the provider's page.
|
|
262
279
|
// `submitting` stays true on purpose — the tab is navigating away.
|
|
263
280
|
if (charged.data.hostedCheckoutUrl) {
|
|
264
|
-
return handOverToChallenge(order, charged.data.hostedCheckoutUrl);
|
|
281
|
+
return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate);
|
|
265
282
|
}
|
|
266
283
|
// A business outcome (e.g. declined → FAILED) shows the status screen;
|
|
267
284
|
// an accepted charge begins polling for the async confirmation.
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { buyerFormComplete } from "./buyer-info-form";
|
|
4
4
|
|
|
5
5
|
import { rememberHostedOrder, takeHostedOrder } from "./hosted-return";
|
|
6
|
+
import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
|
|
6
7
|
import type {
|
|
7
8
|
BuyerContact,
|
|
8
9
|
BuyerField,
|
|
9
10
|
BuyerInfo,
|
|
10
11
|
CheckoutOrder,
|
|
11
12
|
CreateOrderRequest,
|
|
13
|
+
CheckoutCustomerField,
|
|
12
14
|
CreateOrderResult,
|
|
13
15
|
OrderStatus,
|
|
14
16
|
PaymentMethod,
|
|
@@ -19,6 +21,11 @@ type Step = "dados" | "payment" | "status";
|
|
|
19
21
|
|
|
20
22
|
const STEP_ORDER: Step[] = ["dados", "payment", "status"];
|
|
21
23
|
|
|
24
|
+
/** The pre-FUT-595 demand, and the safe reading of a chain that declared none. */
|
|
25
|
+
const CPF_ONLY: readonly CheckoutCustomerField[] = [
|
|
26
|
+
{ key: "taxId", type: "CPF", required: true },
|
|
27
|
+
];
|
|
28
|
+
|
|
22
29
|
/**
|
|
23
30
|
* Everything the flow needs FROM its host, as explicit ports (FUT-564). The
|
|
24
31
|
* package owns the payment surface; the host owns the cart, the catalog,
|
|
@@ -81,19 +88,49 @@ function useCheckoutNav(
|
|
|
81
88
|
return { back, editBuyer: taxIdOnFile ? openDados : undefined };
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
/** The message shown when a declared field is missing or malformed. */
|
|
92
|
+
const FIELD_COMPLAINT: Record<string, string> = {
|
|
93
|
+
taxId: "CPF inválido.",
|
|
94
|
+
name: "Informe seu nome.",
|
|
95
|
+
email: "E-mail inválido.",
|
|
96
|
+
phone: "Telefone inválido.",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Which input to highlight for a declared key. */
|
|
100
|
+
const FIELD_INPUT: Record<string, BuyerField> = {
|
|
101
|
+
taxId: "cpf",
|
|
102
|
+
name: "name",
|
|
103
|
+
email: "email",
|
|
104
|
+
phone: "phone",
|
|
105
|
+
};
|
|
106
|
+
|
|
84
107
|
/**
|
|
85
|
-
* What the "Continuar" gate objects to
|
|
108
|
+
* What the "Continuar" gate objects to, or undefined to advance.
|
|
86
109
|
*
|
|
87
|
-
*
|
|
110
|
+
* The fields come from the chain's own declaration (FUT-595) — absent, they
|
|
111
|
+
* degrade to CPF-required, which is exactly what this gate has always demanded.
|
|
112
|
+
*
|
|
113
|
+
* A blank CPF is only an error when the store has NO CPF for this buyer. With
|
|
88
114
|
* one on file the field starts empty by design (the client is never sent the
|
|
89
115
|
* saved CPF), so demanding one here trapped a returning buyer who opened Dados
|
|
90
116
|
* through "Alterar" and changed their mind: they could not reach Pagamento
|
|
91
117
|
* again, and back only led out to the menu. Leaving it blank means "charge me
|
|
92
118
|
* as before", which is exactly what the server's `resolveBuyerTaxId` does.
|
|
93
119
|
*/
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
function buyerGateError(
|
|
121
|
+
buyer: BuyerInfo,
|
|
122
|
+
fields: readonly CheckoutCustomerField[],
|
|
123
|
+
taxIdOnFile: boolean,
|
|
124
|
+
): { message: string; field: BuyerField } | undefined {
|
|
125
|
+
const effective = taxIdOnFile
|
|
126
|
+
? fields.filter((field) => !(field.key === "taxId" && !buyer.taxId?.trim()))
|
|
127
|
+
: fields;
|
|
128
|
+
const offending = buyerFormComplete(buyer, effective);
|
|
129
|
+
if (!offending) return undefined;
|
|
130
|
+
return {
|
|
131
|
+
message: FIELD_COMPLAINT[offending.key] ?? "Campo obrigatório.",
|
|
132
|
+
field: FIELD_INPUT[offending.key] ?? "cpf",
|
|
133
|
+
};
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
/**
|
|
@@ -119,10 +156,13 @@ function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
|
|
|
119
156
|
*
|
|
120
157
|
* @returns true when the buyer is on their way and the caller must stop.
|
|
121
158
|
*/
|
|
122
|
-
function handOverToProvider(order: CheckoutOrder): boolean {
|
|
159
|
+
function handOverToProvider(order: CheckoutOrder, navigate: CheckoutNavigate): boolean {
|
|
123
160
|
if (!order.hostedCheckoutUrl) return false;
|
|
161
|
+
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
162
|
+
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
163
|
+
// any later write lands.
|
|
124
164
|
rememberHostedOrder(order);
|
|
125
|
-
|
|
165
|
+
navigate(order.hostedCheckoutUrl);
|
|
126
166
|
return true;
|
|
127
167
|
}
|
|
128
168
|
|
|
@@ -197,20 +237,24 @@ function useCreateFailure() {
|
|
|
197
237
|
* being saved.
|
|
198
238
|
* - `startPayment` raises the order (PIX → QR, CARD → chargeable); `payWithEmail`
|
|
199
239
|
* re-raises with a corrected e-mail for the merchant-email rejection.
|
|
240
|
+
*
|
|
241
|
+
* @param taxIdOnFile The buyer already has a CPF saved (FUT-465) ⇒ the Dados
|
|
242
|
+
* step has nothing left to ask, so checkout opens on Pagamento and `back`
|
|
243
|
+
* goes to the menu rather than to a form the buyer never saw. The CPF itself
|
|
244
|
+
* is never in the client's hands — the server reads it from the encrypted
|
|
245
|
+
* profile when the charge is raised.
|
|
246
|
+
* @param buyerFields What the store's chain declares it needs from the buyer
|
|
247
|
+
* (FUT-595). Absent ⇒ CPF-required, which is what this gate demanded before
|
|
248
|
+
* there was a declaration to read — never "ask nothing".
|
|
200
249
|
*/
|
|
201
250
|
export function useCheckoutController(
|
|
202
251
|
ports: CheckoutHostPorts,
|
|
203
252
|
defaultBuyer?: BuyerInfo,
|
|
204
|
-
/**
|
|
205
|
-
* The buyer already has a CPF saved (FUT-465) ⇒ the Dados step has nothing
|
|
206
|
-
* left to ask, so checkout opens on Pagamento and `back` goes to the menu
|
|
207
|
-
* rather than to a form the buyer never saw. The CPF itself is never in the
|
|
208
|
-
* client's hands — the server reads it from the encrypted profile when the
|
|
209
|
-
* charge is raised.
|
|
210
|
-
*/
|
|
211
253
|
taxIdOnFile = false,
|
|
254
|
+
buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
|
|
212
255
|
) {
|
|
213
256
|
const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
|
|
257
|
+
const navigate = useCheckoutNavigate();
|
|
214
258
|
const resume = useHostedResume();
|
|
215
259
|
const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
|
|
216
260
|
// No method pre-selected: the Pagamento step shows just the picker until the
|
|
@@ -232,8 +276,8 @@ export function useCheckoutController(
|
|
|
232
276
|
}, [clearError]);
|
|
233
277
|
const goToPayment = useCallback(() => {
|
|
234
278
|
clearError();
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
279
|
+
const complaint = buyerGateError(buyer, buyerFields, taxIdOnFile);
|
|
280
|
+
if (complaint) { failure.fail(complaint); return; }
|
|
237
281
|
// Persist the buyer's details HERE, on "Continuar" — not when a payment is
|
|
238
282
|
// raised. Everything after this step can fail (no provider configured, a
|
|
239
283
|
// declined card, an abandoned PIX, a closed tab) and the details must
|
|
@@ -245,17 +289,17 @@ export function useCheckoutController(
|
|
|
245
289
|
saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
|
|
246
290
|
}
|
|
247
291
|
setStep("payment");
|
|
248
|
-
}, [buyer
|
|
292
|
+
}, [buyer, buyerFields, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
|
|
249
293
|
const startPayment = useCallback(async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
250
294
|
clearError();
|
|
251
295
|
setCreating(true);
|
|
252
296
|
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
253
297
|
setCreating(false);
|
|
254
298
|
if (!result.ok) { failure.fail(result.error); return; }
|
|
255
|
-
if (handOverToProvider(result.data)) return;
|
|
299
|
+
if (handOverToProvider(result.data, navigate)) return;
|
|
256
300
|
setOrder(result.data);
|
|
257
301
|
setFinalStatus(null);
|
|
258
|
-
}, [buyer, saveProfile, createOrder, clearError]);
|
|
302
|
+
}, [buyer, saveProfile, createOrder, clearError, navigate]);
|
|
259
303
|
const payWithEmail = useCallback((email: string) => {
|
|
260
304
|
if (!method) return;
|
|
261
305
|
const next = { ...buyer, email };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useCheckoutClientApi } from "./client-context";
|
|
4
4
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
5
5
|
|
|
6
6
|
interface PollingOptions {
|
|
@@ -34,6 +34,10 @@ export function usePaymentPolling(
|
|
|
34
34
|
const [status, setStatus] = useState<OrderStatus | null>(null);
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
const [timedOut, setTimedOut] = useState(false);
|
|
37
|
+
// Whichever mount this tree is bound to (FUT-741). Stable per provider, so it
|
|
38
|
+
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
39
|
+
// re-pointed at another mount must re-poll against THAT one.
|
|
40
|
+
const client = useCheckoutClientApi();
|
|
37
41
|
|
|
38
42
|
useEffect(() => {
|
|
39
43
|
if (!orderId || !enabled) {
|
|
@@ -48,7 +52,7 @@ export function usePaymentPolling(
|
|
|
48
52
|
setError(null);
|
|
49
53
|
|
|
50
54
|
const tick = async (): Promise<void> => {
|
|
51
|
-
const result = await
|
|
55
|
+
const result = await client.getStatus(orderId);
|
|
52
56
|
if (cancelled) {
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
@@ -87,7 +91,7 @@ export function usePaymentPolling(
|
|
|
87
91
|
clearTimeout(timer);
|
|
88
92
|
}
|
|
89
93
|
};
|
|
90
|
-
}, [orderId, intervalMs, enabled, maxHealthyPolls]);
|
|
94
|
+
}, [orderId, intervalMs, enabled, maxHealthyPolls, client]);
|
|
91
95
|
|
|
92
96
|
return { status, error, timedOut };
|
|
93
97
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentences `createPaymentFlows` renders on its OWN screens (FUT-741).
|
|
3
|
+
*
|
|
4
|
+
* Scope is deliberately narrow and stated rather than implied: this covers the
|
|
5
|
+
* copy the FACTORY owns — the unavailable screen's two remedies, the hosted
|
|
6
|
+
* handover and its fallback link, the empty cart, and the buyer form's continue
|
|
7
|
+
* action. The screens that already carried their own product copy before this
|
|
8
|
+
* ticket (PIX, card, status) keep it; moving all of it here in the same change
|
|
9
|
+
* that introduces the factory would be a copy rewrite disguised as an API.
|
|
10
|
+
*
|
|
11
|
+
* Every default below is today's pt-BR, verbatim — a host that passes no `copy`
|
|
12
|
+
* reads exactly what a buyer reads now.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Every buyer-facing string the factory's own screens render. */
|
|
16
|
+
export interface CheckoutCopyFE {
|
|
17
|
+
/** No provider connected AND no host remedy: the store simply does not charge. */
|
|
18
|
+
unavailableTitle: string;
|
|
19
|
+
unavailableBody: string;
|
|
20
|
+
/** No provider connected but the host offers a remedy (this repo: the waiter). */
|
|
21
|
+
unavailableWithRemedyTitle: string;
|
|
22
|
+
unavailableWithRemedyBody: string;
|
|
23
|
+
/** The hosted handover interstitial, and the link that is its fallback. */
|
|
24
|
+
handoffTitle: string;
|
|
25
|
+
handoffBody: string;
|
|
26
|
+
handoffLink: string;
|
|
27
|
+
handoffCancel: string;
|
|
28
|
+
/** The return leg, while the parked order is polled to a terminal state. */
|
|
29
|
+
returnPending: string;
|
|
30
|
+
/** The return leg when nothing was parked — the buyer came back to nothing. */
|
|
31
|
+
returnUnknown: string;
|
|
32
|
+
/** Nothing to check out (cart mode only). */
|
|
33
|
+
emptyCartTitle: string;
|
|
34
|
+
emptyCartAction: string;
|
|
35
|
+
/** The Dados step's primary action. */
|
|
36
|
+
continueAction: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CHECKOUT_COPY_FE: CheckoutCopyFE = {
|
|
40
|
+
unavailableTitle: "Pagamento online indisponível",
|
|
41
|
+
unavailableBody:
|
|
42
|
+
"Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido.",
|
|
43
|
+
unavailableWithRemedyTitle: "Pagamento com o garçom",
|
|
44
|
+
unavailableWithRemedyBody:
|
|
45
|
+
"Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa.",
|
|
46
|
+
handoffTitle: "Você será levado ao pagamento",
|
|
47
|
+
handoffBody:
|
|
48
|
+
"Estamos abrindo a página segura do meio de pagamento. Se ela não abrir sozinha, use o link abaixo.",
|
|
49
|
+
handoffLink: "Abrir a página de pagamento",
|
|
50
|
+
handoffCancel: "Voltar",
|
|
51
|
+
returnPending: "Confirmando seu pagamento…",
|
|
52
|
+
returnUnknown:
|
|
53
|
+
"Não encontramos um pagamento em andamento nesta sessão. Verifique seus pedidos em instantes.",
|
|
54
|
+
emptyCartTitle: "Seu carrinho está vazio.",
|
|
55
|
+
emptyCartAction: "Ver cardápio",
|
|
56
|
+
continueAction: "Continuar",
|
|
57
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPaymentFlows` — the buyer checkout, mounted rather than composed
|
|
3
|
+
* (FUT-741).
|
|
4
|
+
*
|
|
5
|
+
* Called ONCE at module scope; the host mounts what comes back. See
|
|
6
|
+
* `./types.ts` for the config vocabulary and why none of it names a vendor.
|
|
7
|
+
*
|
|
8
|
+
* ## Why the scope arrives as HOOKS
|
|
9
|
+
*
|
|
10
|
+
* `useScope`, `useCart`, `useBuyerDefaults`, `useComanda` and
|
|
11
|
+
* `ports.useAvailability` are hooks, not values, and they are invoked in a
|
|
12
|
+
* component BODY — never read at factory time. The factory runs once, at module
|
|
13
|
+
* evaluation, so a value-shaped config would freeze the first store's slug and
|
|
14
|
+
* one moment's cart total onto every checkout the page ever renders. Naming
|
|
15
|
+
* them `use*` also keeps rules-of-hooks lint able to see them.
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, type JSX, type ReactNode } from "react";
|
|
18
|
+
|
|
19
|
+
import { buyerFieldsFor } from "../components/checkout/buyer-fields";
|
|
20
|
+
import { CheckoutFlow } from "../components/checkout/checkout-flow";
|
|
21
|
+
import { createCheckoutClient } from "../components/checkout/transport";
|
|
22
|
+
import type { CheckoutProviderConfig, ComandaCheckout } from "../components/checkout/types";
|
|
23
|
+
import { useCheckoutController } from "../components/checkout/use-checkout-controller";
|
|
24
|
+
|
|
25
|
+
import { DEFAULT_CHECKOUT_COPY_FE } from "./copy";
|
|
26
|
+
import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
|
|
27
|
+
import { buyerScreens } from "./screens-buyer";
|
|
28
|
+
import { hostedScreens } from "./screens-hosted";
|
|
29
|
+
import { payScreens, storeCannotCharge } from "./screens-pay";
|
|
30
|
+
import type {
|
|
31
|
+
CheckoutAvailability,
|
|
32
|
+
CheckoutController,
|
|
33
|
+
CheckoutScreens,
|
|
34
|
+
PaymentFlows,
|
|
35
|
+
PaymentFlowsConfig,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
/** A store that is always payable — what a host with no veto to cast means. */
|
|
39
|
+
const ALWAYS_PAYABLE: CheckoutAvailability = { payable: true };
|
|
40
|
+
|
|
41
|
+
/** Build the runtime every screen closes over. */
|
|
42
|
+
function buildRuntime(config: PaymentFlowsConfig): FlowsRuntime {
|
|
43
|
+
const client = createCheckoutClient(config.transport);
|
|
44
|
+
const navigate =
|
|
45
|
+
config.ports.navigate ??
|
|
46
|
+
((url: string) => {
|
|
47
|
+
window.location.assign(url);
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
config,
|
|
51
|
+
client,
|
|
52
|
+
copy: { ...DEFAULT_CHECKOUT_COPY_FE, ...config.copy },
|
|
53
|
+
navigate,
|
|
54
|
+
// Both of these are HOOKS. They are called from a component body on every
|
|
55
|
+
// render, so the slug follows the host's router and the availability vote
|
|
56
|
+
// follows whatever the host currently knows.
|
|
57
|
+
useTenantSlug: () => config.useScope?.().tenantSlug,
|
|
58
|
+
useAvailability: () => config.ports.useAvailability?.() ?? ALWAYS_PAYABLE,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The whole three-step flow, with the config fetched and availability decided. */
|
|
63
|
+
function buildCheckout(
|
|
64
|
+
runtime: FlowsRuntime,
|
|
65
|
+
screens: CheckoutScreens,
|
|
66
|
+
): PaymentFlows["Checkout"] {
|
|
67
|
+
const { ports } = runtime.config;
|
|
68
|
+
const Unavailable = screens.PaymentsUnavailable;
|
|
69
|
+
|
|
70
|
+
function CheckoutBody({ comanda }: { comanda?: ComandaCheckout | null }): JSX.Element {
|
|
71
|
+
const cart = runtime.config.useCart();
|
|
72
|
+
const defaults = runtime.config.useBuyerDefaults?.() ?? {};
|
|
73
|
+
const hostComanda = runtime.config.useComanda?.() ?? null;
|
|
74
|
+
const { config, pending } = useResolvedConfig(runtime);
|
|
75
|
+
const availability = runtime.useAvailability();
|
|
76
|
+
const tenantSlug = runtime.useTenantSlug();
|
|
77
|
+
// Adapts the port to the package's older, prop-shaped contract. Memoised so
|
|
78
|
+
// the controller does not see a fresh `createOrder` identity each render.
|
|
79
|
+
const createOrder = useCallback(
|
|
80
|
+
(input: Parameters<typeof ports.createPayable>[0]) => ports.createPayable(input),
|
|
81
|
+
[],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (storeCannotCharge(config, pending, availability.payable)) return <Unavailable />;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<CheckoutFlow
|
|
88
|
+
cart={cart}
|
|
89
|
+
createOrder={createOrder}
|
|
90
|
+
saveBuyerContact={ports.saveBuyerContact}
|
|
91
|
+
onExitToMenu={ports.exitToCatalog}
|
|
92
|
+
onPaid={ports.onPaid}
|
|
93
|
+
defaultBuyer={defaults.buyer}
|
|
94
|
+
taxIdOnFile={defaults.taxIdOnFile ?? false}
|
|
95
|
+
comanda={comanda ?? hostComanda}
|
|
96
|
+
providerConfig={config}
|
|
97
|
+
tenantSlug={tenantSlug}
|
|
98
|
+
confirmationExtra={runtime.config.confirmation?.extra}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return function Checkout(props) {
|
|
104
|
+
// Its OWN provider, so the one-line mount really is one line: `/config` is
|
|
105
|
+
// fetched here and every nested screen reads that answer.
|
|
106
|
+
//
|
|
107
|
+
// The design-system slots come from the `FlowsShell` inside it, and the
|
|
108
|
+
// `CheckoutComponentsProvider` that `CheckoutFlow` opens one level down
|
|
109
|
+
// INHERITS them (see `ui.tsx`) rather than resetting to raw MUI — which is
|
|
110
|
+
// why nothing here re-threads `components`.
|
|
111
|
+
return (
|
|
112
|
+
<FlowsProvider runtime={runtime}>
|
|
113
|
+
<CheckoutBody {...props} />
|
|
114
|
+
</FlowsProvider>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Assemble the eleven screens from their builders. */
|
|
120
|
+
function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
|
|
121
|
+
return {
|
|
122
|
+
MethodChoice: buyerScreens.buildMethodChoice(runtime),
|
|
123
|
+
BuyerDetails: buyerScreens.buildBuyerDetails(runtime),
|
|
124
|
+
CardEntry: payScreens.buildCardEntry(runtime),
|
|
125
|
+
PixPayment: payScreens.buildPixPayment(runtime),
|
|
126
|
+
HostedHandoff: hostedScreens.buildHostedHandoff(runtime),
|
|
127
|
+
HostedReturn: hostedScreens.buildHostedReturn(runtime),
|
|
128
|
+
PaymentStatus: payScreens.buildPaymentStatus(runtime),
|
|
129
|
+
PaymentsUnavailable: payScreens.buildPaymentsUnavailable(runtime),
|
|
130
|
+
PayerSummary: buyerScreens.buildPayerSummary(runtime),
|
|
131
|
+
SavedCards: buyerScreens.buildSavedCards(runtime),
|
|
132
|
+
EmptyCart: buyerScreens.buildEmptyCart(runtime),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The flow controller, pre-bound to the ports and the chain's declaration. */
|
|
137
|
+
function buildUseCheckout(runtime: FlowsRuntime): () => CheckoutController {
|
|
138
|
+
const { ports } = runtime.config;
|
|
139
|
+
return function useCheckout(): CheckoutController {
|
|
140
|
+
const { config } = useResolvedConfig(runtime);
|
|
141
|
+
const defaults = runtime.config.useBuyerDefaults?.() ?? {};
|
|
142
|
+
return useCheckoutController(
|
|
143
|
+
{
|
|
144
|
+
createOrder: ports.createPayable,
|
|
145
|
+
saveBuyerContact: ports.saveBuyerContact,
|
|
146
|
+
onExitToMenu: ports.exitToCatalog,
|
|
147
|
+
onPaid: ports.onPaid,
|
|
148
|
+
},
|
|
149
|
+
defaults.buyer,
|
|
150
|
+
defaults.taxIdOnFile ?? false,
|
|
151
|
+
// Resolved for NO method: the gate runs on the Dados step, before the
|
|
152
|
+
// picker, and FUT-595's rule is to collect the union up front.
|
|
153
|
+
buyerFieldsFor(config?.chain, null),
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The buyer checkout, pre-bound.
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* export const components = createPaymentFlows({ useCart, ports: { … } });
|
|
163
|
+
* // …and in the page: <components.Checkout />
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* The flat exports (`CheckoutFlow`, the hooks, the fetch clients) are
|
|
167
|
+
* unchanged and remain the escape hatch for a host that wants its own
|
|
168
|
+
* composition — nothing here replaces them.
|
|
169
|
+
*/
|
|
170
|
+
export function createPaymentFlows(config: PaymentFlowsConfig): PaymentFlows {
|
|
171
|
+
const runtime = buildRuntime(config);
|
|
172
|
+
const screens = buildScreens(runtime);
|
|
173
|
+
|
|
174
|
+
function Provider({
|
|
175
|
+
children,
|
|
176
|
+
config: provided,
|
|
177
|
+
}: {
|
|
178
|
+
children: ReactNode;
|
|
179
|
+
config?: CheckoutProviderConfig | null;
|
|
180
|
+
}): JSX.Element {
|
|
181
|
+
return (
|
|
182
|
+
<FlowsProvider runtime={runtime} config={provided}>
|
|
183
|
+
{children}
|
|
184
|
+
</FlowsProvider>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
Checkout: buildCheckout(runtime, screens),
|
|
190
|
+
Provider,
|
|
191
|
+
screens,
|
|
192
|
+
useCheckout: buildUseCheckout(runtime),
|
|
193
|
+
useCheckoutConfig: () => useResolvedConfig(runtime),
|
|
194
|
+
client: runtime.client,
|
|
195
|
+
};
|
|
196
|
+
}
|