@greatapps/common 1.1.738 → 1.1.740
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/dist/components/modals/billing/RequiredBillingDataModal.mjs +448 -0
- package/dist/components/modals/billing/RequiredBillingDataModal.mjs.map +1 -0
- package/dist/components/modals/cards/AddCardModal.mjs +26 -28
- package/dist/components/modals/cards/AddCardModal.mjs.map +1 -1
- package/dist/components/modals/cards/CardFormFields.mjs +10 -9
- package/dist/components/modals/cards/CardFormFields.mjs.map +1 -1
- package/dist/i18n/messages/en-us.mjs +39 -0
- package/dist/i18n/messages/en-us.mjs.map +1 -1
- package/dist/i18n/messages/es-es.mjs +39 -0
- package/dist/i18n/messages/es-es.mjs.map +1 -1
- package/dist/i18n/messages/pt-br.mjs +40 -0
- package/dist/i18n/messages/pt-br.mjs.map +1 -1
- package/dist/index.mjs +48 -32
- package/dist/index.mjs.map +1 -1
- package/dist/modules/accounts/actions/account-management.action.mjs +15 -0
- package/dist/modules/accounts/actions/account-management.action.mjs.map +1 -1
- package/dist/modules/accounts/hooks/use-required-billing-data.hook.mjs +26 -0
- package/dist/modules/accounts/hooks/use-required-billing-data.hook.mjs.map +1 -0
- package/dist/modules/accounts/hooks/useAccountManagement.mjs +13 -1
- package/dist/modules/accounts/hooks/useAccountManagement.mjs.map +1 -1
- package/dist/modules/accounts/utils/billing-data.mjs +36 -0
- package/dist/modules/accounts/utils/billing-data.mjs.map +1 -0
- package/dist/modules/cards/hooks/create-card.hook.mjs +11 -0
- package/dist/modules/cards/hooks/create-card.hook.mjs.map +1 -1
- package/dist/modules/cards/types.mjs +20 -1
- package/dist/modules/cards/types.mjs.map +1 -1
- package/dist/modules/subscriptions/utils/has-paid-subscription.mjs +13 -0
- package/dist/modules/subscriptions/utils/has-paid-subscription.mjs.map +1 -0
- package/dist/server.mjs +2 -0
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/modals/billing/RequiredBillingDataModal.tsx +548 -0
- package/src/components/modals/cards/AddCardModal.tsx +36 -30
- package/src/components/modals/cards/CardFormFields.tsx +18 -10
- package/src/i18n/messages/en-us.ts +40 -0
- package/src/i18n/messages/es-es.ts +40 -0
- package/src/i18n/messages/pt-br.ts +42 -0
- package/src/index.ts +11 -0
- package/src/modules/accounts/actions/account-management.action.ts +24 -0
- package/src/modules/accounts/hooks/use-required-billing-data.hook.ts +38 -0
- package/src/modules/accounts/hooks/useAccountManagement.ts +13 -0
- package/src/modules/accounts/types.ts +11 -0
- package/src/modules/accounts/utils/billing-data.ts +74 -0
- package/src/modules/cards/hooks/create-card.hook.ts +11 -0
- package/src/modules/cards/types.ts +19 -0
- package/src/modules/subscriptions/utils/has-paid-subscription.ts +27 -0
- package/src/server.ts +1 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- o generic Translator do next-intl não é
|
|
2
|
+
expressável estruturalmente; o `any` do TranslateFn é intencional e está contido neste arquivo
|
|
3
|
+
(mesmo padrão de modules/plans/utils/map-api-plan-to-ui.ts). */
|
|
4
|
+
'use client';
|
|
5
|
+
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
8
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
9
|
+
import { useTranslations } from 'next-intl';
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
import z from 'zod';
|
|
12
|
+
import { IconFileInvoice } from '@tabler/icons-react';
|
|
13
|
+
|
|
14
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/overlay/Dialog';
|
|
15
|
+
import { Button } from '../../ui/buttons/Button';
|
|
16
|
+
import { FormField } from '../../ui/form/FormField';
|
|
17
|
+
import { SelectField } from '../../ui/form/SelectField';
|
|
18
|
+
import { Toast } from '../../ui/feedback/Toast';
|
|
19
|
+
import { useAuth } from '../../../providers/auth.provider';
|
|
20
|
+
import { useViaCep } from '../../../modules/accounts/hooks/useViaCep';
|
|
21
|
+
import { useUpdateBillingData } from '../../../modules/accounts/hooks/useAccountManagement';
|
|
22
|
+
import { useRequiredBillingData } from '../../../modules/accounts/hooks/use-required-billing-data.hook';
|
|
23
|
+
import { COUNTRIES } from '../../../utils/countries';
|
|
24
|
+
import { BR_STATE_OPTIONS } from '../../../utils/constants/br-states';
|
|
25
|
+
import { formatCNPJ, formatCPF, formatPostalCode } from '../../../utils/format/masks';
|
|
26
|
+
import { isValidCNPJ, isValidCPF } from '../../../utils/validators/common';
|
|
27
|
+
import type { UpdateBillingDataRequest } from '../../../modules/accounts/types';
|
|
28
|
+
|
|
29
|
+
type TranslateFn = (key: any, values?: Record<string, string | number>) => string;
|
|
30
|
+
|
|
31
|
+
type BillingDataFormValues = {
|
|
32
|
+
personType: 'pf' | 'pj';
|
|
33
|
+
document: string;
|
|
34
|
+
financialName: string;
|
|
35
|
+
financialEmail: string;
|
|
36
|
+
cep: string;
|
|
37
|
+
street: string;
|
|
38
|
+
streetNumber: string;
|
|
39
|
+
complement?: string;
|
|
40
|
+
neighborhood: string;
|
|
41
|
+
city: string;
|
|
42
|
+
state: string;
|
|
43
|
+
country: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const COUNTRY_OPTIONS = COUNTRIES.map((country: { iso: string; name: string }) => ({
|
|
47
|
+
value: country.iso,
|
|
48
|
+
label: country.name,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
function buildBillingDataSchema(isInternational: boolean, translate: TranslateFn) {
|
|
52
|
+
const requiredText = z
|
|
53
|
+
.string()
|
|
54
|
+
.trim()
|
|
55
|
+
.min(1, translate('common.billing.requiredData.requiredField'));
|
|
56
|
+
|
|
57
|
+
const base = z.object({
|
|
58
|
+
personType: z.enum(['pf', 'pj']),
|
|
59
|
+
document: z.string().trim(),
|
|
60
|
+
financialName: requiredText,
|
|
61
|
+
financialEmail: z
|
|
62
|
+
.string()
|
|
63
|
+
.trim()
|
|
64
|
+
.min(1, translate('common.billing.requiredData.requiredField'))
|
|
65
|
+
.email(translate('common.billing.requiredData.invalidEmail')),
|
|
66
|
+
cep: requiredText,
|
|
67
|
+
street: requiredText,
|
|
68
|
+
streetNumber: z.string().trim(),
|
|
69
|
+
complement: z.string().trim().optional(),
|
|
70
|
+
neighborhood: z.string().trim(),
|
|
71
|
+
city: requiredText,
|
|
72
|
+
state: requiredText,
|
|
73
|
+
country: requiredText,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* Conta internacional não tem CPF/CNPJ, número nem bairro — mesmo recorte do
|
|
77
|
+
* AddCardModal. Exigir esses campos aqui trancaria o cliente fora do app, já que
|
|
78
|
+
* o modal é bloqueante. */
|
|
79
|
+
if (isInternational) return base;
|
|
80
|
+
|
|
81
|
+
return base.superRefine((data, ctx) => {
|
|
82
|
+
if (!data.streetNumber) {
|
|
83
|
+
ctx.addIssue({
|
|
84
|
+
code: 'custom',
|
|
85
|
+
path: ['streetNumber'],
|
|
86
|
+
message: translate('common.billing.requiredData.requiredField'),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!data.neighborhood) {
|
|
91
|
+
ctx.addIssue({
|
|
92
|
+
code: 'custom',
|
|
93
|
+
path: ['neighborhood'],
|
|
94
|
+
message: translate('common.billing.requiredData.requiredField'),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (data.cep.replace(/\D/g, '').length !== 8) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: 'custom',
|
|
101
|
+
path: ['cep'],
|
|
102
|
+
message: translate('common.billing.requiredData.invalidCep'),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isCompany = data.personType === 'pj';
|
|
107
|
+
const digits = data.document.replace(/\D/g, '');
|
|
108
|
+
|
|
109
|
+
if (digits.length !== (isCompany ? 14 : 11)) {
|
|
110
|
+
ctx.addIssue({
|
|
111
|
+
code: 'custom',
|
|
112
|
+
path: ['document'],
|
|
113
|
+
message: isCompany
|
|
114
|
+
? translate('common.billing.requiredData.invalidCnpj')
|
|
115
|
+
: translate('common.billing.requiredData.invalidCpf'),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Dígito verificador, mesmo algoritmo do backend: sem isso 111.111.111-11 passa
|
|
121
|
+
* aqui e o PUT da conta é recusado depois, sem o usuário saber o motivo. */
|
|
122
|
+
const isValid = isCompany ? isValidCNPJ(digits) : isValidCPF(digits);
|
|
123
|
+
if (!isValid) {
|
|
124
|
+
ctx.addIssue({
|
|
125
|
+
code: 'custom',
|
|
126
|
+
path: ['document'],
|
|
127
|
+
message: isCompany
|
|
128
|
+
? translate('common.billing.requiredData.invalidCnpjChecksum')
|
|
129
|
+
: translate('common.billing.requiredData.invalidCpfChecksum'),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatDocument(document: string | null | undefined, isCompany: boolean) {
|
|
136
|
+
if (!document) return '';
|
|
137
|
+
return isCompany ? formatCNPJ(document) : formatCPF(document);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function RequiredBillingDataModalContent() {
|
|
141
|
+
const translate = useTranslations();
|
|
142
|
+
const { user } = useAuth();
|
|
143
|
+
const { isRequired, account, isInternational } = useRequiredBillingData();
|
|
144
|
+
const { mutateAsync: updateBillingData } = useUpdateBillingData();
|
|
145
|
+
const { mutateAsync: lookupCep } = useViaCep();
|
|
146
|
+
|
|
147
|
+
/* O modal fecha na hora do submit e não reabre enquanto o invalidate do account
|
|
148
|
+
* não volta — sem isso o usuário vê o modal piscar de novo com o cache antigo. */
|
|
149
|
+
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
150
|
+
const isPrefilled = useRef(false);
|
|
151
|
+
|
|
152
|
+
const schema = useMemo(
|
|
153
|
+
() => buildBillingDataSchema(isInternational, translate),
|
|
154
|
+
[isInternational, translate],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const form = useForm<BillingDataFormValues>({
|
|
158
|
+
resolver: zodResolver(schema),
|
|
159
|
+
mode: 'onChange',
|
|
160
|
+
defaultValues: {
|
|
161
|
+
personType: 'pf',
|
|
162
|
+
document: '',
|
|
163
|
+
financialName: '',
|
|
164
|
+
financialEmail: '',
|
|
165
|
+
cep: '',
|
|
166
|
+
street: '',
|
|
167
|
+
streetNumber: '',
|
|
168
|
+
complement: '',
|
|
169
|
+
neighborhood: '',
|
|
170
|
+
city: '',
|
|
171
|
+
state: '',
|
|
172
|
+
country: 'BR',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const {
|
|
177
|
+
register,
|
|
178
|
+
control,
|
|
179
|
+
handleSubmit,
|
|
180
|
+
reset,
|
|
181
|
+
setValue,
|
|
182
|
+
watch,
|
|
183
|
+
formState: { errors, isSubmitting, isValid },
|
|
184
|
+
} = form;
|
|
185
|
+
|
|
186
|
+
const personType = watch('personType');
|
|
187
|
+
const isCompany = personType === 'pj';
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!account || isPrefilled.current) return;
|
|
191
|
+
isPrefilled.current = true;
|
|
192
|
+
|
|
193
|
+
const accountIsCompany = account.financial_document_type === 2;
|
|
194
|
+
reset({
|
|
195
|
+
personType: accountIsCompany ? 'pj' : 'pf',
|
|
196
|
+
document: formatDocument(account.financial_document, accountIsCompany),
|
|
197
|
+
financialName: account.financial_name ?? '',
|
|
198
|
+
financialEmail: account.financial_email || user?.email || '',
|
|
199
|
+
cep: account.zipcode ?? '',
|
|
200
|
+
street: account.address ?? '',
|
|
201
|
+
streetNumber: account.address_number ?? '',
|
|
202
|
+
complement: account.address_complement ?? '',
|
|
203
|
+
neighborhood: account.neighborhood ?? '',
|
|
204
|
+
city: account.city ?? '',
|
|
205
|
+
state: account.state ?? '',
|
|
206
|
+
country: account.country || (isInternational ? 'US' : 'BR'),
|
|
207
|
+
});
|
|
208
|
+
}, [account, isInternational, reset, user?.email]);
|
|
209
|
+
|
|
210
|
+
const handleDocumentChange = (rawValue: string) => {
|
|
211
|
+
setValue('document', isCompany ? formatCNPJ(rawValue) : formatCPF(rawValue), {
|
|
212
|
+
shouldValidate: true,
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const handlePersonTypeChange = (value: 'pf' | 'pj') => {
|
|
217
|
+
if (value === personType) return;
|
|
218
|
+
setValue('document', '');
|
|
219
|
+
/* shouldValidate reroda o resolver: sem isso o `isValid` fica preso no valor
|
|
220
|
+
* do documento antigo e o botão continua habilitado com o campo já limpo. */
|
|
221
|
+
setValue('personType', value, { shouldValidate: true });
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const handleCepChange = (rawValue: string) => {
|
|
225
|
+
if (isInternational) {
|
|
226
|
+
setValue('cep', rawValue, { shouldValidate: true });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setValue('cep', formatPostalCode(rawValue), { shouldValidate: true });
|
|
231
|
+
|
|
232
|
+
const digits = rawValue.replace(/\D/g, '');
|
|
233
|
+
if (digits.length !== 8) return;
|
|
234
|
+
|
|
235
|
+
lookupCep(digits)
|
|
236
|
+
.then((data) => {
|
|
237
|
+
setValue('street', data.logradouro, { shouldValidate: true });
|
|
238
|
+
setValue('neighborhood', data.bairro, { shouldValidate: true });
|
|
239
|
+
setValue('city', data.localidade, { shouldValidate: true });
|
|
240
|
+
setValue('state', data.uf, { shouldValidate: true });
|
|
241
|
+
})
|
|
242
|
+
.catch(() => {
|
|
243
|
+
// CEP não encontrado: o usuário preenche o endereço na mão.
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
async function onSubmit(data: BillingDataFormValues) {
|
|
248
|
+
const payload: UpdateBillingDataRequest = {
|
|
249
|
+
...(isInternational
|
|
250
|
+
? {}
|
|
251
|
+
: {
|
|
252
|
+
financial_document_type: data.personType === 'pf' ? 1 : 2,
|
|
253
|
+
financial_document: data.document,
|
|
254
|
+
}),
|
|
255
|
+
financial_name: data.financialName,
|
|
256
|
+
financial_email: data.financialEmail,
|
|
257
|
+
zipcode: data.cep,
|
|
258
|
+
address: data.street,
|
|
259
|
+
address_number: data.streetNumber,
|
|
260
|
+
address_complement: data.complement ?? '',
|
|
261
|
+
neighborhood: data.neighborhood,
|
|
262
|
+
city: data.city,
|
|
263
|
+
state: data.state,
|
|
264
|
+
country: data.country,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await updateBillingData(payload);
|
|
269
|
+
setIsSubmitted(true);
|
|
270
|
+
toast.custom(
|
|
271
|
+
(t) => (
|
|
272
|
+
<Toast
|
|
273
|
+
variant="success"
|
|
274
|
+
message={translate('common.billing.requiredData.successToast')}
|
|
275
|
+
toastId={t}
|
|
276
|
+
/>
|
|
277
|
+
),
|
|
278
|
+
{ duration: 5000 },
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
/* O backend devolve a razão exata da recusa (ex.: documento fiscal inválido).
|
|
282
|
+
* Trocar por um texto genérico deixaria o usuário travado sem saber o que corrigir. */
|
|
283
|
+
const message =
|
|
284
|
+
error instanceof Error && error.message
|
|
285
|
+
? error.message
|
|
286
|
+
: translate('common.billing.requiredData.errorToast');
|
|
287
|
+
toast.custom((t) => <Toast variant="error" message={message} toastId={t} />, {
|
|
288
|
+
duration: 5000,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!isRequired || isSubmitted) return null;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<Dialog open onOpenChange={() => {}}>
|
|
297
|
+
<DialogContent
|
|
298
|
+
showCloseButton={false}
|
|
299
|
+
onEscapeKeyDown={(event: Event) => event.preventDefault()}
|
|
300
|
+
onPointerDownOutside={(event: Event) => event.preventDefault()}
|
|
301
|
+
onInteractOutside={(event: Event) => event.preventDefault()}
|
|
302
|
+
className="flex flex-col p-0 gap-0 max-w-full sm:max-w-full border-0 rounded-t-2xl rounded-b-none h-dvh top-0 bottom-0 left-0 right-0 translate-x-0 translate-y-0 lg:max-w-[502px] lg:h-auto lg:max-h-[90vh] lg:rounded-lg lg:border lg:top-[50%] lg:left-[50%] lg:right-auto lg:bottom-auto lg:translate-x-[-50%] lg:translate-y-[-50%] overflow-hidden"
|
|
303
|
+
>
|
|
304
|
+
<DialogHeader className="gap-3 text-left p-4 lg:p-5 border-b border-zinc-200 shrink-0">
|
|
305
|
+
<div className="flex items-center justify-center w-10 h-10 bg-zinc-50 rounded-lg">
|
|
306
|
+
<IconFileInvoice size={24} className="text-zinc-950" />
|
|
307
|
+
</div>
|
|
308
|
+
<div className="flex flex-col gap-2">
|
|
309
|
+
<DialogTitle className="paragraph-medium-semibold text-zinc-950">
|
|
310
|
+
{translate('common.billing.requiredData.title')}
|
|
311
|
+
</DialogTitle>
|
|
312
|
+
<span className="paragraph-small-regular text-zinc-600">
|
|
313
|
+
{translate('common.billing.requiredData.description')}
|
|
314
|
+
</span>
|
|
315
|
+
</div>
|
|
316
|
+
</DialogHeader>
|
|
317
|
+
|
|
318
|
+
<form
|
|
319
|
+
onSubmit={handleSubmit(onSubmit)}
|
|
320
|
+
className="flex flex-col flex-1 min-h-0"
|
|
321
|
+
noValidate
|
|
322
|
+
>
|
|
323
|
+
<div className="flex flex-col gap-6 p-4 lg:p-5 flex-1 overflow-y-auto">
|
|
324
|
+
<div className="flex flex-col gap-5">
|
|
325
|
+
{!isInternational && (
|
|
326
|
+
<div className="flex gap-4">
|
|
327
|
+
<Controller
|
|
328
|
+
name="personType"
|
|
329
|
+
control={control}
|
|
330
|
+
render={({ field }) => (
|
|
331
|
+
<SelectField
|
|
332
|
+
label={translate('common.billing.requiredData.personType')}
|
|
333
|
+
placeholder={translate('common.billing.requiredData.select')}
|
|
334
|
+
value={field.value}
|
|
335
|
+
onChange={(value) => handlePersonTypeChange(value as 'pf' | 'pj')}
|
|
336
|
+
options={[
|
|
337
|
+
{
|
|
338
|
+
value: 'pf',
|
|
339
|
+
label: translate('common.billing.requiredData.personTypePf'),
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
value: 'pj',
|
|
343
|
+
label: translate('common.billing.requiredData.personTypePj'),
|
|
344
|
+
},
|
|
345
|
+
]}
|
|
346
|
+
containerClassName="flex-1"
|
|
347
|
+
/>
|
|
348
|
+
)}
|
|
349
|
+
/>
|
|
350
|
+
<div className="flex-1">
|
|
351
|
+
<FormField
|
|
352
|
+
label={
|
|
353
|
+
isCompany
|
|
354
|
+
? translate('common.billing.requiredData.cnpj')
|
|
355
|
+
: translate('common.billing.requiredData.cpf')
|
|
356
|
+
}
|
|
357
|
+
placeholder={isCompany ? '__.___.___/____-__' : '___.___.___-__'}
|
|
358
|
+
error={!!errors.document}
|
|
359
|
+
errorMessage={errors.document?.message}
|
|
360
|
+
{...register('document', {
|
|
361
|
+
onChange: (event) => handleDocumentChange(event.target.value),
|
|
362
|
+
})}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
<FormField
|
|
369
|
+
label={
|
|
370
|
+
isCompany
|
|
371
|
+
? translate('common.billing.requiredData.companyName')
|
|
372
|
+
: translate('common.billing.requiredData.fullName')
|
|
373
|
+
}
|
|
374
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
375
|
+
error={!!errors.financialName}
|
|
376
|
+
errorMessage={errors.financialName?.message}
|
|
377
|
+
{...register('financialName')}
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
<FormField
|
|
381
|
+
label={translate('common.billing.requiredData.financialEmail')}
|
|
382
|
+
placeholder={translate('common.billing.requiredData.financialEmailPlaceholder')}
|
|
383
|
+
type="email"
|
|
384
|
+
error={!!errors.financialEmail}
|
|
385
|
+
errorMessage={errors.financialEmail?.message}
|
|
386
|
+
{...register('financialEmail')}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<div className="h-px bg-zinc-200" />
|
|
391
|
+
|
|
392
|
+
<div className="flex flex-col gap-5">
|
|
393
|
+
<span className="paragraph-small-semibold text-zinc-950">
|
|
394
|
+
{translate('common.billing.requiredData.addressHeading')}
|
|
395
|
+
</span>
|
|
396
|
+
|
|
397
|
+
<FormField
|
|
398
|
+
label={
|
|
399
|
+
isInternational
|
|
400
|
+
? translate('common.billing.requiredData.postalCode')
|
|
401
|
+
: translate('common.billing.requiredData.cep')
|
|
402
|
+
}
|
|
403
|
+
placeholder={isInternational ? '' : '_____-___'}
|
|
404
|
+
error={!!errors.cep}
|
|
405
|
+
errorMessage={errors.cep?.message}
|
|
406
|
+
{...register('cep', {
|
|
407
|
+
onChange: (event) => handleCepChange(event.target.value),
|
|
408
|
+
})}
|
|
409
|
+
/>
|
|
410
|
+
|
|
411
|
+
<div className="flex gap-4">
|
|
412
|
+
<div className="flex-[3]">
|
|
413
|
+
<FormField
|
|
414
|
+
label={translate('common.billing.requiredData.street')}
|
|
415
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
416
|
+
error={!!errors.street}
|
|
417
|
+
errorMessage={errors.street?.message}
|
|
418
|
+
{...register('street')}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
{!isInternational && (
|
|
422
|
+
<div className="flex-1">
|
|
423
|
+
<FormField
|
|
424
|
+
label={translate('common.billing.requiredData.streetNumber')}
|
|
425
|
+
placeholder="000"
|
|
426
|
+
error={!!errors.streetNumber}
|
|
427
|
+
errorMessage={errors.streetNumber?.message}
|
|
428
|
+
{...register('streetNumber')}
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div className="flex gap-4">
|
|
435
|
+
{!isInternational && (
|
|
436
|
+
<div className="flex-1">
|
|
437
|
+
<FormField
|
|
438
|
+
label={translate('common.billing.requiredData.neighborhood')}
|
|
439
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
440
|
+
error={!!errors.neighborhood}
|
|
441
|
+
errorMessage={errors.neighborhood?.message}
|
|
442
|
+
{...register('neighborhood')}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
<div className="flex-1">
|
|
447
|
+
<FormField
|
|
448
|
+
label={translate('common.billing.requiredData.complement')}
|
|
449
|
+
optional
|
|
450
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
451
|
+
{...register('complement')}
|
|
452
|
+
/>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div className="flex gap-4">
|
|
457
|
+
<div className="flex-1">
|
|
458
|
+
<FormField
|
|
459
|
+
label={translate('common.billing.requiredData.city')}
|
|
460
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
461
|
+
error={!!errors.city}
|
|
462
|
+
errorMessage={errors.city?.message}
|
|
463
|
+
{...register('city')}
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
466
|
+
<div className="flex-1">
|
|
467
|
+
{isInternational ? (
|
|
468
|
+
<FormField
|
|
469
|
+
label={translate('common.billing.requiredData.stateProvince')}
|
|
470
|
+
placeholder={translate('common.billing.requiredData.typeHere')}
|
|
471
|
+
error={!!errors.state}
|
|
472
|
+
errorMessage={errors.state?.message}
|
|
473
|
+
{...register('state')}
|
|
474
|
+
/>
|
|
475
|
+
) : (
|
|
476
|
+
<Controller
|
|
477
|
+
name="state"
|
|
478
|
+
control={control}
|
|
479
|
+
render={({ field }) => (
|
|
480
|
+
<SelectField
|
|
481
|
+
label={translate('common.billing.requiredData.state')}
|
|
482
|
+
placeholder={translate('common.billing.requiredData.select')}
|
|
483
|
+
value={field.value || undefined}
|
|
484
|
+
onChange={(value) =>
|
|
485
|
+
setValue('state', value as string, { shouldValidate: true })
|
|
486
|
+
}
|
|
487
|
+
options={BR_STATE_OPTIONS}
|
|
488
|
+
error={!!errors.state}
|
|
489
|
+
errorMessage={errors.state?.message}
|
|
490
|
+
/>
|
|
491
|
+
)}
|
|
492
|
+
/>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<Controller
|
|
498
|
+
name="country"
|
|
499
|
+
control={control}
|
|
500
|
+
render={({ field }) => (
|
|
501
|
+
<SelectField
|
|
502
|
+
label={translate('common.billing.requiredData.country')}
|
|
503
|
+
placeholder={translate('common.billing.requiredData.select')}
|
|
504
|
+
value={field.value || undefined}
|
|
505
|
+
onChange={(value) =>
|
|
506
|
+
setValue('country', value as string, { shouldValidate: true })
|
|
507
|
+
}
|
|
508
|
+
options={COUNTRY_OPTIONS}
|
|
509
|
+
error={!!errors.country}
|
|
510
|
+
errorMessage={errors.country?.message}
|
|
511
|
+
/>
|
|
512
|
+
)}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
<div className="flex items-center justify-end p-4 lg:p-5 border-t border-zinc-200 shrink-0">
|
|
518
|
+
<Button
|
|
519
|
+
type="submit"
|
|
520
|
+
className="h-10! w-full lg:w-fit"
|
|
521
|
+
disabled={!isValid || isSubmitting}
|
|
522
|
+
loading={isSubmitting}
|
|
523
|
+
>
|
|
524
|
+
{isSubmitting
|
|
525
|
+
? translate('common.billing.requiredData.submitting')
|
|
526
|
+
: translate('common.billing.requiredData.submit')}
|
|
527
|
+
</Button>
|
|
528
|
+
</div>
|
|
529
|
+
</form>
|
|
530
|
+
</DialogContent>
|
|
531
|
+
</Dialog>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Modal bloqueante que exige o cadastro de cobrança/fiscal de contas que já
|
|
537
|
+
* pagaram sem ele — sem esses dados a nota fiscal não é emitida. Monte uma vez
|
|
538
|
+
* no layout raiz do app; ele mesmo decide se aparece (ver `useRequiredBillingData`).
|
|
539
|
+
*/
|
|
540
|
+
export default function RequiredBillingDataModal() {
|
|
541
|
+
const { user, isLoading } = useAuth();
|
|
542
|
+
|
|
543
|
+
/* Sem sessão não há conta nem assinatura pra consultar — evita disparar as
|
|
544
|
+
* queries (e tomar 401) em /login, /register e demais rotas públicas. */
|
|
545
|
+
if (isLoading || !user) return null;
|
|
546
|
+
|
|
547
|
+
return <RequiredBillingDataModalContent />;
|
|
548
|
+
}
|
|
@@ -7,8 +7,8 @@ import { Toast } from '../../ui/feedback/Toast';
|
|
|
7
7
|
import { useModalManager } from '../../../store/useModalManager';
|
|
8
8
|
import { useCreateCard } from '../../../modules/cards/hooks/create-card.hook';
|
|
9
9
|
import { useCreateSetupIntent } from '../../../modules/cards/hooks/create-setup-intent.hook';
|
|
10
|
-
import { useUpdateAccount } from '../../../modules/accounts/hooks/useAccountManagement';
|
|
11
10
|
import { useCurrentAccount } from '../../../modules/accounts/hooks/current-account.hook';
|
|
11
|
+
import { isValidTaxId } from '../../../utils/validators/common';
|
|
12
12
|
import { useExternalContracting } from '../../../providers/whitelabel.provider';
|
|
13
13
|
import { CardFormFields, BillingFormFields, buildCardFormSchema } from './CardFormFields';
|
|
14
14
|
import type { CardFormData, StripeElementsStatus } from './CardFormFields';
|
|
@@ -89,7 +89,6 @@ export default function AddCardModal() {
|
|
|
89
89
|
|
|
90
90
|
const createSetupIntentMutation = useCreateSetupIntent();
|
|
91
91
|
const createCardMutation = useCreateCard();
|
|
92
|
-
const updateAccountMutation = useUpdateAccount();
|
|
93
92
|
const elements = useElements();
|
|
94
93
|
const stripe = useStripe();
|
|
95
94
|
|
|
@@ -223,30 +222,32 @@ export default function AddCardModal() {
|
|
|
223
222
|
return;
|
|
224
223
|
}
|
|
225
224
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
225
|
+
/* Chamada ÚNICA: os dados fiscais vão no mesmo request do cartão. O backend atualiza a
|
|
226
|
+
* conta ANTES de registrar o cartão, fail-closed — fiscal rejeitado (CPF inválido etc.)
|
|
227
|
+
* = cartão não registra e o erro chega com a mensagem específica no catch abaixo.
|
|
228
|
+
* Substitui o Promise.all de createCard + updateAccount, que não tinha garantia entre as
|
|
229
|
+
* pernas: quando o PUT de conta falhava, o cartão ficava salvo e a conta sem NENHUM dado
|
|
230
|
+
* fiscal — e as faturas seguintes falhavam a emissão de nota em silêncio. */
|
|
231
|
+
const cardResult = await createCardMutation.mutateAsync({
|
|
232
|
+
setup_intent_id: intent.setup_intent_id,
|
|
233
|
+
name: data.name,
|
|
234
|
+
set_default: true,
|
|
235
|
+
...(isInternational
|
|
236
|
+
? {}
|
|
237
|
+
: {
|
|
238
|
+
financial_document_type: data.personType === 'pf' ? 1 : 2,
|
|
239
|
+
financial_document: data.personType === 'pf' ? data.cpf : data.cnpj,
|
|
240
|
+
}),
|
|
241
|
+
financial_name: data.fullName,
|
|
242
|
+
zipcode: data.cep,
|
|
243
|
+
address: data.street,
|
|
244
|
+
address_number: data.streetNumber || '',
|
|
245
|
+
address_complement: data.complement || '',
|
|
246
|
+
neighborhood: data.neighborhood || '',
|
|
247
|
+
city: data.city,
|
|
248
|
+
state: data.state,
|
|
249
|
+
country: data.country,
|
|
250
|
+
});
|
|
250
251
|
|
|
251
252
|
if (cardResult.success && cardResult.data?.id != null) {
|
|
252
253
|
onCardAdded?.(cardResult.data.id.toString());
|
|
@@ -257,12 +258,16 @@ export default function AddCardModal() {
|
|
|
257
258
|
{ duration: 5000 }
|
|
258
259
|
);
|
|
259
260
|
handleClose();
|
|
260
|
-
} catch {
|
|
261
|
+
} catch (error) {
|
|
262
|
+
/* O backend fail-closed devolve a razão exata da recusa (ex.: "CPF do pagador inválido —
|
|
263
|
+
* confira os dígitos verificadores"), e o withAction relança como Error com essa mensagem.
|
|
264
|
+
* Engolir num texto genérico deixaria o usuário sem saber o que corrigir. */
|
|
265
|
+
const backendMessage = error instanceof Error && error.message ? error.message : null;
|
|
261
266
|
toast.custom(
|
|
262
267
|
(t) => (
|
|
263
268
|
<Toast
|
|
264
269
|
variant="error"
|
|
265
|
-
message={translate('common.cards.add.errorSave')}
|
|
270
|
+
message={backendMessage || translate('common.cards.add.errorSave')}
|
|
266
271
|
toastId={t}
|
|
267
272
|
/>
|
|
268
273
|
),
|
|
@@ -275,7 +280,6 @@ export default function AddCardModal() {
|
|
|
275
280
|
|
|
276
281
|
const isSubmitting =
|
|
277
282
|
createCardMutation.isPending ||
|
|
278
|
-
updateAccountMutation.isPending ||
|
|
279
283
|
form.formState.isSubmitting;
|
|
280
284
|
|
|
281
285
|
const watchedValues = form.watch(['name', 'country', 'personType', 'cpf', 'cnpj', 'fullName', 'cep', 'street', 'streetNumber', 'neighborhood', 'city', 'state']);
|
|
@@ -294,7 +298,9 @@ export default function AddCardModal() {
|
|
|
294
298
|
: !!name &&
|
|
295
299
|
!!country &&
|
|
296
300
|
!!personType &&
|
|
297
|
-
|
|
301
|
+
/* Dígito verificador, em sincronia com o schema (buildCardFormSchema) e com a validação
|
|
302
|
+
* do backend — a regra antiga de tamanho deixava 111.111.111-11 habilitar o botão. */
|
|
303
|
+
(personType === 'pf' ? isValidTaxId(cpf, 'cpf') : isValidTaxId(cnpj, 'cnpj')) &&
|
|
298
304
|
stripeStatus.cardNumber &&
|
|
299
305
|
stripeStatus.cardExpiry &&
|
|
300
306
|
stripeStatus.cardCvc;
|