@greatapps/common 1.1.795 → 1.1.796

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/components/modals/billing/BillingDataForm.mjs +406 -0
  2. package/dist/components/modals/billing/BillingDataForm.mjs.map +1 -0
  3. package/dist/components/modals/billing/RequiredBillingDataModal.mjs +5 -405
  4. package/dist/components/modals/billing/RequiredBillingDataModal.mjs.map +1 -1
  5. package/dist/i18n/resolve-locale.mjs +1 -1
  6. package/dist/i18n/resolve-locale.mjs.map +1 -1
  7. package/dist/index.mjs +4 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs +3 -0
  10. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs.map +1 -1
  11. package/dist/modules/whitelabel/actions/find-whitelabel.action.mjs +1 -5
  12. package/dist/modules/whitelabel/actions/find-whitelabel.action.mjs.map +1 -1
  13. package/dist/modules/whitelabel/constants/whitelabel.constants.mjs +11 -0
  14. package/dist/modules/whitelabel/constants/whitelabel.constants.mjs.map +1 -0
  15. package/dist/modules/whitelabel/services/whitelabel.service.mjs +69 -11
  16. package/dist/modules/whitelabel/services/whitelabel.service.mjs.map +1 -1
  17. package/package.json +10 -9
  18. package/src/components/modals/billing/BillingDataForm.tsx +506 -0
  19. package/src/components/modals/billing/RequiredBillingDataModal.tsx +3 -475
  20. package/src/i18n/resolve-locale.ts +1 -1
  21. package/src/index.ts +3 -0
  22. package/src/modules/ia-credits/types/ai-credits-offer.type.ts +3 -0
  23. package/src/modules/whitelabel/actions/find-whitelabel.action.ts +1 -7
  24. package/src/modules/whitelabel/constants/whitelabel.constants.ts +14 -0
  25. package/src/modules/whitelabel/services/whitelabel.service.ts +282 -209
