@12-apps/payments-frontend 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +7 -4
  2. package/src/__tests__/provider-priority-list.test.tsx +2 -2
  3. package/src/__tests__/slugged-provider.test.tsx +108 -0
  4. package/src/card/cpf.ts +42 -0
  5. package/src/card/fields.tsx +254 -0
  6. package/src/card/format.ts +103 -0
  7. package/src/card/index.ts +42 -0
  8. package/src/card/stripe-token.ts +81 -0
  9. package/src/card/tokenize.test.ts +194 -0
  10. package/src/card/tokenize.ts +327 -0
  11. package/src/card/types.ts +54 -0
  12. package/src/components/PaymentProviderSettings.tsx +30 -4
  13. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
  14. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
  15. package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
  16. package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
  17. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
  18. package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
  19. package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
  20. package/src/components/checkout/buyer-info-form.tsx +138 -0
  21. package/src/components/checkout/card-view.tsx +128 -0
  22. package/src/components/checkout/checkout-flow.tsx +201 -0
  23. package/src/components/checkout/checkout-steps.tsx +366 -0
  24. package/src/components/checkout/client.ts +157 -0
  25. package/src/components/checkout/hosted-return.ts +92 -0
  26. package/src/components/checkout/icons.tsx +61 -0
  27. package/src/components/checkout/method-capability.ts +69 -0
  28. package/src/components/checkout/method-picker.tsx +153 -0
  29. package/src/components/checkout/mui-defaults.tsx +218 -0
  30. package/src/components/checkout/payer-summary.tsx +81 -0
  31. package/src/components/checkout/payment-status.tsx +256 -0
  32. package/src/components/checkout/payments-unavailable.tsx +79 -0
  33. package/src/components/checkout/pix-view.tsx +179 -0
  34. package/src/components/checkout/types.ts +223 -0
  35. package/src/components/checkout/ui.tsx +171 -0
  36. package/src/components/checkout/use-card-checkout.ts +346 -0
  37. package/src/components/checkout/use-checkout-controller.ts +252 -0
  38. package/src/components/checkout/use-payment-polling.ts +93 -0
  39. package/src/components/settings-state.ts +45 -2
  40. package/src/index.ts +74 -1
  41. package/src/result.ts +11 -0
  42. package/src/components/CheckoutFlow.tsx +0 -169
