@12-apps/payments-frontend 1.0.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/eslint.config.js +34 -0
- package/package.json +63 -0
- package/src/__tests__/checkout-confirmation.test.tsx +177 -0
- package/src/__tests__/connection-state.test.tsx +84 -0
- package/src/__tests__/context.test.tsx +88 -0
- package/src/__tests__/controlled-provider.test.tsx +116 -0
- package/src/__tests__/credential-confirm.test.tsx +193 -0
- package/src/__tests__/initial-provider.test.tsx +81 -0
- package/src/__tests__/provider-priority-list.test.tsx +159 -0
- package/src/__tests__/provider-status-bar.test.tsx +152 -0
- package/src/__tests__/verification-slot.test.tsx +125 -0
- package/src/client.ts +200 -0
- package/src/components/CheckoutFlow.tsx +169 -0
- package/src/components/CheckoutPayment.tsx +379 -0
- package/src/components/ConfirmCredentialSave.tsx +106 -0
- package/src/components/CredentialFields.tsx +158 -0
- package/src/components/CredentialFormAlerts.tsx +144 -0
- package/src/components/EnvironmentTabs.tsx +109 -0
- package/src/components/PaymentProviderSettings.tsx +267 -0
- package/src/components/ProviderConnection.tsx +251 -0
- package/src/components/ProviderCredentialForm.tsx +387 -0
- package/src/components/ProviderList.tsx +126 -0
- package/src/components/ProviderPanel.tsx +300 -0
- package/src/components/ProviderPriorityList.tsx +293 -0
- package/src/components/ProviderSetupGuide.tsx +263 -0
- package/src/components/ProviderStatusBar.tsx +192 -0
- package/src/components/SetupGuideSection.tsx +190 -0
- package/src/components/checkout-ack.ts +106 -0
- package/src/components/connection-state.ts +66 -0
- package/src/components/credential-rules.ts +120 -0
- package/src/components/rich-text.tsx +27 -0
- package/src/components/settings-state.ts +211 -0
- package/src/context.tsx +144 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
CircularProgress,
|
|
8
|
+
FormControlLabel,
|
|
9
|
+
Radio,
|
|
10
|
+
RadioGroup,
|
|
11
|
+
Stack,
|
|
12
|
+
TextField,
|
|
13
|
+
Typography,
|
|
14
|
+
} from '@mui/material';
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-backend';
|
|
18
|
+
|
|
19
|
+
import type { ClientPaymentsConfig, PaymentsClient } from '../client';
|
|
20
|
+
import { useChargeStatus, useCreateCharge, usePaymentsClient } from '../context';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Plug-and-play checkout payment step — the reusable equivalent of the
|
|
24
|
+
* buyer-facing payment screen. Renders the right flow for the merchant's
|
|
25
|
+
* ACTIVE provider by switching on its client config and the created charge:
|
|
26
|
+
*
|
|
27
|
+
* - PIX: create → show copy-paste QR payload (+ image when the provider
|
|
28
|
+
* returns one) → poll until PAID → `onPaid`
|
|
29
|
+
* - CARD: collect via a host-injected `tokenizeCard` (each provider's
|
|
30
|
+
* tokenization differs — Stripe SDK, public-key encryption, ...); only
|
|
31
|
+
* the resulting TOKEN reaches the backend
|
|
32
|
+
* - REDIRECT (hosted checkout, e.g. InfinitePay): send the buyer to
|
|
33
|
+
* `hostedCheckoutUrl`, poll on return
|
|
34
|
+
*
|
|
35
|
+
* Must be rendered inside a `<PaymentsProvider>`. The host owns totals,
|
|
36
|
+
* order creation, and what happens on `onPaid` — this component owns only
|
|
37
|
+
* the payment interaction.
|
|
38
|
+
*/
|
|
39
|
+
export interface CardFormValues {
|
|
40
|
+
number: string;
|
|
41
|
+
holder: string;
|
|
42
|
+
expiry: string;
|
|
43
|
+
cvv: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A provider-vaulted card the buyer may reuse ("Mastercard •••• 7599"). */
|
|
47
|
+
export interface SavedCardOption {
|
|
48
|
+
savedCardToken: string;
|
|
49
|
+
brand: string;
|
|
50
|
+
last4: string;
|
|
51
|
+
/** e.g. "02/2034" — shown as "Validade 02/2034". */
|
|
52
|
+
expiry?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CheckoutPaymentProps {
|
|
56
|
+
/**
|
|
57
|
+
* Opaque host-side handle for what is being paid (cart/order id). Sent as
|
|
58
|
+
* a LOOKUP KEY only — the server resolves the authoritative amount and
|
|
59
|
+
* reference from its own records, never from this component.
|
|
60
|
+
*/
|
|
61
|
+
reference: string;
|
|
62
|
+
/** Display total. Shown to the buyer; the server recomputes what is charged. */
|
|
63
|
+
amount: Money;
|
|
64
|
+
customer: CustomerInfo;
|
|
65
|
+
/**
|
|
66
|
+
* Provider-specific client-side tokenization, injected by the host using
|
|
67
|
+
* `config.tokenization`/`config.publicKey` (SDK or public-key flows).
|
|
68
|
+
* Required for providers whose config declares CARD support.
|
|
69
|
+
*/
|
|
70
|
+
tokenizeCard?: (values: CardFormValues, config: ClientPaymentsConfig) => Promise<string>;
|
|
71
|
+
/** Provider-vaulted cards for one-tap reuse (host loads them). */
|
|
72
|
+
savedCards?: SavedCardOption[];
|
|
73
|
+
onPaid: (charge: ClientChargeView) => void;
|
|
74
|
+
onFailed?: (charge: ClientChargeView) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatBRL(amount: Money): string {
|
|
78
|
+
return (amount.amountCents / 100).toLocaleString('pt-BR', {
|
|
79
|
+
style: 'currency',
|
|
80
|
+
currency: amount.currency,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function firstError(...messages: (string | null | undefined)[]): string | null {
|
|
85
|
+
for (const message of messages) if (message) return message;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Card charges can settle synchronously on create (no polling needed). */
|
|
90
|
+
function isFinalOnCreate(charge: ClientChargeView): boolean {
|
|
91
|
+
return charge.status !== 'PENDING' && charge.status !== 'AUTHORIZED';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Load the merchant's client-safe payment config once. */
|
|
95
|
+
function useClientConfig(client: PaymentsClient) {
|
|
96
|
+
const [config, setConfig] = useState<ClientPaymentsConfig | null | undefined>(undefined);
|
|
97
|
+
const [error, setError] = useState<string | null>(null);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
client
|
|
100
|
+
.getConfig()
|
|
101
|
+
.then(setConfig)
|
|
102
|
+
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
|
|
103
|
+
}, [client]);
|
|
104
|
+
return { config, error };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fire onPaid/onFailed exactly when the charge reaches its outcome. */
|
|
108
|
+
function useChargeOutcome(
|
|
109
|
+
current: ClientChargeView | null,
|
|
110
|
+
settled: boolean,
|
|
111
|
+
onPaid: (charge: ClientChargeView) => void,
|
|
112
|
+
onFailed?: (charge: ClientChargeView) => void,
|
|
113
|
+
) {
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!current) return;
|
|
116
|
+
if (current.status === 'PAID') onPaid(current);
|
|
117
|
+
else if (settled || isFinalOnCreate(current)) onFailed?.(current);
|
|
118
|
+
}, [current, settled, onPaid, onFailed]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function PixPanel({ charge }: { charge: ClientChargeView }) {
|
|
122
|
+
const [copied, setCopied] = useState(false);
|
|
123
|
+
if (!charge.pix) return null;
|
|
124
|
+
return (
|
|
125
|
+
<Stack spacing={1} alignItems="flex-start">
|
|
126
|
+
{charge.pix.qrImageUrl ? (
|
|
127
|
+
<Box component="img" src={charge.pix.qrImageUrl} alt="QR Code PIX" sx={{ width: 220 }} />
|
|
128
|
+
) : null}
|
|
129
|
+
<TextField fullWidth multiline size="small" label="PIX copia e cola" value={charge.pix.qrText} />
|
|
130
|
+
<Button
|
|
131
|
+
size="small"
|
|
132
|
+
onClick={() => {
|
|
133
|
+
void navigator.clipboard.writeText(charge.pix?.qrText ?? '').then(() => setCopied(true));
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{copied ? 'Copiado!' : 'Copiar código'}
|
|
137
|
+
</Button>
|
|
138
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
139
|
+
<CircularProgress size={16} />
|
|
140
|
+
<Typography variant="body2">Aguardando pagamento…</Typography>
|
|
141
|
+
</Stack>
|
|
142
|
+
</Stack>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function CardPanel({ disabled, onSubmit }: { disabled: boolean; onSubmit: (values: CardFormValues) => void }) {
|
|
147
|
+
const [values, setValues] = useState<CardFormValues>({ number: '', holder: '', expiry: '', cvv: '' });
|
|
148
|
+
const field = (key: keyof CardFormValues, label: string, width?: number) => (
|
|
149
|
+
<TextField
|
|
150
|
+
size="small"
|
|
151
|
+
label={label}
|
|
152
|
+
value={values[key]}
|
|
153
|
+
sx={width ? { width } : undefined}
|
|
154
|
+
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
return (
|
|
158
|
+
<Stack spacing={2}>
|
|
159
|
+
{field('number', 'Número do cartão')}
|
|
160
|
+
{field('holder', 'Nome impresso no cartão')}
|
|
161
|
+
<Stack direction="row" spacing={2}>
|
|
162
|
+
{field('expiry', 'Validade (MM/AA)', 140)}
|
|
163
|
+
{field('cvv', 'CVV', 100)}
|
|
164
|
+
</Stack>
|
|
165
|
+
<Button variant="contained" disabled={disabled} onClick={() => onSubmit(values)}>
|
|
166
|
+
Pagar com cartão
|
|
167
|
+
</Button>
|
|
168
|
+
</Stack>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface CardSectionProps {
|
|
173
|
+
amount: Money;
|
|
174
|
+
savedCards: SavedCardOption[];
|
|
175
|
+
disabled: boolean;
|
|
176
|
+
onNewCard: (values: CardFormValues) => void;
|
|
177
|
+
onSavedCard: (card: SavedCardOption) => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** "Pague com cartão": saved cards as radios + "Novo cartão" fallback. */
|
|
181
|
+
function CardSection({ amount, savedCards, disabled, onNewCard, onSavedCard }: CardSectionProps) {
|
|
182
|
+
const [selected, setSelected] = useState(savedCards[0]?.savedCardToken ?? 'new');
|
|
183
|
+
const chosen = savedCards.find((c) => c.savedCardToken === selected);
|
|
184
|
+
if (savedCards.length === 0) return <CardPanel disabled={disabled} onSubmit={onNewCard} />;
|
|
185
|
+
return (
|
|
186
|
+
<Stack spacing={2}>
|
|
187
|
+
<Typography variant="subtitle2">Pague com cartão</Typography>
|
|
188
|
+
<RadioGroup value={selected} onChange={(_, v) => setSelected(v)}>
|
|
189
|
+
{savedCards.map((card) => (
|
|
190
|
+
<FormControlLabel
|
|
191
|
+
key={card.savedCardToken}
|
|
192
|
+
value={card.savedCardToken}
|
|
193
|
+
control={<Radio />}
|
|
194
|
+
label={`${card.brand} •••• ${card.last4}${card.expiry ? ` — Validade ${card.expiry}` : ''}`}
|
|
195
|
+
/>
|
|
196
|
+
))}
|
|
197
|
+
<FormControlLabel value="new" control={<Radio />} label="Novo cartão — inserir outro cartão" />
|
|
198
|
+
</RadioGroup>
|
|
199
|
+
{chosen ? (
|
|
200
|
+
<Button variant="contained" disabled={disabled} onClick={() => onSavedCard(chosen)}>
|
|
201
|
+
Pagar {formatBRL(amount)}
|
|
202
|
+
</Button>
|
|
203
|
+
) : (
|
|
204
|
+
<CardPanel disabled={disabled} onSubmit={onNewCard} />
|
|
205
|
+
)}
|
|
206
|
+
</Stack>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface MethodChooserProps {
|
|
211
|
+
config: ClientPaymentsConfig;
|
|
212
|
+
amount: Money;
|
|
213
|
+
savedCards: SavedCardOption[];
|
|
214
|
+
loading: boolean;
|
|
215
|
+
startPix: () => void;
|
|
216
|
+
startCard: (values: CardFormValues) => void;
|
|
217
|
+
startSavedCard: (card: SavedCardOption) => void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One "Forma de pagamento" option card (PIX / Cartão). */
|
|
221
|
+
function MethodCard(props: { title: string; subtitle: string; selected: boolean; onClick: () => void }) {
|
|
222
|
+
return (
|
|
223
|
+
<Button
|
|
224
|
+
variant="outlined"
|
|
225
|
+
onClick={props.onClick}
|
|
226
|
+
sx={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', borderWidth: props.selected ? 2 : 1 }}
|
|
227
|
+
>
|
|
228
|
+
<Stack alignItems="flex-start">
|
|
229
|
+
<Typography variant="subtitle2">{props.title}</Typography>
|
|
230
|
+
<Typography variant="caption" color="text.secondary">
|
|
231
|
+
{props.subtitle}
|
|
232
|
+
</Typography>
|
|
233
|
+
</Stack>
|
|
234
|
+
</Button>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Pre-charge phase: "Forma de pagamento" method cards, then the flow. */
|
|
239
|
+
function MethodChooser(props: MethodChooserProps) {
|
|
240
|
+
const { config, amount, savedCards, loading, startPix, startCard, startSavedCard } = props;
|
|
241
|
+
const [method, setMethod] = useState<'PIX' | 'CARD'>('PIX');
|
|
242
|
+
if (config.tokenization === 'REDIRECT') {
|
|
243
|
+
return (
|
|
244
|
+
<Button variant="contained" disabled={loading} onClick={startPix}>
|
|
245
|
+
Continuar para o pagamento
|
|
246
|
+
</Button>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return (
|
|
250
|
+
<>
|
|
251
|
+
<Typography variant="subtitle2">Forma de pagamento</Typography>
|
|
252
|
+
<Stack direction="row" spacing={2}>
|
|
253
|
+
<MethodCard title="PIX" subtitle="Aprovação imediata" selected={method === 'PIX'} onClick={() => setMethod('PIX')} />
|
|
254
|
+
<MethodCard title="Cartão" subtitle="Crédito à vista" selected={method === 'CARD'} onClick={() => setMethod('CARD')} />
|
|
255
|
+
</Stack>
|
|
256
|
+
{method === 'PIX' ? (
|
|
257
|
+
<Button variant="contained" disabled={loading} onClick={startPix}>
|
|
258
|
+
Gerar QR Code PIX
|
|
259
|
+
</Button>
|
|
260
|
+
) : (
|
|
261
|
+
<CardSection
|
|
262
|
+
amount={amount}
|
|
263
|
+
savedCards={savedCards}
|
|
264
|
+
disabled={loading}
|
|
265
|
+
onNewCard={startCard}
|
|
266
|
+
onSavedCard={startSavedCard}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Poll target: only charges that can still change server-side. */
|
|
274
|
+
function pollRefOf(charge: ClientChargeView | null) {
|
|
275
|
+
if (!charge || isFinalOnCreate(charge)) return null;
|
|
276
|
+
return { provider: charge.provider, providerChargeId: charge.providerChargeId };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface CheckoutGateProps {
|
|
280
|
+
errorMessage: string | null;
|
|
281
|
+
config: ClientPaymentsConfig | null | undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Error / loading / payments-disabled gates ahead of the payment UI. */
|
|
285
|
+
function checkoutGate({ errorMessage, config }: CheckoutGateProps) {
|
|
286
|
+
if (errorMessage) return <Alert severity="error">{errorMessage}</Alert>;
|
|
287
|
+
if (config === undefined) return <CircularProgress data-testid="checkout-payment-loading" />;
|
|
288
|
+
if (config === null) {
|
|
289
|
+
return <Alert severity="warning">Esta loja ainda não aceita pagamentos online.</Alert>;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** In-flight phase: the created charge decides what the buyer sees. */
|
|
295
|
+
function ChargePhase({ charge, amount }: { charge: ClientChargeView; amount: Money }) {
|
|
296
|
+
if (charge.hostedCheckoutUrl && charge.status === 'PENDING') {
|
|
297
|
+
return (
|
|
298
|
+
<Stack spacing={2}>
|
|
299
|
+
<Typography>Você será direcionado para concluir o pagamento com segurança.</Typography>
|
|
300
|
+
<Button variant="contained" href={charge.hostedCheckoutUrl}>
|
|
301
|
+
Pagar {formatBRL(amount)}
|
|
302
|
+
</Button>
|
|
303
|
+
</Stack>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (charge.method === 'PIX' && charge.status === 'PENDING') return <PixPanel charge={charge} />;
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
311
|
+
const { reference, amount, customer, tokenizeCard, savedCards = [], onPaid, onFailed } = props;
|
|
312
|
+
const client = usePaymentsClient();
|
|
313
|
+
const { config, error: configError } = useClientConfig(client);
|
|
314
|
+
const [tokenizeError, setTokenizeError] = useState<string | null>(null);
|
|
315
|
+
const { charge, loading, create, error: createError } = useCreateCharge();
|
|
316
|
+
const { charge: live, settled } = useChargeStatus(pollRefOf(charge));
|
|
317
|
+
const current = live ?? charge;
|
|
318
|
+
useChargeOutcome(current, settled, onPaid, onFailed);
|
|
319
|
+
|
|
320
|
+
const startPix = useCallback(
|
|
321
|
+
() => void create({ method: 'PIX', customer, orderRef: reference }),
|
|
322
|
+
[create, reference, customer],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const startCard = useCallback(
|
|
326
|
+
async (values: CardFormValues) => {
|
|
327
|
+
if (!config || !tokenizeCard) {
|
|
328
|
+
setTokenizeError('Pagamento com cartão indisponível.');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const token = await tokenizeCard(values, config);
|
|
333
|
+
await create({
|
|
334
|
+
method: 'CARD',
|
|
335
|
+
customer,
|
|
336
|
+
orderRef: reference,
|
|
337
|
+
card: { token, holder: values.holder },
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
setTokenizeError(err instanceof Error ? err.message : String(err));
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
[config, tokenizeCard, create, reference, customer],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const startSavedCard = useCallback(
|
|
347
|
+
(card: SavedCardOption) =>
|
|
348
|
+
void create({
|
|
349
|
+
method: 'CARD',
|
|
350
|
+
customer,
|
|
351
|
+
orderRef: reference,
|
|
352
|
+
card: { savedCardToken: card.savedCardToken },
|
|
353
|
+
}),
|
|
354
|
+
[create, reference, customer],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const errorMessage = firstError(configError, tokenizeError, createError && createError.message);
|
|
358
|
+
const gate = checkoutGate({ errorMessage, config });
|
|
359
|
+
if (gate || config === undefined || config === null) return gate;
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<Stack spacing={2}>
|
|
363
|
+
<Typography variant="h6">Total: {formatBRL(amount)}</Typography>
|
|
364
|
+
{current ? (
|
|
365
|
+
<ChargePhase charge={current} amount={amount} />
|
|
366
|
+
) : (
|
|
367
|
+
<MethodChooser
|
|
368
|
+
config={config}
|
|
369
|
+
amount={amount}
|
|
370
|
+
savedCards={savedCards}
|
|
371
|
+
loading={loading}
|
|
372
|
+
startPix={startPix}
|
|
373
|
+
startCard={(values) => void startCard(values)}
|
|
374
|
+
startSavedCard={startSavedCard}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
</Stack>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Box,
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogActions,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
Typography,
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
|
|
13
|
+
import type { CredentialFieldSpec } from '@12-apps/payments-backend';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The last look at the value that decides who gets paid.
|
|
17
|
+
*
|
|
18
|
+
* Two real R$ 1,01 payments were lost during this flow's development, both to
|
|
19
|
+
* things that could be tested and fixed. A mistyped InfiniteTag is the failure
|
|
20
|
+
* with no such recourse: the charge succeeds, the link works, the buyer is
|
|
21
|
+
* happy, and the money is in a stranger's account with nothing on our side able
|
|
22
|
+
* to reverse it. There is no checksum to catch it, no confirmation email, and
|
|
23
|
+
* the store finds out at the end of the month.
|
|
24
|
+
*
|
|
25
|
+
* So the one intervention available is to show the value back, large, in the
|
|
26
|
+
* face it will be read in, and say what saving it means — after the owner has
|
|
27
|
+
* decided to save and before the write happens. This is the whole of the
|
|
28
|
+
* dialog: no new information, one more reading.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface PendingSave {
|
|
32
|
+
spec: CredentialFieldSpec;
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ConfirmCredentialSave({
|
|
37
|
+
pending,
|
|
38
|
+
busy,
|
|
39
|
+
onCancel,
|
|
40
|
+
onConfirm,
|
|
41
|
+
}: {
|
|
42
|
+
/** Null while nothing is awaiting confirmation. */
|
|
43
|
+
pending: PendingSave | null;
|
|
44
|
+
busy: boolean;
|
|
45
|
+
onCancel: () => void;
|
|
46
|
+
onConfirm: () => void;
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<Dialog
|
|
50
|
+
open={pending !== null}
|
|
51
|
+
onClose={onCancel}
|
|
52
|
+
maxWidth="xs"
|
|
53
|
+
fullWidth
|
|
54
|
+
data-testid="payments-confirm-credential"
|
|
55
|
+
aria-labelledby="payments-confirm-credential-title"
|
|
56
|
+
>
|
|
57
|
+
<DialogTitle id="payments-confirm-credential-title">
|
|
58
|
+
Confirme para onde vai o dinheiro
|
|
59
|
+
</DialogTitle>
|
|
60
|
+
<DialogContent>
|
|
61
|
+
<Typography variant="body2" color="text.secondary">
|
|
62
|
+
Todo pagamento recebido por esta loja será depositado na conta desta{' '}
|
|
63
|
+
{pending?.spec.label ?? 'credencial'}:
|
|
64
|
+
</Typography>
|
|
65
|
+
<Box
|
|
66
|
+
data-testid="payments-confirm-credential-value"
|
|
67
|
+
sx={{
|
|
68
|
+
my: 2,
|
|
69
|
+
py: 2,
|
|
70
|
+
px: 1,
|
|
71
|
+
textAlign: 'center',
|
|
72
|
+
fontFamily: 'monospace',
|
|
73
|
+
fontSize: '1.5rem',
|
|
74
|
+
letterSpacing: '0.12em',
|
|
75
|
+
wordBreak: 'break-all',
|
|
76
|
+
borderRadius: 1,
|
|
77
|
+
bgcolor: 'action.hover',
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{pending?.value}
|
|
81
|
+
</Box>
|
|
82
|
+
<Typography variant="caption" color="text.secondary">
|
|
83
|
+
Um valor errado envia os pagamentos desta loja para outra pessoa, e não há como reverter.
|
|
84
|
+
</Typography>
|
|
85
|
+
</DialogContent>
|
|
86
|
+
<DialogActions>
|
|
87
|
+
{/*
|
|
88
|
+
Cancel is the wide, plain, first-reached option and confirm carries
|
|
89
|
+
the specific verb: an owner who is not sure should find it easier to
|
|
90
|
+
go back and check than to press on.
|
|
91
|
+
*/}
|
|
92
|
+
<Button onClick={onCancel} disabled={busy} data-testid="payments-confirm-credential-cancel">
|
|
93
|
+
Voltar e revisar
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
variant="contained"
|
|
97
|
+
onClick={onConfirm}
|
|
98
|
+
disabled={busy}
|
|
99
|
+
data-testid="payments-confirm-credential-confirm"
|
|
100
|
+
>
|
|
101
|
+
É essa, salvar
|
|
102
|
+
</Button>
|
|
103
|
+
</DialogActions>
|
|
104
|
+
</Dialog>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import type { CredentialFieldSpec, MaskedFieldState } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The schema-driven credential inputs, and the one-line summary a finished
|
|
9
|
+
* step collapses into.
|
|
10
|
+
*
|
|
11
|
+
* Split out of `ProviderCredentialForm` so that file stays about the ACTIONS
|
|
12
|
+
* (save, probe, confirm) rather than about rendering a field.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface FieldPresentation {
|
|
16
|
+
type: 'text' | 'password';
|
|
17
|
+
value: string;
|
|
18
|
+
required: boolean;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
helperText?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Write-only secret field: masked hint as placeholder, value never echoed. */
|
|
24
|
+
function secretPresentation(
|
|
25
|
+
state: MaskedFieldState | undefined,
|
|
26
|
+
value: string | undefined,
|
|
27
|
+
required: boolean,
|
|
28
|
+
): FieldPresentation {
|
|
29
|
+
const configured = state?.configured ?? false;
|
|
30
|
+
return {
|
|
31
|
+
type: 'password',
|
|
32
|
+
value: value ?? '',
|
|
33
|
+
required: required && !configured,
|
|
34
|
+
placeholder: configured ? (state?.hint ?? '') : undefined,
|
|
35
|
+
helperText: configured ? 'Configurado — deixe em branco para manter o valor atual.' : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fieldPresentation(
|
|
40
|
+
spec: CredentialFieldSpec,
|
|
41
|
+
state: MaskedFieldState | undefined,
|
|
42
|
+
value: string | undefined,
|
|
43
|
+
): FieldPresentation {
|
|
44
|
+
if (spec.secret) return secretPresentation(state, value, spec.required);
|
|
45
|
+
const configured = state?.configured ?? false;
|
|
46
|
+
return {
|
|
47
|
+
type: 'text',
|
|
48
|
+
value: value ?? state?.hint ?? '',
|
|
49
|
+
required: spec.required && !configured,
|
|
50
|
+
placeholder: spec.placeholder,
|
|
51
|
+
helperText: spec.helperText,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A monospace, letter-spaced face for values that must be checked character by
|
|
57
|
+
* character. The whole reason InfinitePay's handle carries `mono` is that a
|
|
58
|
+
* wrong one is not rejected — it is a different person's account.
|
|
59
|
+
*/
|
|
60
|
+
const MONO_INPUT = {
|
|
61
|
+
fontFamily: 'monospace',
|
|
62
|
+
letterSpacing: '0.08em',
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
interface CredentialFieldProps {
|
|
66
|
+
spec: CredentialFieldSpec;
|
|
67
|
+
state: MaskedFieldState | undefined;
|
|
68
|
+
value: string | undefined;
|
|
69
|
+
onChange: (value: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One schema-driven form field. Secrets are write-only: hint, never value. */
|
|
73
|
+
export function CredentialField({ spec, state, value, onChange }: CredentialFieldProps) {
|
|
74
|
+
const presentation = fieldPresentation(spec, state, value);
|
|
75
|
+
return (
|
|
76
|
+
<TextField
|
|
77
|
+
size="small"
|
|
78
|
+
label={spec.label}
|
|
79
|
+
{...presentation}
|
|
80
|
+
slotProps={spec.mono ? { htmlInput: { sx: MONO_INPUT, spellCheck: false } } : undefined}
|
|
81
|
+
onChange={(e) => onChange(e.target.value)}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What a completed step collapses to.
|
|
88
|
+
*
|
|
89
|
+
* A form that keeps every finished step expanded makes the owner re-read three
|
|
90
|
+
* cards to find the one thing still outstanding, and — worse for this screen —
|
|
91
|
+
* leaves the field that decides where the money goes sitting there editable,
|
|
92
|
+
* one stray keystroke from being changed by someone who came back to check
|
|
93
|
+
* something else. Collapsed, the value is still legible (that IS the check
|
|
94
|
+
* they came for) and altering it is a deliberate act.
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* The parenthetical in a field label — "InfiniteTag ($usuario)" — tells you how
|
|
98
|
+
* to TYPE the value. Beside the value itself it is answered by the thing it
|
|
99
|
+
* sits next to, so the summary row shows the bare name.
|
|
100
|
+
*/
|
|
101
|
+
// Drops a trailing "(...)" qualifier from a field label. Done by scanning
|
|
102
|
+
// rather than with /\s*\([^)]*\)\s*$/, which is polynomial-time on labels made
|
|
103
|
+
// of many spaces or many open parens (CodeQL: polynomial regular expression
|
|
104
|
+
// used on uncontrolled data) — the label is supplied by the caller.
|
|
105
|
+
function stripTrailingParenthetical(label: string): string {
|
|
106
|
+
const trimmed = label.trimEnd();
|
|
107
|
+
if (!trimmed.endsWith(')')) return trimmed;
|
|
108
|
+
|
|
109
|
+
const open = trimmed.lastIndexOf('(');
|
|
110
|
+
if (open === -1) return trimmed;
|
|
111
|
+
|
|
112
|
+
return trimmed.slice(0, open).trimEnd();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function DoneRow({
|
|
116
|
+
label,
|
|
117
|
+
value,
|
|
118
|
+
mono,
|
|
119
|
+
onEdit,
|
|
120
|
+
editLabel = 'Alterar',
|
|
121
|
+
testId,
|
|
122
|
+
}: {
|
|
123
|
+
label: string;
|
|
124
|
+
value: string;
|
|
125
|
+
mono?: boolean;
|
|
126
|
+
onEdit: () => void;
|
|
127
|
+
editLabel?: string;
|
|
128
|
+
testId: string;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<Stack
|
|
132
|
+
direction="row"
|
|
133
|
+
spacing={1.5}
|
|
134
|
+
alignItems="center"
|
|
135
|
+
data-testid={testId}
|
|
136
|
+
sx={{ border: 1, borderColor: 'divider', borderRadius: 1, px: 2, py: 1.25 }}
|
|
137
|
+
>
|
|
138
|
+
<Box aria-hidden sx={{ color: 'success.main', fontWeight: 700 }}>
|
|
139
|
+
✓
|
|
140
|
+
</Box>
|
|
141
|
+
<Typography variant="body2" color="text.secondary">
|
|
142
|
+
{stripTrailingParenthetical(label)}
|
|
143
|
+
</Typography>
|
|
144
|
+
<Typography variant="body2" sx={mono ? MONO_INPUT : undefined}>
|
|
145
|
+
{value}
|
|
146
|
+
</Typography>
|
|
147
|
+
<Box sx={{ flexGrow: 1 }} />
|
|
148
|
+
<Button
|
|
149
|
+
size="small"
|
|
150
|
+
onClick={onEdit}
|
|
151
|
+
data-testid={`${testId}-edit`}
|
|
152
|
+
sx={{ textTransform: 'none' }}
|
|
153
|
+
>
|
|
154
|
+
{editLabel}
|
|
155
|
+
</Button>
|
|
156
|
+
</Stack>
|
|
157
|
+
);
|
|
158
|
+
}
|