@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,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import type { MerchantSettingsView } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
8
|
+
import { PaymentProviderSettings } from '../components/PaymentProviderSettings';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The activation step's slot.
|
|
12
|
+
*
|
|
13
|
+
* "Connected" and "can charge" are different facts, and only the HOST can prove
|
|
14
|
+
* the second — it owns the endpoint that puts a real cent through the store's
|
|
15
|
+
* account. This package's job is narrower and worth pinning: place that step
|
|
16
|
+
* where an owner will actually find it, and tell the host which provider is
|
|
17
|
+
* open and whether it is already live.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PROVIDERS = [
|
|
21
|
+
{ name: 'pagbank', displayName: 'PagBank', authMode: 'oauth', credentialSchema: [] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function viewWith(config: Record<string, unknown> | null): MerchantSettingsView {
|
|
25
|
+
return {
|
|
26
|
+
providers: PROVIDERS,
|
|
27
|
+
configs: config ? [config] : [],
|
|
28
|
+
activeProvider: null,
|
|
29
|
+
} as unknown as MerchantSettingsView;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeClient(view: MerchantSettingsView): PaymentsSettingsClient {
|
|
33
|
+
return {
|
|
34
|
+
getSettings: vi.fn().mockResolvedValue(view),
|
|
35
|
+
getSetupGuide: vi.fn().mockResolvedValue(null),
|
|
36
|
+
setEnabled: vi.fn(),
|
|
37
|
+
saveCredentials: vi.fn(),
|
|
38
|
+
} as unknown as PaymentsSettingsClient;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(cleanup);
|
|
42
|
+
|
|
43
|
+
describe('PaymentProviderSettings — the verification slot', () => {
|
|
44
|
+
it('is not rendered while the owner is still on the provider list', async () => {
|
|
45
|
+
const renderVerification = vi.fn(() => <div data-testid="slot" />);
|
|
46
|
+
render(
|
|
47
|
+
<PaymentProviderSettings
|
|
48
|
+
client={fakeClient(viewWith(null))}
|
|
49
|
+
renderVerification={renderVerification}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await screen.findByTestId('payments-provider-picker');
|
|
54
|
+
expect(renderVerification).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hands the host the open provider and its connection state', async () => {
|
|
58
|
+
const renderVerification = vi.fn(() => <div data-testid="slot" />);
|
|
59
|
+
render(
|
|
60
|
+
<PaymentProviderSettings
|
|
61
|
+
client={fakeClient(
|
|
62
|
+
viewWith({ provider: 'pagbank', status: 'VERIFIED', enabled: true, environments: {} }),
|
|
63
|
+
)}
|
|
64
|
+
initialProvider="pagbank"
|
|
65
|
+
renderVerification={renderVerification}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await screen.findByTestId('slot');
|
|
70
|
+
expect(renderVerification).toHaveBeenCalledWith(
|
|
71
|
+
expect.objectContaining({ provider: 'pagbank', connected: true }),
|
|
72
|
+
);
|
|
73
|
+
/**
|
|
74
|
+
* `enabled` is deliberately withheld. The step used it to render a
|
|
75
|
+
* standing "Provedor ativo" banner, which went on reassuring a store whose
|
|
76
|
+
* every charge was refused — the banner and the refusal were on screen
|
|
77
|
+
* together. The step reports what it observes, nothing else.
|
|
78
|
+
*/
|
|
79
|
+
expect(renderVerification).not.toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({ enabled: expect.anything() }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A provider with no stored row yet still renders the slot — the host decides
|
|
86
|
+
* to show nothing. Reporting `connected: false` is what lets it.
|
|
87
|
+
*/
|
|
88
|
+
it('reports a provider with no stored connection as not connected', async () => {
|
|
89
|
+
const renderVerification = vi.fn(() => <div data-testid="slot" />);
|
|
90
|
+
render(
|
|
91
|
+
<PaymentProviderSettings
|
|
92
|
+
client={fakeClient(viewWith(null))}
|
|
93
|
+
initialProvider="pagbank"
|
|
94
|
+
renderVerification={renderVerification}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await screen.findByTestId('slot');
|
|
99
|
+
expect(renderVerification).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({ connected: false }),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Placement, not just presence: the step must sit OUTSIDE the manual
|
|
106
|
+
* credentials disclosure. An owner who connected by OAuth is told no key
|
|
107
|
+
* needs copying, so they have no reason to open it — and the step that
|
|
108
|
+
* actually turns the store on would be hidden in there.
|
|
109
|
+
*/
|
|
110
|
+
it('places the step outside the manual-credentials disclosure', async () => {
|
|
111
|
+
render(
|
|
112
|
+
<PaymentProviderSettings
|
|
113
|
+
client={fakeClient(
|
|
114
|
+
viewWith({ provider: 'pagbank', status: 'VERIFIED', enabled: false, environments: {} }),
|
|
115
|
+
)}
|
|
116
|
+
initialProvider="pagbank"
|
|
117
|
+
renderVerification={() => <div data-testid="slot" />}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const slot = await screen.findByTestId('slot');
|
|
122
|
+
await waitFor(() => expect(screen.queryByTestId('payments-manual-fallback')).toBeNull());
|
|
123
|
+
expect(slot.closest('[data-testid="payments-manual-fallback"]')).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChargeRequestDraft,
|
|
3
|
+
ChargeStatus,
|
|
4
|
+
ClientChargeView,
|
|
5
|
+
ClientTokenization,
|
|
6
|
+
MaskedProviderConfig,
|
|
7
|
+
MerchantFailoverPolicy,
|
|
8
|
+
MerchantSettingsView,
|
|
9
|
+
PaymentEnvironment,
|
|
10
|
+
ProviderName,
|
|
11
|
+
ProviderSetupGuide,
|
|
12
|
+
SaveCredentialsInput,
|
|
13
|
+
VerifiedProviderConfig,
|
|
14
|
+
} from '@12-apps/payments-backend';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Browser-side API clients. They talk to the HOST's endpoints (which mount
|
|
18
|
+
* `@12-apps/payments-backend`'s HTTP handlers) — never to a provider directly.
|
|
19
|
+
* Endpoint paths are injected so every app can mount its routes wherever it
|
|
20
|
+
* likes; the shapes are fixed so the components work identically everywhere.
|
|
21
|
+
*
|
|
22
|
+
* Only TYPES are imported from the backend package — no server code ever
|
|
23
|
+
* enters a browser bundle.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Charge states the frontend should stop polling on. */
|
|
27
|
+
export function isSettled(status: ChargeStatus): boolean {
|
|
28
|
+
return status !== 'PENDING' && status !== 'AUTHORIZED';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the browser sends to create a charge. Card data is TOKENS ONLY, and
|
|
33
|
+
* there is deliberately no `amount`/`reference`: the SERVER resolves what is
|
|
34
|
+
* owed and for which order (see the backend's `resolveChargeRequest`), so a
|
|
35
|
+
* tampered client cannot underpay or point a payment at someone else's order.
|
|
36
|
+
*/
|
|
37
|
+
export type ClientChargeRequest = ChargeRequestDraft;
|
|
38
|
+
|
|
39
|
+
/** Client-safe tokenization config (see the backend's `clientConfig`). */
|
|
40
|
+
export interface ClientPaymentsConfig {
|
|
41
|
+
provider: ProviderName;
|
|
42
|
+
tokenization: ClientTokenization;
|
|
43
|
+
publicKey?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class PaymentsClientError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
readonly status: number,
|
|
49
|
+
message: string,
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'PaymentsClientError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PaymentsClientOptions {
|
|
57
|
+
/** e.g. `/api/payments` — host route prefix wrapping the gateway. */
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
60
|
+
fetchImpl?: typeof fetch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeRequest(options: PaymentsClientOptions) {
|
|
64
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
65
|
+
return async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
66
|
+
const res = await fetchImpl(`${options.baseUrl}${path}`, {
|
|
67
|
+
headers: { 'content-type': 'application/json' },
|
|
68
|
+
...init,
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const body = await res.text().catch(() => '');
|
|
72
|
+
throw new PaymentsClientError(res.status, body || `Payments request failed (${res.status})`);
|
|
73
|
+
}
|
|
74
|
+
return (await res.json()) as T;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Checkout-surface client (buyer-facing). */
|
|
79
|
+
export interface PaymentsClient {
|
|
80
|
+
getConfig(): Promise<ClientPaymentsConfig | null>;
|
|
81
|
+
createCharge(request: ClientChargeRequest): Promise<ClientChargeView>;
|
|
82
|
+
getCharge(provider: ProviderName, providerChargeId: string): Promise<ClientChargeView>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createPaymentsClient(options: PaymentsClientOptions): PaymentsClient {
|
|
86
|
+
const request = makeRequest(options);
|
|
87
|
+
return {
|
|
88
|
+
getConfig: () => request<ClientPaymentsConfig | null>('/config'),
|
|
89
|
+
createCharge: (body) =>
|
|
90
|
+
request<ClientChargeView>('/charges', { method: 'POST', body: JSON.stringify(body) }),
|
|
91
|
+
getCharge: (provider, providerChargeId) =>
|
|
92
|
+
request<ClientChargeView>(
|
|
93
|
+
`/charges/${encodeURIComponent(provider)}/${encodeURIComponent(providerChargeId)}`,
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Settings-surface client (merchant-admin-facing). */
|
|
99
|
+
export interface PaymentsSettingsClient {
|
|
100
|
+
/**
|
|
101
|
+
* The host prefix this client was built on, echoed back.
|
|
102
|
+
*
|
|
103
|
+
* Exposed because it is the only MERCHANT-SCOPED identifier the components
|
|
104
|
+
* have: the package is deliberately ignorant of tenants, and a host mounts
|
|
105
|
+
* one client per store (`/api/admin/<slug>/payments`). Anything the browser
|
|
106
|
+
* remembers per store — a setup step the owner has ticked off — has to be
|
|
107
|
+
* keyed on something, and this is it. Read-only; nothing derives a request
|
|
108
|
+
* path from it.
|
|
109
|
+
*/
|
|
110
|
+
readonly baseUrl: string;
|
|
111
|
+
getSettings(): Promise<MerchantSettingsView>;
|
|
112
|
+
saveCredentials(provider: ProviderName, input: SaveCredentialsInput): Promise<MaskedProviderConfig>;
|
|
113
|
+
setEnabled(provider: ProviderName, enabled: boolean): Promise<MaskedProviderConfig>;
|
|
114
|
+
/**
|
|
115
|
+
* Replace the whole failover chain in priority order. Sends the COMPLETE
|
|
116
|
+
* enabled set, not a delta — a reorder is one intent, and splitting it into
|
|
117
|
+
* per-provider writes is what allows a half-applied order.
|
|
118
|
+
*/
|
|
119
|
+
setPriorities(ordered: readonly ProviderName[]): Promise<MerchantSettingsView>;
|
|
120
|
+
/** Whether a declined card may be retried on the next acquirer. */
|
|
121
|
+
setFailoverPolicy(policy: MerchantFailoverPolicy): Promise<MerchantSettingsView>;
|
|
122
|
+
/**
|
|
123
|
+
* Probe the stored credentials for `environment` (default: the active one).
|
|
124
|
+
*
|
|
125
|
+
* The reply carries `probe` because the stored `status` answers only for the
|
|
126
|
+
* ACTIVE environment — testing the other tab reports its result without
|
|
127
|
+
* overwriting a verdict that describes different credentials.
|
|
128
|
+
*/
|
|
129
|
+
verify(
|
|
130
|
+
provider: ProviderName,
|
|
131
|
+
environment?: PaymentEnvironment,
|
|
132
|
+
): Promise<VerifiedProviderConfig>;
|
|
133
|
+
/** Null when the provider ships no walkthrough (endpoint 404s). */
|
|
134
|
+
getSetupGuide(provider: ProviderName): Promise<ProviderSetupGuide | null>;
|
|
135
|
+
/**
|
|
136
|
+
* Start an OAuth connect. `state` is minted and stored SERVER-side by the
|
|
137
|
+
* host against the admin session; the browser only carries it onward.
|
|
138
|
+
*/
|
|
139
|
+
beginOAuth(
|
|
140
|
+
provider: ProviderName,
|
|
141
|
+
body: { state: string; redirectUri: string; environment?: 'SANDBOX' | 'PRODUCTION' },
|
|
142
|
+
): Promise<{ url: string; state: string }>;
|
|
143
|
+
disconnectOAuth(provider: ProviderName): Promise<{ disconnected: boolean }>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createPaymentsSettingsClient(
|
|
147
|
+
options: PaymentsClientOptions,
|
|
148
|
+
): PaymentsSettingsClient {
|
|
149
|
+
const request = makeRequest(options);
|
|
150
|
+
const providerPath = (provider: ProviderName, suffix = ''): string =>
|
|
151
|
+
`/settings/providers/${encodeURIComponent(provider)}${suffix}`;
|
|
152
|
+
return {
|
|
153
|
+
baseUrl: options.baseUrl,
|
|
154
|
+
getSettings: () => request<MerchantSettingsView>('/settings'),
|
|
155
|
+
saveCredentials: (provider, input) =>
|
|
156
|
+
request<MaskedProviderConfig>(providerPath(provider), {
|
|
157
|
+
method: 'PUT',
|
|
158
|
+
body: JSON.stringify(input),
|
|
159
|
+
}),
|
|
160
|
+
setEnabled: (provider, enabled) =>
|
|
161
|
+
request<MaskedProviderConfig>(providerPath(provider, '/enabled'), {
|
|
162
|
+
method: 'PUT',
|
|
163
|
+
body: JSON.stringify({ enabled }),
|
|
164
|
+
}),
|
|
165
|
+
setFailoverPolicy: (policy) =>
|
|
166
|
+
request<MerchantSettingsView>('/settings/failover-policy', {
|
|
167
|
+
method: 'PUT',
|
|
168
|
+
body: JSON.stringify({ policy }),
|
|
169
|
+
}),
|
|
170
|
+
setPriorities: (ordered) =>
|
|
171
|
+
request<MerchantSettingsView>('/settings/priorities', {
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
body: JSON.stringify({ providers: ordered }),
|
|
174
|
+
}),
|
|
175
|
+
verify: (provider, environment) =>
|
|
176
|
+
request<VerifiedProviderConfig>(
|
|
177
|
+
providerPath(provider, environment ? `/verify?environment=${environment}` : '/verify'),
|
|
178
|
+
{ method: 'POST' },
|
|
179
|
+
),
|
|
180
|
+
// Deliberately NOT under `/providers/...`: the guide is READ-ONLY public
|
|
181
|
+
// content, while everything under `/providers` writes or tests a
|
|
182
|
+
// credential. Hosts gate the two differently (this repo's agent surface
|
|
183
|
+
// exposes the guide and forbids the writes), which a shared prefix would
|
|
184
|
+
// make impossible to express.
|
|
185
|
+
getSetupGuide: (provider) =>
|
|
186
|
+
request<ProviderSetupGuide>(`/settings/guides/${encodeURIComponent(provider)}`).catch((err: unknown) => {
|
|
187
|
+
if (err instanceof PaymentsClientError && err.status === 404) return null;
|
|
188
|
+
throw err;
|
|
189
|
+
}),
|
|
190
|
+
beginOAuth: (provider, body) =>
|
|
191
|
+
request<{ url: string; state: string }>(providerPath(provider, '/oauth/begin'), {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
body: JSON.stringify(body),
|
|
194
|
+
}),
|
|
195
|
+
disconnectOAuth: (provider) =>
|
|
196
|
+
request<{ disconnected: boolean }>(providerPath(provider, '/oauth/disconnect'), {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Button,
|
|
6
|
+
Checkbox,
|
|
7
|
+
FormControlLabel,
|
|
8
|
+
Stack,
|
|
9
|
+
Step,
|
|
10
|
+
StepLabel,
|
|
11
|
+
Stepper,
|
|
12
|
+
TextField,
|
|
13
|
+
Typography,
|
|
14
|
+
} from '@mui/material';
|
|
15
|
+
import { useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-backend';
|
|
18
|
+
|
|
19
|
+
import { CheckoutPayment, type CheckoutPaymentProps } from './CheckoutPayment';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The full plug-and-play checkout: the three-step flow of the buyer-facing
|
|
23
|
+
* screens — Dados (CPF required; name/email/phone optional, "save my data")
|
|
24
|
+
* → Pagamento (method cards, saved cards, pay button — `CheckoutPayment`)
|
|
25
|
+
* → Confirmação. The host supplies cart totals, prefilled customer data,
|
|
26
|
+
* saved cards, and reacts to `onCustomerConfirmed` / `onPaid`.
|
|
27
|
+
*/
|
|
28
|
+
export interface CheckoutFlowProps
|
|
29
|
+
extends Omit<CheckoutPaymentProps, 'customer' | 'onPaid'> {
|
|
30
|
+
initialCustomer?: Partial<CustomerInfo>;
|
|
31
|
+
/** Default state of the "save my data for next time" checkbox. */
|
|
32
|
+
saveDataDefault?: boolean;
|
|
33
|
+
/** Fired when the buyer completes step 1 (persist profile if saveData). */
|
|
34
|
+
onCustomerConfirmed?: (customer: CustomerInfo, saveData: boolean) => void;
|
|
35
|
+
onPaid?: (charge: ClientChargeView) => void;
|
|
36
|
+
/** e.g. "1 item" — shown next to the total in the footer. */
|
|
37
|
+
itemsLabel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STEPS = ['Dados', 'Pagamento', 'Confirmação'];
|
|
41
|
+
|
|
42
|
+
/** CPF: 11 digits (checksum stays server/provider-side; this is UX-level). */
|
|
43
|
+
function cpfDigits(value: string): string {
|
|
44
|
+
return value.replace(/\D/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CustomerStepProps {
|
|
48
|
+
amount: Money;
|
|
49
|
+
itemsLabel?: string;
|
|
50
|
+
initial: Partial<CustomerInfo>;
|
|
51
|
+
saveDataDefault: boolean;
|
|
52
|
+
onContinue: (customer: CustomerInfo, saveData: boolean) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function CustomerStep({ amount, itemsLabel, initial, saveDataDefault, onContinue }: CustomerStepProps) {
|
|
56
|
+
const [taxId, setTaxId] = useState(initial.taxId ?? '');
|
|
57
|
+
const [name, setName] = useState(initial.name ?? '');
|
|
58
|
+
const [email, setEmail] = useState(initial.email ?? '');
|
|
59
|
+
const [phone, setPhone] = useState(initial.phone ?? '');
|
|
60
|
+
const [saveData, setSaveData] = useState(saveDataDefault);
|
|
61
|
+
const [touched, setTouched] = useState(false);
|
|
62
|
+
const cpfInvalid = cpfDigits(taxId).length !== 11;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Stack spacing={2}>
|
|
66
|
+
<Typography variant="body2" color="text.secondary">
|
|
67
|
+
Informe seu CPF (obrigatório para o pagamento). Nome, e-mail e telefone são opcionais —
|
|
68
|
+
usados apenas para o comprovante.
|
|
69
|
+
</Typography>
|
|
70
|
+
<TextField
|
|
71
|
+
size="small"
|
|
72
|
+
label="CPF"
|
|
73
|
+
required
|
|
74
|
+
value={taxId}
|
|
75
|
+
error={touched && cpfInvalid}
|
|
76
|
+
helperText={touched && cpfInvalid ? 'Informe um CPF com 11 dígitos.' : undefined}
|
|
77
|
+
onBlur={() => setTouched(true)}
|
|
78
|
+
onChange={(e) => setTaxId(e.target.value)}
|
|
79
|
+
/>
|
|
80
|
+
<TextField size="small" label="Nome" value={name} onChange={(e) => setName(e.target.value)} />
|
|
81
|
+
<TextField size="small" label="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
82
|
+
<TextField size="small" label="Telefone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
|
83
|
+
<FormControlLabel
|
|
84
|
+
control={<Checkbox checked={saveData} onChange={(_, v) => setSaveData(v)} />}
|
|
85
|
+
label="Salvar meus dados para a próxima compra"
|
|
86
|
+
/>
|
|
87
|
+
<Stack direction="row" spacing={2} alignItems="center">
|
|
88
|
+
<Typography variant="body2" color="text.secondary">
|
|
89
|
+
{itemsLabel ? `Total · ${itemsLabel} — ` : 'Total: '}
|
|
90
|
+
{(amount.amountCents / 100).toLocaleString('pt-BR', { style: 'currency', currency: amount.currency })}
|
|
91
|
+
</Typography>
|
|
92
|
+
<Button
|
|
93
|
+
variant="contained"
|
|
94
|
+
fullWidth
|
|
95
|
+
disabled={cpfInvalid}
|
|
96
|
+
onClick={() => onContinue({ name, email, taxId: cpfDigits(taxId), phone }, saveData)}
|
|
97
|
+
>
|
|
98
|
+
Continuar
|
|
99
|
+
</Button>
|
|
100
|
+
</Stack>
|
|
101
|
+
<Typography variant="caption" color="text.secondary" textAlign="right">
|
|
102
|
+
🔒 Pagamento seguro
|
|
103
|
+
</Typography>
|
|
104
|
+
</Stack>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function CheckoutFlow(props: CheckoutFlowProps) {
|
|
109
|
+
const {
|
|
110
|
+
initialCustomer = {},
|
|
111
|
+
saveDataDefault = true,
|
|
112
|
+
onCustomerConfirmed,
|
|
113
|
+
onPaid,
|
|
114
|
+
itemsLabel,
|
|
115
|
+
...payment
|
|
116
|
+
} = props;
|
|
117
|
+
const [step, setStep] = useState(0);
|
|
118
|
+
const [customer, setCustomer] = useState<CustomerInfo | null>(null);
|
|
119
|
+
const [paid, setPaid] = useState<ClientChargeView | null>(null);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Stack spacing={3}>
|
|
123
|
+
<Stepper activeStep={step} alternativeLabel>
|
|
124
|
+
{STEPS.map((label) => (
|
|
125
|
+
<Step key={label}>
|
|
126
|
+
<StepLabel>{label}</StepLabel>
|
|
127
|
+
</Step>
|
|
128
|
+
))}
|
|
129
|
+
</Stepper>
|
|
130
|
+
|
|
131
|
+
{step === 0 ? (
|
|
132
|
+
<CustomerStep
|
|
133
|
+
amount={payment.amount}
|
|
134
|
+
itemsLabel={itemsLabel}
|
|
135
|
+
initial={initialCustomer}
|
|
136
|
+
saveDataDefault={saveDataDefault}
|
|
137
|
+
onContinue={(confirmed, saveData) => {
|
|
138
|
+
setCustomer(confirmed);
|
|
139
|
+
onCustomerConfirmed?.(confirmed, saveData);
|
|
140
|
+
setStep(1);
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
{step === 1 && customer ? (
|
|
146
|
+
<Stack spacing={1}>
|
|
147
|
+
<Button size="small" sx={{ alignSelf: 'flex-start' }} onClick={() => setStep(0)}>
|
|
148
|
+
← Voltar
|
|
149
|
+
</Button>
|
|
150
|
+
<CheckoutPayment
|
|
151
|
+
{...payment}
|
|
152
|
+
customer={customer}
|
|
153
|
+
onPaid={(charge) => {
|
|
154
|
+
setPaid(charge);
|
|
155
|
+
setStep(2);
|
|
156
|
+
onPaid?.(charge);
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
</Stack>
|
|
160
|
+
) : null}
|
|
161
|
+
|
|
162
|
+
{step === 2 && paid ? (
|
|
163
|
+
<Alert severity="success">
|
|
164
|
+
Pagamento confirmado — {paid.provider} · {paid.providerChargeId}
|
|
165
|
+
</Alert>
|
|
166
|
+
) : null}
|
|
167
|
+
</Stack>
|
|
168
|
+
);
|
|
169
|
+
}
|