@12-apps/payments-frontend 1.7.1 → 1.8.1
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 +60 -10
- 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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkout's HTTP transport, as a bound client (FUT-741).
|
|
3
|
+
*
|
|
4
|
+
* Everything `client.ts` used to do with a hard-coded prefix and the ambient
|
|
5
|
+
* `fetch` now lives here behind {@link createCheckoutClient}, so the same five
|
|
6
|
+
* calls can be pointed at a different mount, carry a host's auth headers, or —
|
|
7
|
+
* the reason this exists — be driven through an injected `fetch` that routes
|
|
8
|
+
* straight into a real `createPaymentFlowsBE` mount. A story or a harness page
|
|
9
|
+
* can then exercise the WIRE rather than a mock of our own client, which is the
|
|
10
|
+
* only place the FUT-740 review found its three criticals.
|
|
11
|
+
*
|
|
12
|
+
* `baseUrl` defaults to `/api/checkout` VERBATIM — not normalized, not
|
|
13
|
+
* re-derived. The published client already posts those exact paths, and both
|
|
14
|
+
* pinned wire suites drive them; a prefix that is "cleaned up" here breaks the
|
|
15
|
+
* shipped contract in the same release that introduces the factory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SavedCard } from "../../card";
|
|
19
|
+
import { err, ok, type Result } from "../../result";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
ChargeCardInput,
|
|
23
|
+
ChargeOutcome,
|
|
24
|
+
CheckoutProviderConfig,
|
|
25
|
+
OrderStatus,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The prefix every shipped buyer checkout posts to today. Exported so a host
|
|
30
|
+
* (or a test) can state it rather than re-type it, and so a change to it is a
|
|
31
|
+
* change to one named constant with this comment attached.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
|
|
34
|
+
|
|
35
|
+
/** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
|
|
36
|
+
export interface CheckoutTransport {
|
|
37
|
+
/** Prefix for `/config`, `/status`, `/charge`, `/cards`, `/refresh-key`. */
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
|
|
41
|
+
* suite that stubs the global after the client was built still sees its stub.
|
|
42
|
+
*/
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
/** Extra headers per request (a bearer token, a tenant header). */
|
|
45
|
+
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
|
|
49
|
+
export interface CheckoutClient {
|
|
50
|
+
getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
|
|
51
|
+
getStatus(ref: string): Promise<Result<OrderStatus>>;
|
|
52
|
+
charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
|
|
53
|
+
listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
|
|
54
|
+
refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
|
|
58
|
+
interface ApiEnvelope<T> {
|
|
59
|
+
data?: T;
|
|
60
|
+
error?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Stable machine code for the failure (`checkoutErrorResponse` always sends
|
|
63
|
+
* one). Carried through so a surface can PRESENT a refusal for what it is —
|
|
64
|
+
* an unresolved charge is not a decline, and rendering it under "não foi
|
|
65
|
+
* possível pagar" with a live pay button invites the second payment its own
|
|
66
|
+
* text forbids.
|
|
67
|
+
*/
|
|
68
|
+
code?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
|
|
73
|
+
* The message is what the buyer reads; the code is what a surface uses to
|
|
74
|
+
* decide how to PRESENT it, which a message cannot be parsed for.
|
|
75
|
+
*/
|
|
76
|
+
function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
|
|
77
|
+
return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a hosted checkout appended to the return URL when it sent the buyer
|
|
82
|
+
* back, or undefined.
|
|
83
|
+
*
|
|
84
|
+
* InfinitePay's `payment_check` refuses to confirm without BOTH the
|
|
85
|
+
* `transaction_nsu` and the invoice `slug`, and neither exists until somebody
|
|
86
|
+
* has actually paid — they arrive here, on the redirect. Left in the URL: the
|
|
87
|
+
* server treats them as hints, so re-sending them on later polls is harmless.
|
|
88
|
+
*
|
|
89
|
+
* Note the buyer has to press "Continuar" on the provider's receipt for this
|
|
90
|
+
* redirect to happen at all, which is exactly why it is a hint and never the
|
|
91
|
+
* mechanism: the webhook, and the server-side reconciliation behind it, are
|
|
92
|
+
* what must work when they simply close the tab.
|
|
93
|
+
*/
|
|
94
|
+
function returnedSettlement(): Record<string, string> {
|
|
95
|
+
if (typeof window === "undefined") return {};
|
|
96
|
+
const params = new URLSearchParams(window.location.search);
|
|
97
|
+
// BOTH. Measured against the live API: handle + order_nsu +
|
|
98
|
+
// transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
|
|
99
|
+
// same call missing EITHER answers {"success":false}. Neither exists before
|
|
100
|
+
// the payment, and the slug is not in the link-creation response — the
|
|
101
|
+
// redirect and the webhook are the only places both appear together.
|
|
102
|
+
const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
|
|
103
|
+
const slug = params.get("slug") ?? "";
|
|
104
|
+
return {
|
|
105
|
+
...(transactionNsu ? { transactionNsu } : {}),
|
|
106
|
+
...(slug ? { slug } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The ambient `fetch`, wrapped so it is never invoked detached from its global. */
|
|
111
|
+
function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
112
|
+
return globalThis.fetch(input, init);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The five checkout calls, bound to one transport.
|
|
117
|
+
*
|
|
118
|
+
* Passing no transport reproduces exactly what the free functions in
|
|
119
|
+
* `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
|
|
120
|
+
*/
|
|
121
|
+
export function createCheckoutClient(transport: CheckoutTransport = {}): CheckoutClient {
|
|
122
|
+
const baseUrl = transport.baseUrl ?? DEFAULT_CHECKOUT_BASE_URL;
|
|
123
|
+
|
|
124
|
+
/** Call a checkout route and normalize the response into a {@link Result}. */
|
|
125
|
+
async function call<T>(route: string, init?: RequestInit): Promise<Result<T>> {
|
|
126
|
+
// Resolved here, not captured at build time: a suite (or a story) that
|
|
127
|
+
// replaces the global afterwards must still be the one that answers.
|
|
128
|
+
const doFetch = transport.fetchImpl ?? ambientFetch;
|
|
129
|
+
try {
|
|
130
|
+
const extra = transport.headers ? await transport.headers() : undefined;
|
|
131
|
+
const res = await doFetch(`${baseUrl}${route}`, {
|
|
132
|
+
...init,
|
|
133
|
+
headers: { "Content-Type": "application/json", ...extra, ...init?.headers },
|
|
134
|
+
});
|
|
135
|
+
const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
|
|
136
|
+
if (!res.ok) return refused(json);
|
|
137
|
+
if (!json || json.data === undefined) {
|
|
138
|
+
return err(json?.error ?? "Resposta inválida do servidor.");
|
|
139
|
+
}
|
|
140
|
+
return ok(json.data);
|
|
141
|
+
} catch {
|
|
142
|
+
return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
getConfig: (tenantSlug) =>
|
|
148
|
+
call<CheckoutProviderConfig>(`/config?tenantSlug=${encodeURIComponent(tenantSlug)}`, {
|
|
149
|
+
method: "GET",
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
getStatus: (ref) =>
|
|
153
|
+
call<OrderStatus>(
|
|
154
|
+
`/status?${new URLSearchParams({ orderId: ref, ...returnedSettlement() }).toString()}`,
|
|
155
|
+
{ method: "GET" },
|
|
156
|
+
),
|
|
157
|
+
|
|
158
|
+
charge: (input) =>
|
|
159
|
+
call<ChargeOutcome>("/charge", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
// The FLAT body the shipped client has always sent. Pinned from both
|
|
162
|
+
// ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
orderId: input.orderId,
|
|
165
|
+
token: input.token,
|
|
166
|
+
// One instrument per provider (FUT-563) — the server hands each
|
|
167
|
+
// provider in the chain its own, which is what lets a card charge
|
|
168
|
+
// fail over.
|
|
169
|
+
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
170
|
+
saveCard: input.saveCard,
|
|
171
|
+
cardMeta: input.cardMeta,
|
|
172
|
+
taxId: input.taxId,
|
|
173
|
+
}),
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
listInstruments: async (tenantSlug) => {
|
|
177
|
+
// Scoped to the store when known (FUT-697): only cards the store's ACTIVE
|
|
178
|
+
// provider can actually charge come back — a PagBank-vaulted card is not
|
|
179
|
+
// offered for a Stone charge it would fail (or misroute).
|
|
180
|
+
const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
|
181
|
+
const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
|
|
182
|
+
return result.ok ? result.data : [];
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
refreshBrowserKey: (input) =>
|
|
186
|
+
call<{ publicKey: string | null }>("/refresh-key", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: JSON.stringify({ orderId: input.orderId }),
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -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
|
+
};
|