@@ -0,0 +1,366 @@
1
+ import { Box } from "@mui/material";
2
+ import { useEffect, useRef, useState, type JSX, type ReactNode } from "react";
3
+
4
+ import { BuyerInfoForm } from "./buyer-info-form";
5
+ import { CardView } from "./card-view";
6
+ import { LockOutlinedIcon } from "./icons";
7
+ import {
8
+ cardPathAvailable,
9
+ cardTokenization,
10
+ offeredMethods,
11
+ usePreselectSoleMethod,
12
+ } from "./method-capability";
13
+ import { MethodPicker } from "./method-picker";
14
+ import { PayerSummary } from "./payer-summary";
15
+ import { PixView } from "./pix-view";
16
+ import type {
17
+ BuyerField,
18
+ BuyerInfo,
19
+ CheckoutOrder,
20
+ CheckoutProviderConfig,
21
+ OrderStatus,
22
+ PaymentMethod,
23
+ } from "./types";
24
+ import { useCheckoutComponents } from "./ui";
25
+
26
+ /**
27
+ * Auto-raise the order once per method-while-orderless (the ref also absorbs
28
+ * StrictMode's double-effect, so PIX never double-charges); a failure sets
29
+ * `createError` which stops the loop until the buyer retries.
30
+ */
31
+ function useAutoRaiseOrder(
32
+ order: CheckoutOrder | null,
33
+ method: PaymentMethod | null,
34
+ creating: boolean,
35
+ createError: string | null,
36
+ onGenerate: (method: PaymentMethod) => void,
37
+ ): void {
38
+ const requestedFor = useRef<PaymentMethod | null>(null);
39
+ useEffect(() => {
40
+ if (order) {
41
+ requestedFor.current = null;
42
+ return;
43
+ }
44
+ // No method chosen yet ⇒ show only the picker; raise the order once the
45
+ // buyer selects PIX or card.
46
+ if (!method || creating || createError || requestedFor.current === method) return;
47
+ requestedFor.current = method;
48
+ onGenerate(method);
49
+ }, [method, order, creating, createError, onGenerate]);
50
+ }
51
+
52
+ /** The per-method payment body — PIX QR, card form, or nothing until raised. */
53
+ function PaymentBody({
54
+ order,
55
+ buyer,
56
+ providerConfig,
57
+ tenantSlug,
58
+ onResolved,
59
+ pollIntervalMs,
60
+ }: {
61
+ order: CheckoutOrder | null;
62
+ buyer: BuyerInfo;
63
+ providerConfig: CheckoutProviderConfig | null;
64
+ tenantSlug?: string;
65
+ onResolved: (status: OrderStatus) => void;
66
+ pollIntervalMs?: number;
67
+ }): JSX.Element | null {
68
+ if (order?.method === "PIX") {
69
+ return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
70
+ }
71
+ if (order?.method === "CARD") {
72
+ return (
73
+ <CardView
74
+ order={order}
75
+ buyer={buyer}
76
+ providerConfig={cardTokenization(providerConfig)}
77
+ tenantSlug={tenantSlug}
78
+ onResolved={onResolved}
79
+ pollIntervalMs={pollIntervalMs}
80
+ />
81
+ );
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** Empty-cart state shown when there's nothing to check out. */
87
+ export function EmptyCart({ onBack }: { onBack: () => void }): JSX.Element {
88
+ const { Button, Text } = useCheckoutComponents();
89
+ return (
90
+ <Box data-testid="checkout-empty" sx={{ py: 8, textAlign: "center", display: "flex", flexDirection: "column", gap: 2, alignItems: "center" }}>
91
+ <Text variant="heading" size="md" as="p">
92
+ Seu carrinho está vazio.
93
+ </Text>
94
+ <Button variant="solid" color="primary" size="md" onClick={onBack} dataTestId="checkout-empty-back">
95
+ Ver cardápio
96
+ </Button>
97
+ </Box>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The totals shown on the pay bar: the comanda scope's when settling a comanda
103
+ * (FUT-comandas), otherwise the cart's own — both supplied by the host, which
104
+ * is the only side that knows either.
105
+ */
106
+ function displayTotals(
107
+ override: { label: string; items: number } | undefined,
108
+ cart: { totalLabel: string; totalItems: number },
109
+ ): { label: string; items: number } {
110
+ return { label: override?.label ?? cart.totalLabel, items: override?.items ?? cart.totalItems };
111
+ }
112
+
113
+ /** The pay bar's money column: item count, grand total, host discount lines. */
114
+ function PayBarTotal({
115
+ totalLabel,
116
+ totalItems,
117
+ children,
118
+ }: {
119
+ totalLabel: string;
120
+ totalItems: number;
121
+ children?: ReactNode;
122
+ }): JSX.Element {
123
+ const { Text } = useCheckoutComponents();
124
+ return (
125
+ <Box sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
126
+ <Text variant="caption" size="xs" color="secondary" as="span">
127
+ Total · {totalItems} {totalItems === 1 ? "item" : "itens"}
128
+ </Text>
129
+ <Text variant="heading" size="md" weight="bold" color="primary" as="span" data-testid="pay-bar-total">
130
+ {totalLabel}
131
+ </Text>
132
+ {children}
133
+ </Box>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Step 1 "Dados" — the buyer's register info (CPF plus optional name/email/
139
+ * phone; contact pre-filled from the saved buyer profile). NO payment method
140
+ * here (that's step 2); nav lives in the slim checkout header. "Continuar"
141
+ * (sticky, with the live total) validates the CPF and advances to "Pagamento"
142
+ * — no charge yet.
143
+ */
144
+ export function DadosStep({
145
+ buyer,
146
+ onBuyerChange,
147
+ saveProfile,
148
+ onSaveProfileChange,
149
+ createError,
150
+ errorField,
151
+ onContinue,
152
+ cartTotals,
153
+ discountLines,
154
+ totalOverride,
155
+ }: {
156
+ buyer: BuyerInfo;
157
+ onBuyerChange: (buyer: BuyerInfo) => void;
158
+ saveProfile: boolean;
159
+ onSaveProfileChange: (value: boolean) => void;
160
+ createError: string | null;
161
+ errorField: BuyerField | null;
162
+ onContinue: () => void;
163
+ /** The host cart's own totals — what the pay bar shows in cart mode. */
164
+ cartTotals: { totalLabel: string; totalItems: number };
165
+ /**
166
+ * The saving, itemized under the total the buyer is about to authorize
167
+ * (FUT-246) — RENDERED BY THE HOST from its cart (the storefront passes its
168
+ * cart footer's money block), never re-implemented here, so the two surfaces
169
+ * can never word the same discount differently.
170
+ */
171
+ discountLines?: ReactNode;
172
+ /** Comanda settlement (FUT-comandas): totals come from the comanda, not the cart. */
173
+ totalOverride?: { label: string; items: number };
174
+ }): JSX.Element {
175
+ const { ActionBar, Alert, Button, Checkbox, Text } = useCheckoutComponents();
176
+ const { label: totalLabel, items: totalItems } = displayTotals(totalOverride, cartTotals);
177
+
178
+ return (
179
+ <>
180
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 3, pb: { xs: createError ? 22 : 14, sm: createError ? 20 : 12 } }}>
181
+ <BuyerInfoForm
182
+ value={buyer}
183
+ onChange={onBuyerChange}
184
+ fieldError={errorField && createError ? { field: errorField, message: createError } : null}
185
+ />
186
+ <Checkbox
187
+ checked={saveProfile}
188
+ onChange={(_event, checked) => onSaveProfileChange(checked)}
189
+ label="Salvar meus dados para a próxima compra"
190
+ data-testid="buyer-save-profile"
191
+ />
192
+ </Box>
193
+
194
+ <ActionBar dataTestId="checkout-pay-bar">
195
+ <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
196
+ {createError ? (
197
+ <Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
198
+ ) : null}
199
+ <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
200
+ <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>
201
+ {/* Suppressed while settling a comanda: those totals come from
202
+ the frozen ticket, not the cart. */}
203
+ {totalOverride ? null : discountLines}
204
+ </PayBarTotal>
205
+ <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
206
+ <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
207
+ Continuar
208
+ </Button>
209
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
210
+ <LockOutlinedIcon sx={{ fontSize: 13 }} />
211
+ <Text variant="caption" size="xs" color="secondary" as="span">
212
+ Pagamento seguro
213
+ </Text>
214
+ </Box>
215
+ </Box>
216
+ </Box>
217
+ </Box>
218
+ </ActionBar>
219
+ </>
220
+ );
221
+ }
222
+
223
+ /** Props for {@link PaymentStep} — step 2's inputs, wired by the flow. */
224
+ interface PaymentStepProps {
225
+ method: PaymentMethod | null;
226
+ onMethodChange: (method: PaymentMethod) => void;
227
+ order: CheckoutOrder | null;
228
+ buyer: BuyerInfo;
229
+ creating: boolean;
230
+ createError: string | null;
231
+ errorField: BuyerField | null;
232
+ onGenerate: (method: PaymentMethod) => void;
233
+ onUseEmail: (email: string) => void;
234
+ /**
235
+ * Present ⇒ the buyer reached this step without a Dados step (FUT-465), so
236
+ * the payer block states who is being charged and reopens Dados to change it.
237
+ */
238
+ onEditBuyer?: () => void;
239
+ /**
240
+ * The store's active payment protocol (`GET /api/checkout/config`, FUT-697).
241
+ * `null` while loading or on a transient fetch failure — methods then render
242
+ * as before and the card path degrades to the PagBank per-order key refresh,
243
+ * WITHOUT mock permission (fail-open for the UI, fail-closed for the money).
244
+ */
245
+ providerConfig?: CheckoutProviderConfig | null;
246
+ /** Scopes the saved-card list to the store being paid (host routing owns it). */
247
+ tenantSlug?: string;
248
+ pollIntervalMs?: number;
249
+ onResolved: (status: OrderStatus) => void;
250
+ }
251
+
252
+ /**
253
+ * Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
254
+ * method auto-raises its order and reveals its UI (PIX QR / card form) with no
255
+ * intermediate tap; switching method clears the previous order (controller).
256
+ */
257
+ export function PaymentStep({
258
+ method,
259
+ onMethodChange,
260
+ order,
261
+ buyer,
262
+ creating,
263
+ createError,
264
+ errorField,
265
+ onGenerate,
266
+ onUseEmail,
267
+ onEditBuyer,
268
+ providerConfig,
269
+ tenantSlug,
270
+ pollIntervalMs,
271
+ onResolved,
272
+ }: PaymentStepProps): JSX.Element {
273
+ const { LoadingState } = useCheckoutComponents();
274
+ const cardUnavailable = !cardPathAvailable(providerConfig ?? null);
275
+ useAutoRaiseOrder(order, method, creating, createError, onGenerate);
276
+ usePreselectSoleMethod(cardUnavailable, method, onMethodChange);
277
+
278
+ return (
279
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
280
+ {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
281
+ <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
282
+
283
+ <MethodPicker
284
+ value={method}
285
+ onChange={onMethodChange}
286
+ cardUnavailable={cardUnavailable}
287
+ offered={offeredMethods(providerConfig ?? null)}
288
+ />
289
+
290
+ <PaymentBody
291
+ order={order}
292
+ buyer={buyer}
293
+ providerConfig={providerConfig ?? null}
294
+ tenantSlug={tenantSlug}
295
+ onResolved={onResolved}
296
+ pollIntervalMs={pollIntervalMs}
297
+ />
298
+
299
+ {!order && creating ? (
300
+ <LoadingState variant="spinner" size="md" message="Gerando pagamento…" dataTestId="payment-generating" />
301
+ ) : null}
302
+
303
+ {!order && method && createError ? (
304
+ <PaymentErrorPanel
305
+ message={createError}
306
+ emailFlagged={errorField === "email"}
307
+ onUseEmail={onUseEmail}
308
+ onRetry={() => onGenerate(method)}
309
+ />
310
+ ) : null}
311
+ </Box>
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Order-creation failure (non-field) shown inline on Pagamento with a retry — the
317
+ * buyer never leaves the step. When the buyer e-mail was rejected (owner testing
318
+ * with the store's own e-mail) it offers a different e-mail to pay with.
319
+ */
320
+ function PaymentErrorPanel({
321
+ message,
322
+ emailFlagged,
323
+ onUseEmail,
324
+ onRetry,
325
+ }: {
326
+ message: string;
327
+ emailFlagged: boolean;
328
+ onUseEmail: (email: string) => void;
329
+ onRetry: () => void;
330
+ }): JSX.Element {
331
+ const { Alert, Button, Input } = useCheckoutComponents();
332
+ const [altEmail, setAltEmail] = useState("");
333
+
334
+ return (
335
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
336
+ <Alert variant="danger" title="Não foi possível continuar" description={message} showIcon data-testid="checkout-error" />
337
+ {emailFlagged ? (
338
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
339
+ <Input
340
+ label="E-mail para o pagamento"
341
+ type="email"
342
+ variant="outlined"
343
+ size="md"
344
+ fullWidth
345
+ autoComplete="email"
346
+ placeholder="use um e-mail diferente do da loja"
347
+ value={altEmail}
348
+ onChange={(event) => setAltEmail(event.target.value)}
349
+ data-testid="checkout-alt-email"
350
+ />
351
+ <Box>
352
+ <Button variant="solid" color="primary" size="md" disabled={!altEmail.trim()} onClick={() => onUseEmail(altEmail.trim())} dataTestId="checkout-use-alt-email">
353
+ Usar este e-mail e continuar
354
+ </Button>
355
+ </Box>
356
+ </Box>
357
+ ) : (
358
+ <Box>
359
+ <Button variant="solid" color="primary" size="md" onClick={onRetry} dataTestId="checkout-retry-payment">
360
+ Tentar novamente
361
+ </Button>
362
+ </Box>
363
+ )}
364
+ </Box>
365
+ );
366
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The checkout's PAYMENT client (FUT-43/57/45, moved into the library by
3
+ * FUT-564) — polling, card charging, saved cards, key refresh and the
4
+ * provider-protocol read, against the host-mounted `/api/checkout*` surface
5
+ * (documented in `packages/payments/ADOPTING.md` §3).
6
+ *
7
+ * Order CREATION and the buyer-profile save are deliberately NOT here: both
8
+ * are host domain (the cart, the account) and reach the flow as ports
9
+ * (`createOrder` / `saveBuyerContact` on `CheckoutFlowProps`).
10
+ *
11
+ * Tokenization is not here either: it is a browser-only step shared with the
12
+ * admin's provider-activation charge, so it lives in this package's `card/`
13
+ * module.
14
+ */
15
+
16
+ import type { SavedCard } from "../../card";
17
+ import { err, ok, type Result } from "../../result";
18
+
19
+ import type {
20
+ ChargeCardInput,
21
+ ChargeOutcome,
22
+ CheckoutProviderConfig,
23
+ OrderStatus,
24
+ } from "./types";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Transport
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
31
+ interface ApiEnvelope<T> {
32
+ data?: T;
33
+ error?: string;
34
+ }
35
+
36
+ /** Call an API route and normalize the response into a {@link Result}. */
37
+ async function requestResult<T>(input: string, init?: RequestInit): Promise<Result<T>> {
38
+ try {
39
+ const res = await fetch(input, {
40
+ ...init,
41
+ headers: { "Content-Type": "application/json", ...init?.headers },
42
+ });
43
+ const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
44
+ if (!res.ok) {
45
+ return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.");
46
+ }
47
+ if (!json || json.data === undefined) {
48
+ return err(json?.error ?? "Resposta inválida do servidor.");
49
+ }
50
+ return ok(json.data);
51
+ } catch {
52
+ return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Public API
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * What a hosted checkout appended to the return URL when it sent the buyer
62
+ * back, or undefined.
63
+ *
64
+ * InfinitePay's `payment_check` refuses to confirm without BOTH the
65
+ * `transaction_nsu` and the invoice `slug`, and neither exists until somebody
66
+ * has actually paid — they arrive here, on the redirect. Left in the URL: the
67
+ * server treats them as hints, so re-sending them on later polls is harmless.
68
+ *
69
+ * Note the buyer has to press "Continuar" on the provider's receipt for this
70
+ * redirect to happen at all, which is exactly why it is a hint and never the
71
+ * mechanism: the webhook, and the server-side reconciliation behind it, are
72
+ * what must work when they simply close the tab.
73
+ */
74
+ function returnedSettlement(): Record<string, string> {
75
+ if (typeof window === "undefined") return {};
76
+ const params = new URLSearchParams(window.location.search);
77
+ // BOTH. Measured against the live API: handle + order_nsu +
78
+ // transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
79
+ // same call missing EITHER answers {"success":false}. Neither exists before
80
+ // the payment, and the slug is not in the link-creation response — the
81
+ // redirect and the webhook are the only places both appear together.
82
+ const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
83
+ const slug = params.get("slug") ?? "";
84
+ return {
85
+ ...(transactionNsu ? { transactionNsu } : {}),
86
+ ...(slug ? { slug } : {}),
87
+ };
88
+ }
89
+
90
+ /** Poll an order's reconciled status (async provider webhook confirmation). */
91
+ export async function pollOrderStatus(orderId: string): Promise<Result<OrderStatus>> {
92
+ const params = new URLSearchParams({ orderId, ...returnedSettlement() });
93
+ return requestResult<OrderStatus>(`/api/checkout/status?${params.toString()}`, {
94
+ method: "GET",
95
+ });
96
+ }
97
+
98
+ /**
99
+ * `GET /api/checkout/config` — the store's active payment protocol (FUT-697):
100
+ * which provider, how it tokenizes, with which PUBLIC key, and whether the
101
+ * server grants stub-mode mock tokenization. Public like the menu — the buyer
102
+ * has not signed in when the checkout decides which methods to render.
103
+ */
104
+ export async function fetchCheckoutConfig(
105
+ tenantSlug: string,
106
+ ): Promise<Result<CheckoutProviderConfig>> {
107
+ return requestResult<CheckoutProviderConfig>(
108
+ `/api/checkout/config?tenantSlug=${encodeURIComponent(tenantSlug)}`,
109
+ { method: "GET" },
110
+ );
111
+ }
112
+
113
+ /**
114
+ * `POST /api/checkout/refresh-key` — fetch/refresh the store's PagBank card
115
+ * public key, scoped to the buyer's OWN order (the server derives the store
116
+ * from it; never a client-supplied store id). Used both for the initial key
117
+ * (the web page resolved it server-side) and for the FUT-174 rotated-key
118
+ * self-heal retry.
119
+ */
120
+ export async function refreshCardPublicKey(input: {
121
+ orderId: string;
122
+ }): Promise<Result<{ publicKey: string | null }>> {
123
+ return requestResult<{ publicKey: string | null }>("/api/checkout/refresh-key", {
124
+ method: "POST",
125
+ body: JSON.stringify({ orderId: input.orderId }),
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Charge a tokenized (or saved) card against an order. The outcome carries a
131
+ * `hostedCheckoutUrl` when the provider demands the buyer finish on its own
132
+ * page (3-D Secure, FUT-698) — the caller then hands the buyer over.
133
+ */
134
+ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeOutcome>> {
135
+ return requestResult<ChargeOutcome>("/api/checkout/charge", {
136
+ method: "POST",
137
+ body: JSON.stringify({
138
+ orderId: input.orderId,
139
+ token: input.token,
140
+ saveCard: input.saveCard,
141
+ cardMeta: input.cardMeta,
142
+ taxId: input.taxId,
143
+ }),
144
+ });
145
+ }
146
+
147
+ /** List saved cards available for reuse (empty on any error — non-blocking). */
148
+ export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
149
+ // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
150
+ // provider can actually charge come back — a PagBank-vaulted card is not
151
+ // offered for a Stone charge it would fail (or misroute).
152
+ const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
153
+ const result = await requestResult<SavedCard[]>(`/api/checkout/cards${query}`, {
154
+ method: "GET",
155
+ });
156
+ return result.ok ? result.data : [];
157
+ }
@@ -0,0 +1,92 @@
1
+ import type { CheckoutOrder } from "./types";
2
+
3
+ /**
4
+ * Surviving the trip to a hosted checkout (FUT-556).
5
+ *
6
+ * A redirect provider takes the buyer to ITS OWN site, so the SPA is torn down
7
+ * and remounts fresh when they come back. Everything the checkout held —
8
+ * which order was raised, for how much — is gone, and without it the return
9
+ * lands on an empty payment step: no confirmation, no total, no sign that the
10
+ * money they just moved arrived.
11
+ *
12
+ * The webhook still settles the order server-side; that is the mechanism and it
13
+ * does not depend on any of this. What is rescued here is only the buyer's view
14
+ * of it.
15
+ *
16
+ * `sessionStorage`, not `localStorage`: the handover is one tab's round trip,
17
+ * and a pending order left in durable storage would resurface in a later,
18
+ * unrelated session.
19
+ */
20
+
21
+ const KEY = "futurepay.checkout.hostedOrder";
22
+
23
+ /**
24
+ * What a hosted provider appends to the return URL. InfinitePay sends the
25
+ * first three; Stripe's redirect-based 3-D Secure appends `payment_intent`
26
+ * (+ its client secret) and `redirect_status` to the `return_url` (FUT-698).
27
+ * Any one alone is enough to recognise a return trip, which is why this is
28
+ * a "some of", not an "all of" — a provider that sends only its own reference
29
+ * must still be recognised rather than dropping the buyer on a blank page.
30
+ */
31
+ const RETURN_MARKERS = [
32
+ "transaction_nsu",
33
+ "slug",
34
+ "order_nsu",
35
+ "payment_intent",
36
+ "redirect_status",
37
+ ] as const;
38
+
39
+ /** Whether the current URL looks like a buyer coming back from a hosted page. */
40
+ function isReturnTrip(): boolean {
41
+ if (typeof window === "undefined") return false;
42
+ const params = new URLSearchParams(window.location.search);
43
+ return RETURN_MARKERS.some((marker) => params.has(marker));
44
+ }
45
+
46
+ /** Park the raised order before handing the buyer to the provider's page. */
47
+ export function rememberHostedOrder(order: CheckoutOrder): void {
48
+ try {
49
+ window.sessionStorage?.setItem(KEY, JSON.stringify(order));
50
+ } catch {
51
+ // Storage disabled or full. The redirect must still happen: the webhook
52
+ // settles the order either way, and refusing to send the buyer to pay
53
+ // would be a far worse failure than a plain return screen.
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The parked order, but ONLY on a return trip — and cleared as it is read.
59
+ *
60
+ * Gated on the URL rather than on mere presence so a buyer who abandons the
61
+ * provider's page and later opens checkout again starts a fresh order instead
62
+ * of resuming one they never paid. Read-and-clear for the same reason: the
63
+ * resumed view belongs to exactly one return.
64
+ */
65
+ export function takeHostedOrder(): CheckoutOrder | null {
66
+ if (!isReturnTrip()) return null;
67
+ let raw: string | null = null;
68
+ try {
69
+ raw = window.sessionStorage?.getItem(KEY) ?? null;
70
+ window.sessionStorage?.removeItem(KEY);
71
+ } catch {
72
+ return null;
73
+ }
74
+ if (!raw) return null;
75
+ try {
76
+ const parsed: unknown = JSON.parse(raw);
77
+ return isCheckoutOrder(parsed) ? parsed : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Trust nothing that came back out of storage: it is the only input here that
85
+ * did not come from this render, and a half-written or hand-edited value would
86
+ * otherwise reach the status view as an order.
87
+ */
88
+ function isCheckoutOrder(value: unknown): value is CheckoutOrder {
89
+ if (typeof value !== "object" || value === null) return false;
90
+ const candidate = value as Partial<CheckoutOrder>;
91
+ return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
92
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The checkout's icons, drawn inline through MUI's `SvgIcon` (already a peer).
3
+ *
4
+ * The path data is Material Symbols' — byte-identical to the corresponding
5
+ * `@mui/icons-material` exports the storefront rendered before the move — so
6
+ * the screens keep their exact pixels WITHOUT this package growing an
7
+ * icons-package dependency (the design-system contract is peers `@mui/material`
8
+ * + `@emotion/*` and nothing else; see `ui.tsx`).
9
+ */
10
+ import { SvgIcon, type SvgIconProps } from '@mui/material';
11
+ import type { JSX } from 'react';
12
+
13
+ function makeIcon(paths: string[]): (props: SvgIconProps) => JSX.Element {
14
+ return function CheckoutIcon(props: SvgIconProps): JSX.Element {
15
+ return (
16
+ <SvgIcon {...props}>
17
+ {paths.map((d) => (
18
+ <path key={d} d={d} />
19
+ ))}
20
+ </SvgIcon>
21
+ );
22
+ };
23
+ }
24
+
25
+ export const ArrowBackIcon = makeIcon([
26
+ 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
27
+ ]);
28
+
29
+ export const LockOutlinedIcon = makeIcon([
30
+ 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2M9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9zm9 14H6V10h12zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2',
31
+ ]);
32
+
33
+ export const ContentCopyIcon = makeIcon([
34
+ 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z',
35
+ ]);
36
+
37
+ export const CreditCardIcon = makeIcon([
38
+ 'M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2m0 14H4v-6h16zm0-10H4V6h16z',
39
+ ]);
40
+
41
+ export const PixIcon = makeIcon([
42
+ 'm15.45 16.52-3.01-3.01c-.11-.11-.24-.13-.31-.13s-.2.02-.31.13L8.8 16.53c-.34.34-.87.89-2.64.89l3.71 3.7c1.17 1.17 3.07 1.17 4.24 0l3.72-3.71c-.91 0-1.67-.18-2.38-.89M8.8 7.47l3.02 3.02c.08.08.2.13.31.13s.23-.05.31-.13l2.99-2.99c.71-.74 1.52-.91 2.43-.91l-3.72-3.71c-1.17-1.17-3.07-1.17-4.24 0l-3.71 3.7c1.76 0 2.3.58 2.61.89',
43
+ 'm21.11 9.85-2.25-2.26H17.6c-.54 0-1.08.22-1.45.61l-3 3c-.28.28-.65.42-1.02.42-.36 0-.74-.15-1.02-.42L8.09 8.17c-.38-.38-.9-.6-1.45-.6H5.17l-2.29 2.3c-1.17 1.17-1.17 3.07 0 4.24l2.29 2.3h1.48c.54 0 1.06-.22 1.45-.6l3.02-3.02c.28-.28.65-.42 1.02-.42s.74.14 1.02.42l3.01 3.01c.38.38.9.6 1.45.6h1.26l2.25-2.26c1.17-1.18 1.17-3.1-.02-4.29',
44
+ ]);
45
+
46
+ export const CheckCircleOutlineIcon = makeIcon([
47
+ 'M16.59 7.58 10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8',
48
+ ]);
49
+
50
+ export const ErrorOutlineIcon = makeIcon([
51
+ 'M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8',
52
+ ]);
53
+
54
+ export const ScheduleIcon = makeIcon([
55
+ 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8',
56
+ 'M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z',
57
+ ]);
58
+
59
+ export const PersonOutlineIcon = makeIcon([
60
+ 'M12 5.9c1.16 0 2.1.94 2.1 2.1s-.94 2.1-2.1 2.1S9.9 9.16 9.9 8s.94-2.1 2.1-2.1m0 9c2.97 0 6.1 1.46 6.1 2.1v1.1H5.9V17c0-.64 3.13-2.1 6.1-2.1M12 4C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 9c-2.67 0-8 1.34-8 4v3h16v-3c0-2.66-5.33-4-8-4',
61
+ ]);