@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.
- package/package.json +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- 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
|
+
]);
|