@@ -0,0 +1,506 @@
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 } 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
+
13
+ import { Button } from '../../ui/buttons/Button';
14
+ import { FormField } from '../../ui/form/FormField';
15
+ import { SelectField } from '../../ui/form/SelectField';
16
+ import { Toast } from '../../ui/feedback/Toast';
17
+ import { useAuth } from '../../../providers/auth.provider';
18
+ import { useViaCep } from '../../../modules/accounts/hooks/useViaCep';
19
+ import { useUpdateBillingData } from '../../../modules/accounts/hooks/useAccountManagement';
20
+ import { COUNTRIES } from '../../../utils/countries';
21
+ import { readGeoCountry } from '../../../utils/geo-country';
22
+ import { BR_STATE_OPTIONS } from '../../../utils/constants/br-states';
23
+ import { formatCNPJ, formatCPF, formatPostalCode } from '../../../utils/format/masks';
24
+ import { isValidCNPJ, isValidCPF } from '../../../utils/validators/common';
25
+ import type { Account } from '../../../modules/accounts/types';
26
+ import type { UpdateBillingDataRequest } from '../../../modules/accounts/types';
27
+
28
+ type TranslateFn = (key: any, values?: Record<string, string | number>) => string;
29
+
30
+ type BillingDataFormValues = {
31
+ personType: 'pf' | 'pj';
32
+ document: string;
33
+ financialName: string;
34
+ financialEmail: string;
35
+ cep: string;
36
+ street: string;
37
+ streetNumber: string;
38
+ complement?: string;
39
+ neighborhood: string;
40
+ city: string;
41
+ state: string;
42
+ country: string;
43
+ };
44
+
45
+ const COUNTRY_OPTIONS = COUNTRIES.map((country: { iso: string; name: string }) => ({
46
+ value: country.iso,
47
+ label: country.name,
48
+ }));
49
+
50
+ const BR_REQUIRED_ADDRESS_FIELDS: readonly (keyof BillingDataFormValues)[] = [
51
+ 'street',
52
+ 'streetNumber',
53
+ 'neighborhood',
54
+ 'city',
55
+ 'state',
56
+ ];
57
+
58
+ function buildBillingDataSchema(translate: TranslateFn) {
59
+ const base = z.object({
60
+ personType: z.enum(['pf', 'pj']),
61
+ document: z.string().trim(),
62
+ financialName: z
63
+ .string()
64
+ .trim()
65
+ .min(1, translate('common.billing.requiredData.requiredField')),
66
+ financialEmail: z
67
+ .string()
68
+ .trim()
69
+ .min(1, translate('common.billing.requiredData.requiredField'))
70
+ .email(translate('common.billing.requiredData.invalidEmail')),
71
+ cep: z.string().trim(),
72
+ street: z.string().trim(),
73
+ streetNumber: z.string().trim(),
74
+ complement: z.string().trim().optional(),
75
+ neighborhood: z.string().trim(),
76
+ city: z.string().trim(),
77
+ state: z.string().trim(),
78
+ country: z.string().trim().min(1, translate('common.billing.requiredData.requiredField')),
79
+ });
80
+
81
+ return base.superRefine((data, ctx) => {
82
+ if (data.country !== 'BR') return;
83
+
84
+ for (const field of BR_REQUIRED_ADDRESS_FIELDS) {
85
+ if (!data[field]) {
86
+ ctx.addIssue({
87
+ code: 'custom',
88
+ path: [field],
89
+ message: translate('common.billing.requiredData.requiredField'),
90
+ });
91
+ }
92
+ }
93
+
94
+ if (data.cep.replace(/\D/g, '').length !== 8) {
95
+ ctx.addIssue({
96
+ code: 'custom',
97
+ path: ['cep'],
98
+ message: translate('common.billing.requiredData.invalidCep'),
99
+ });
100
+ }
101
+
102
+ const isCompany = data.personType === 'pj';
103
+ const digits = data.document.replace(/\D/g, '');
104
+
105
+ if (digits.length !== (isCompany ? 14 : 11)) {
106
+ ctx.addIssue({
107
+ code: 'custom',
108
+ path: ['document'],
109
+ message: isCompany
110
+ ? translate('common.billing.requiredData.invalidCnpj')
111
+ : translate('common.billing.requiredData.invalidCpf'),
112
+ });
113
+ return;
114
+ }
115
+
116
+ /* Dígito verificador, mesmo algoritmo do backend: sem isso 111.111.111-11 passa
117
+ * aqui e o PUT da conta é recusado depois, sem o usuário saber o motivo. */
118
+ const isValid = isCompany ? isValidCNPJ(digits) : isValidCPF(digits);
119
+ if (!isValid) {
120
+ ctx.addIssue({
121
+ code: 'custom',
122
+ path: ['document'],
123
+ message: isCompany
124
+ ? translate('common.billing.requiredData.invalidCnpjChecksum')
125
+ : translate('common.billing.requiredData.invalidCpfChecksum'),
126
+ });
127
+ }
128
+ });
129
+ }
130
+
131
+ function formatDocument(document: string | null | undefined, isCompany: boolean) {
132
+ if (!document) return '';
133
+ return isCompany ? formatCNPJ(document) : formatCPF(document);
134
+ }
135
+
136
+ export interface BillingDataFormProps {
137
+ account: Account | undefined;
138
+ onSaved: () => void;
139
+ /** Fixa o formulário em modo Brasil (documento, CEP e endereço obrigatórios) e esconde o select
140
+ * de país — usado por fluxos PIX, que só existem no Brasil. Sem isso, o país vem da conta ou da
141
+ * geolocalização do visitante. */
142
+ country?: 'BR';
143
+ submitLabel?: string;
144
+ }
145
+
146
+ /**
147
+ * Formulário de cadastro fiscal/cobrança, reaproveitado por qualquer fluxo que precise dele
148
+ * bloqueante (`RequiredBillingDataModal`) ou como etapa dentro de outro modal.
149
+ */
150
+ export function BillingDataForm({ account, onSaved, country, submitLabel }: BillingDataFormProps) {
151
+ const translate = useTranslations();
152
+ const { user } = useAuth();
153
+ const { mutateAsync: updateBillingData } = useUpdateBillingData();
154
+ const { mutateAsync: lookupCep } = useViaCep();
155
+
156
+ const isPrefilled = useRef(false);
157
+
158
+ const schema = useMemo(
159
+ () => buildBillingDataSchema(translate),
160
+ [translate],
161
+ );
162
+
163
+ const form = useForm<BillingDataFormValues>({
164
+ resolver: zodResolver(schema),
165
+ mode: 'onChange',
166
+ defaultValues: {
167
+ personType: 'pf',
168
+ document: '',
169
+ financialName: '',
170
+ financialEmail: '',
171
+ cep: '',
172
+ street: '',
173
+ streetNumber: '',
174
+ complement: '',
175
+ neighborhood: '',
176
+ city: '',
177
+ state: '',
178
+ country: country ?? 'BR',
179
+ },
180
+ });
181
+
182
+ const {
183
+ register,
184
+ control,
185
+ handleSubmit,
186
+ reset,
187
+ setValue,
188
+ watch,
189
+ formState: { errors, isSubmitting, isValid },
190
+ } = form;
191
+
192
+ const personType = watch('personType');
193
+ const isCompany = personType === 'pj';
194
+ const isBrazil = watch('country') === 'BR';
195
+
196
+ useEffect(() => {
197
+ if (!account || isPrefilled.current) return;
198
+ isPrefilled.current = true;
199
+
200
+ const accountIsCompany = account.financial_document_type === 2;
201
+ reset({
202
+ personType: accountIsCompany ? 'pj' : 'pf',
203
+ document: formatDocument(account.financial_document, accountIsCompany),
204
+ financialName: account.financial_name ?? '',
205
+ financialEmail: account.financial_email || user?.email || '',
206
+ cep: account.zipcode ?? '',
207
+ street: account.address ?? '',
208
+ streetNumber: account.address_number ?? '',
209
+ complement: account.address_complement ?? '',
210
+ neighborhood: account.neighborhood ?? '',
211
+ city: account.city ?? '',
212
+ state: account.state ?? '',
213
+ country: country ?? (account.country || readGeoCountry() || 'BR'),
214
+ });
215
+ }, [account, country, reset, user?.email]);
216
+
217
+ const handleDocumentChange = (rawValue: string) => {
218
+ setValue('document', isCompany ? formatCNPJ(rawValue) : formatCPF(rawValue), {
219
+ shouldValidate: true,
220
+ });
221
+ };
222
+
223
+ const handlePersonTypeChange = (value: 'pf' | 'pj') => {
224
+ if (value === personType) return;
225
+ setValue('document', '');
226
+ /* shouldValidate reroda o resolver: sem isso o `isValid` fica preso no valor
227
+ * do documento antigo e o botão continua habilitado com o campo já limpo. */
228
+ setValue('personType', value, { shouldValidate: true });
229
+ };
230
+
231
+ const handleCepChange = (rawValue: string) => {
232
+ setValue('cep', formatPostalCode(rawValue), { shouldValidate: true });
233
+
234
+ const digits = rawValue.replace(/\D/g, '');
235
+ if (digits.length !== 8) return;
236
+
237
+ lookupCep(digits)
238
+ .then((data) => {
239
+ setValue('street', data.logradouro, { shouldValidate: true });
240
+ setValue('neighborhood', data.bairro, { shouldValidate: true });
241
+ setValue('city', data.localidade, { shouldValidate: true });
242
+ setValue('state', data.uf, { shouldValidate: true });
243
+ })
244
+ .catch(() => {
245
+ // CEP não encontrado: o usuário preenche o endereço na mão.
246
+ });
247
+ };
248
+
249
+ async function onSubmit(data: BillingDataFormValues) {
250
+ const payload: UpdateBillingDataRequest =
251
+ data.country === 'BR'
252
+ ? {
253
+ financial_document_type: data.personType === 'pf' ? 1 : 2,
254
+ financial_document: data.document,
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
+ financial_name: data.financialName,
268
+ financial_email: data.financialEmail,
269
+ country: data.country,
270
+ };
271
+
272
+ try {
273
+ await updateBillingData(payload);
274
+ toast.custom(
275
+ (t) => (
276
+ <Toast
277
+ variant="success"
278
+ message={translate('common.billing.requiredData.successToast')}
279
+ toastId={t}
280
+ />
281
+ ),
282
+ { duration: 5000 },
283
+ );
284
+ onSaved();
285
+ } catch (error) {
286
+ /* O backend devolve a razão exata da recusa (ex.: documento fiscal inválido).
287
+ * Trocar por um texto genérico deixaria o usuário travado sem saber o que corrigir. */
288
+ const message =
289
+ error instanceof Error && error.message
290
+ ? error.message
291
+ : translate('common.billing.requiredData.errorToast');
292
+ toast.custom((t) => <Toast variant="error" message={message} toastId={t} />, {
293
+ duration: 5000,
294
+ });
295
+ }
296
+ }
297
+
298
+ return (
299
+ <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" noValidate>
300
+ <div className="flex flex-col gap-6 p-4 lg:p-5 flex-1 overflow-y-auto">
301
+ <div className="flex flex-col gap-5">
302
+ {!country && (
303
+ <>
304
+ {/* Reage ao watch('country'): só quem está aqui dentro sabe quando o usuário
305
+ * troca de país no select logo abaixo. */}
306
+ <span className="paragraph-small-regular text-zinc-600">
307
+ {isBrazil
308
+ ? translate('common.billing.requiredData.description')
309
+ : translate('common.billing.requiredData.descriptionInternational')}
310
+ </span>
311
+
312
+ <Controller
313
+ name="country"
314
+ control={control}
315
+ render={({ field }) => (
316
+ <SelectField
317
+ label={translate('common.billing.requiredData.country')}
318
+ placeholder={translate('common.billing.requiredData.select')}
319
+ value={field.value || undefined}
320
+ onChange={(value) =>
321
+ setValue('country', String(value), { shouldValidate: true })
322
+ }
323
+ options={COUNTRY_OPTIONS}
324
+ error={!!errors.country}
325
+ errorMessage={errors.country?.message}
326
+ />
327
+ )}
328
+ />
329
+ </>
330
+ )}
331
+
332
+ {isBrazil && (
333
+ <div className="flex gap-4">
334
+ <Controller
335
+ name="personType"
336
+ control={control}
337
+ render={({ field }) => (
338
+ <SelectField
339
+ label={translate('common.billing.requiredData.personType')}
340
+ placeholder={translate('common.billing.requiredData.select')}
341
+ value={field.value}
342
+ onChange={(value) => handlePersonTypeChange(value as 'pf' | 'pj')}
343
+ options={[
344
+ {
345
+ value: 'pf',
346
+ label: translate('common.billing.requiredData.personTypePf'),
347
+ },
348
+ {
349
+ value: 'pj',
350
+ label: translate('common.billing.requiredData.personTypePj'),
351
+ },
352
+ ]}
353
+ containerClassName="flex-1"
354
+ />
355
+ )}
356
+ />
357
+ <div className="flex-1">
358
+ <FormField
359
+ label={
360
+ isCompany
361
+ ? translate('common.billing.requiredData.cnpj')
362
+ : translate('common.billing.requiredData.cpf')
363
+ }
364
+ placeholder={isCompany ? '__.___.___/____-__' : '___.___.___-__'}
365
+ error={!!errors.document}
366
+ errorMessage={errors.document?.message}
367
+ {...register('document', {
368
+ onChange: (event) => handleDocumentChange(event.target.value),
369
+ })}
370
+ />
371
+ </div>
372
+ </div>
373
+ )}
374
+
375
+ <FormField
376
+ label={
377
+ isBrazil && isCompany
378
+ ? translate('common.billing.requiredData.companyName')
379
+ : translate('common.billing.requiredData.fullName')
380
+ }
381
+ placeholder={translate('common.billing.requiredData.typeHere')}
382
+ error={!!errors.financialName}
383
+ errorMessage={errors.financialName?.message}
384
+ {...register('financialName')}
385
+ />
386
+
387
+ <FormField
388
+ label={translate('common.billing.requiredData.financialEmail')}
389
+ placeholder={translate('common.billing.requiredData.financialEmailPlaceholder')}
390
+ type="email"
391
+ error={!!errors.financialEmail}
392
+ errorMessage={errors.financialEmail?.message}
393
+ {...register('financialEmail')}
394
+ />
395
+ </div>
396
+
397
+ {isBrazil && (
398
+ <>
399
+ <div className="h-px bg-zinc-200" />
400
+
401
+ <div className="flex flex-col gap-5">
402
+ <span className="paragraph-small-semibold text-zinc-950">
403
+ {translate('common.billing.requiredData.addressHeading')}
404
+ </span>
405
+
406
+ <FormField
407
+ label={translate('common.billing.requiredData.cep')}
408
+ placeholder="_____-___"
409
+ error={!!errors.cep}
410
+ errorMessage={errors.cep?.message}
411
+ {...register('cep', {
412
+ onChange: (event) => handleCepChange(event.target.value),
413
+ })}
414
+ />
415
+
416
+ <div className="flex gap-4">
417
+ <div className="flex-[3]">
418
+ <FormField
419
+ label={translate('common.billing.requiredData.street')}
420
+ placeholder={translate('common.billing.requiredData.typeHere')}
421
+ error={!!errors.street}
422
+ errorMessage={errors.street?.message}
423
+ {...register('street')}
424
+ />
425
+ </div>
426
+ <div className="flex-1">
427
+ <FormField
428
+ label={translate('common.billing.requiredData.streetNumber')}
429
+ placeholder="000"
430
+ error={!!errors.streetNumber}
431
+ errorMessage={errors.streetNumber?.message}
432
+ {...register('streetNumber')}
433
+ />
434
+ </div>
435
+ </div>
436
+
437
+ <div className="flex gap-4">
438
+ <div className="flex-1">
439
+ <FormField
440
+ label={translate('common.billing.requiredData.neighborhood')}
441
+ placeholder={translate('common.billing.requiredData.typeHere')}
442
+ error={!!errors.neighborhood}
443
+ errorMessage={errors.neighborhood?.message}
444
+ {...register('neighborhood')}
445
+ />
446
+ </div>
447
+ <div className="flex-1">
448
+ <FormField
449
+ label={translate('common.billing.requiredData.complement')}
450
+ optional
451
+ placeholder={translate('common.billing.requiredData.typeHere')}
452
+ {...register('complement')}
453
+ />
454
+ </div>
455
+ </div>
456
+
457
+ <div className="flex gap-4">
458
+ <div className="flex-1">
459
+ <FormField
460
+ label={translate('common.billing.requiredData.city')}
461
+ placeholder={translate('common.billing.requiredData.typeHere')}
462
+ error={!!errors.city}
463
+ errorMessage={errors.city?.message}
464
+ {...register('city')}
465
+ />
466
+ </div>
467
+ <div className="flex-1">
468
+ <Controller
469
+ name="state"
470
+ control={control}
471
+ render={({ field }) => (
472
+ <SelectField
473
+ label={translate('common.billing.requiredData.state')}
474
+ placeholder={translate('common.billing.requiredData.select')}
475
+ value={field.value || undefined}
476
+ onChange={(value) =>
477
+ setValue('state', String(value), { shouldValidate: true })
478
+ }
479
+ options={BR_STATE_OPTIONS}
480
+ error={!!errors.state}
481
+ errorMessage={errors.state?.message}
482
+ />
483
+ )}
484
+ />
485
+ </div>
486
+ </div>
487
+ </div>
488
+ </>
489
+ )}
490
+ </div>
491
+
492
+ <div className="flex items-center justify-end p-4 lg:p-5 border-t border-zinc-200 shrink-0">
493
+ <Button
494
+ type="submit"
495
+ className="h-10! w-full lg:w-fit"
496
+ disabled={!isValid || isSubmitting}
497
+ loading={isSubmitting}
498
+ >
499
+ {isSubmitting
500
+ ? translate('common.billing.requiredData.submitting')
501
+ : submitLabel ?? translate('common.billing.requiredData.submit')}
502
+ </Button>
503
+ </div>
504
+ </form>
505
+ );
506
+ }