@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
@@ -1,291 +1,21 @@
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
1
  'use client';
5
2
 
6
- import { useEffect, useMemo, useRef, useState } from 'react';
7
- import { Controller, useForm } from 'react-hook-form';
8
- import { zodResolver } from '@hookform/resolvers/zod';
3
+ import { useState } from 'react';
9
4
  import { useTranslations } from 'next-intl';
10
- import { toast } from 'sonner';
11
- import z from 'zod';
12
5
  import { IconFileInvoice } from '@tabler/icons-react';
13
6
 
14
7
  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
8
  import { useAuth } from '../../../providers/auth.provider';
20
- import { useViaCep } from '../../../modules/accounts/hooks/useViaCep';
21
- import { useUpdateBillingData } from '../../../modules/accounts/hooks/useAccountManagement';
22
9
  import { useRequiredBillingData } from '../../../modules/accounts/hooks/use-required-billing-data.hook';
23
- import { COUNTRIES } from '../../../utils/countries';
24
- import { readGeoCountry } from '../../../utils/geo-country';
25
- import { BR_STATE_OPTIONS } from '../../../utils/constants/br-states';
26
- import { formatCNPJ, formatCPF, formatPostalCode } from '../../../utils/format/masks';
27
- import { isValidCNPJ, isValidCPF } from '../../../utils/validators/common';
28
- import type { UpdateBillingDataRequest } from '../../../modules/accounts/types';
29
-
30
- type TranslateFn = (key: any, values?: Record<string, string | number>) => string;
31
-
32
- type BillingDataFormValues = {
33
- personType: 'pf' | 'pj';
34
- document: string;
35
- financialName: string;
36
- financialEmail: string;
37
- cep: string;
38
- street: string;
39
- streetNumber: string;
40
- complement?: string;
41
- neighborhood: string;
42
- city: string;
43
- state: string;
44
- country: string;
45
- };
46
-
47
- const COUNTRY_OPTIONS = COUNTRIES.map((country: { iso: string; name: string }) => ({
48
- value: country.iso,
49
- label: country.name,
50
- }));
51
-
52
- const BR_REQUIRED_ADDRESS_FIELDS: readonly (keyof BillingDataFormValues)[] = [
53
- 'street',
54
- 'streetNumber',
55
- 'neighborhood',
56
- 'city',
57
- 'state',
58
- ];
59
-
60
- function buildBillingDataSchema(translate: TranslateFn) {
61
- const base = z.object({
62
- personType: z.enum(['pf', 'pj']),
63
- document: z.string().trim(),
64
- financialName: z
65
- .string()
66
- .trim()
67
- .min(1, translate('common.billing.requiredData.requiredField')),
68
- financialEmail: z
69
- .string()
70
- .trim()
71
- .min(1, translate('common.billing.requiredData.requiredField'))
72
- .email(translate('common.billing.requiredData.invalidEmail')),
73
- cep: z.string().trim(),
74
- street: z.string().trim(),
75
- streetNumber: z.string().trim(),
76
- complement: z.string().trim().optional(),
77
- neighborhood: z.string().trim(),
78
- city: z.string().trim(),
79
- state: z.string().trim(),
80
- country: z.string().trim().min(1, translate('common.billing.requiredData.requiredField')),
81
- });
82
-
83
- return base.superRefine((data, ctx) => {
84
- if (data.country !== 'BR') return;
85
-
86
- for (const field of BR_REQUIRED_ADDRESS_FIELDS) {
87
- if (!data[field]) {
88
- ctx.addIssue({
89
- code: 'custom',
90
- path: [field],
91
- message: translate('common.billing.requiredData.requiredField'),
92
- });
93
- }
94
- }
95
-
96
- if (data.cep.replace(/\D/g, '').length !== 8) {
97
- ctx.addIssue({
98
- code: 'custom',
99
- path: ['cep'],
100
- message: translate('common.billing.requiredData.invalidCep'),
101
- });
102
- }
103
-
104
- const isCompany = data.personType === 'pj';
105
- const digits = data.document.replace(/\D/g, '');
106
-
107
- if (digits.length !== (isCompany ? 14 : 11)) {
108
- ctx.addIssue({
109
- code: 'custom',
110
- path: ['document'],
111
- message: isCompany
112
- ? translate('common.billing.requiredData.invalidCnpj')
113
- : translate('common.billing.requiredData.invalidCpf'),
114
- });
115
- return;
116
- }
117
-
118
- /* Dígito verificador, mesmo algoritmo do backend: sem isso 111.111.111-11 passa
119
- * aqui e o PUT da conta é recusado depois, sem o usuário saber o motivo. */
120
- const isValid = isCompany ? isValidCNPJ(digits) : isValidCPF(digits);
121
- if (!isValid) {
122
- ctx.addIssue({
123
- code: 'custom',
124
- path: ['document'],
125
- message: isCompany
126
- ? translate('common.billing.requiredData.invalidCnpjChecksum')
127
- : translate('common.billing.requiredData.invalidCpfChecksum'),
128
- });
129
- }
130
- });
131
- }
132
-
133
- function formatDocument(document: string | null | undefined, isCompany: boolean) {
134
- if (!document) return '';
135
- return isCompany ? formatCNPJ(document) : formatCPF(document);
136
- }
10
+ import { BillingDataForm } from './BillingDataForm';
137
11
 
138
12
  function RequiredBillingDataModalContent() {
139
13
  const translate = useTranslations();
140
- const { user } = useAuth();
141
14
  const { isRequired, account } = useRequiredBillingData();
142
- const { mutateAsync: updateBillingData } = useUpdateBillingData();
143
- const { mutateAsync: lookupCep } = useViaCep();
144
15
 
145
16
  /* O modal fecha na hora do submit e não reabre enquanto o invalidate do account
146
17
  * não volta — sem isso o usuário vê o modal piscar de novo com o cache antigo. */
147
18
  const [isSubmitted, setIsSubmitted] = useState(false);
148
- const isPrefilled = useRef(false);
149
-
150
- const schema = useMemo(
151
- () => buildBillingDataSchema(translate),
152
- [translate],
153
- );
154
-
155
- const form = useForm<BillingDataFormValues>({
156
- resolver: zodResolver(schema),
157
- mode: 'onChange',
158
- defaultValues: {
159
- personType: 'pf',
160
- document: '',
161
- financialName: '',
162
- financialEmail: '',
163
- cep: '',
164
- street: '',
165
- streetNumber: '',
166
- complement: '',
167
- neighborhood: '',
168
- city: '',
169
- state: '',
170
- country: 'BR',
171
- },
172
- });
173
-
174
- const {
175
- register,
176
- control,
177
- handleSubmit,
178
- reset,
179
- setValue,
180
- watch,
181
- formState: { errors, isSubmitting, isValid },
182
- } = form;
183
-
184
- const personType = watch('personType');
185
- const isCompany = personType === 'pj';
186
- const isBrazil = watch('country') === 'BR';
187
-
188
- useEffect(() => {
189
- if (!account || isPrefilled.current) return;
190
- isPrefilled.current = true;
191
-
192
- const accountIsCompany = account.financial_document_type === 2;
193
- reset({
194
- personType: accountIsCompany ? 'pj' : 'pf',
195
- document: formatDocument(account.financial_document, accountIsCompany),
196
- financialName: account.financial_name ?? '',
197
- financialEmail: account.financial_email || user?.email || '',
198
- cep: account.zipcode ?? '',
199
- street: account.address ?? '',
200
- streetNumber: account.address_number ?? '',
201
- complement: account.address_complement ?? '',
202
- neighborhood: account.neighborhood ?? '',
203
- city: account.city ?? '',
204
- state: account.state ?? '',
205
- country: account.country || readGeoCountry() || 'BR',
206
- });
207
- }, [account, reset, user?.email]);
208
-
209
- const handleDocumentChange = (rawValue: string) => {
210
- setValue('document', isCompany ? formatCNPJ(rawValue) : formatCPF(rawValue), {
211
- shouldValidate: true,
212
- });
213
- };
214
-
215
- const handlePersonTypeChange = (value: 'pf' | 'pj') => {
216
- if (value === personType) return;
217
- setValue('document', '');
218
- /* shouldValidate reroda o resolver: sem isso o `isValid` fica preso no valor
219
- * do documento antigo e o botão continua habilitado com o campo já limpo. */
220
- setValue('personType', value, { shouldValidate: true });
221
- };
222
-
223
- const handleCepChange = (rawValue: string) => {
224
- setValue('cep', formatPostalCode(rawValue), { shouldValidate: true });
225
-
226
- const digits = rawValue.replace(/\D/g, '');
227
- if (digits.length !== 8) return;
228
-
229
- lookupCep(digits)
230
- .then((data) => {
231
- setValue('street', data.logradouro, { shouldValidate: true });
232
- setValue('neighborhood', data.bairro, { shouldValidate: true });
233
- setValue('city', data.localidade, { shouldValidate: true });
234
- setValue('state', data.uf, { shouldValidate: true });
235
- })
236
- .catch(() => {
237
- // CEP não encontrado: o usuário preenche o endereço na mão.
238
- });
239
- };
240
-
241
- async function onSubmit(data: BillingDataFormValues) {
242
- const payload: UpdateBillingDataRequest =
243
- data.country === 'BR'
244
- ? {
245
- financial_document_type: data.personType === 'pf' ? 1 : 2,
246
- financial_document: data.document,
247
- financial_name: data.financialName,
248
- financial_email: data.financialEmail,
249
- zipcode: data.cep,
250
- address: data.street,
251
- address_number: data.streetNumber,
252
- address_complement: data.complement ?? '',
253
- neighborhood: data.neighborhood,
254
- city: data.city,
255
- state: data.state,
256
- country: data.country,
257
- }
258
- : {
259
- financial_name: data.financialName,
260
- financial_email: data.financialEmail,
261
- country: data.country,
262
- };
263
-
264
- try {
265
- await updateBillingData(payload);
266
- setIsSubmitted(true);
267
- toast.custom(
268
- (t) => (
269
- <Toast
270
- variant="success"
271
- message={translate('common.billing.requiredData.successToast')}
272
- toastId={t}
273
- />
274
- ),
275
- { duration: 5000 },
276
- );
277
- } catch (error) {
278
- /* O backend devolve a razão exata da recusa (ex.: documento fiscal inválido).
279
- * Trocar por um texto genérico deixaria o usuário travado sem saber o que corrigir. */
280
- const message =
281
- error instanceof Error && error.message
282
- ? error.message
283
- : translate('common.billing.requiredData.errorToast');
284
- toast.custom((t) => <Toast variant="error" message={message} toastId={t} />, {
285
- duration: 5000,
286
- });
287
- }
288
- }
289
19
 
290
20
  if (!isRequired || isSubmitted) return null;
291
21
 
@@ -306,212 +36,10 @@ function RequiredBillingDataModalContent() {
306
36
  <DialogTitle className="paragraph-medium-semibold text-zinc-950">
307
37
  {translate('common.billing.requiredData.title')}
308
38
  </DialogTitle>
309
- <span className="paragraph-small-regular text-zinc-600">
310
- {isBrazil
311
- ? translate('common.billing.requiredData.description')
312
- : translate('common.billing.requiredData.descriptionInternational')}
313
- </span>
314
39
  </div>
315
40
  </DialogHeader>
316
41
 
317
- <form
318
- onSubmit={handleSubmit(onSubmit)}
319
- className="flex flex-col flex-1 min-h-0"
320
- noValidate
321
- >
322
- <div className="flex flex-col gap-6 p-4 lg:p-5 flex-1 overflow-y-auto">
323
- <div className="flex flex-col gap-5">
324
- <Controller
325
- name="country"
326
- control={control}
327
- render={({ field }) => (
328
- <SelectField
329
- label={translate('common.billing.requiredData.country')}
330
- placeholder={translate('common.billing.requiredData.select')}
331
- value={field.value || undefined}
332
- onChange={(value) =>
333
- setValue('country', String(value), { shouldValidate: true })
334
- }
335
- options={COUNTRY_OPTIONS}
336
- error={!!errors.country}
337
- errorMessage={errors.country?.message}
338
- />
339
- )}
340
- />
341
-
342
- {isBrazil && (
343
- <div className="flex gap-4">
344
- <Controller
345
- name="personType"
346
- control={control}
347
- render={({ field }) => (
348
- <SelectField
349
- label={translate('common.billing.requiredData.personType')}
350
- placeholder={translate('common.billing.requiredData.select')}
351
- value={field.value}
352
- onChange={(value) => handlePersonTypeChange(value as 'pf' | 'pj')}
353
- options={[
354
- {
355
- value: 'pf',
356
- label: translate('common.billing.requiredData.personTypePf'),
357
- },
358
- {
359
- value: 'pj',
360
- label: translate('common.billing.requiredData.personTypePj'),
361
- },
362
- ]}
363
- containerClassName="flex-1"
364
- />
365
- )}
366
- />
367
- <div className="flex-1">
368
- <FormField
369
- label={
370
- isCompany
371
- ? translate('common.billing.requiredData.cnpj')
372
- : translate('common.billing.requiredData.cpf')
373
- }
374
- placeholder={isCompany ? '__.___.___/____-__' : '___.___.___-__'}
375
- error={!!errors.document}
376
- errorMessage={errors.document?.message}
377
- {...register('document', {
378
- onChange: (event) => handleDocumentChange(event.target.value),
379
- })}
380
- />
381
- </div>
382
- </div>
383
- )}
384
-
385
- <FormField
386
- label={
387
- isBrazil && isCompany
388
- ? translate('common.billing.requiredData.companyName')
389
- : translate('common.billing.requiredData.fullName')
390
- }
391
- placeholder={translate('common.billing.requiredData.typeHere')}
392
- error={!!errors.financialName}
393
- errorMessage={errors.financialName?.message}
394
- {...register('financialName')}
395
- />
396
-
397
- <FormField
398
- label={translate('common.billing.requiredData.financialEmail')}
399
- placeholder={translate('common.billing.requiredData.financialEmailPlaceholder')}
400
- type="email"
401
- error={!!errors.financialEmail}
402
- errorMessage={errors.financialEmail?.message}
403
- {...register('financialEmail')}
404
- />
405
- </div>
406
-
407
- {isBrazil && (
408
- <>
409
- <div className="h-px bg-zinc-200" />
410
-
411
- <div className="flex flex-col gap-5">
412
- <span className="paragraph-small-semibold text-zinc-950">
413
- {translate('common.billing.requiredData.addressHeading')}
414
- </span>
415
-
416
- <FormField
417
- label={translate('common.billing.requiredData.cep')}
418
- placeholder="_____-___"
419
- error={!!errors.cep}
420
- errorMessage={errors.cep?.message}
421
- {...register('cep', {
422
- onChange: (event) => handleCepChange(event.target.value),
423
- })}
424
- />
425
-
426
- <div className="flex gap-4">
427
- <div className="flex-[3]">
428
- <FormField
429
- label={translate('common.billing.requiredData.street')}
430
- placeholder={translate('common.billing.requiredData.typeHere')}
431
- error={!!errors.street}
432
- errorMessage={errors.street?.message}
433
- {...register('street')}
434
- />
435
- </div>
436
- <div className="flex-1">
437
- <FormField
438
- label={translate('common.billing.requiredData.streetNumber')}
439
- placeholder="000"
440
- error={!!errors.streetNumber}
441
- errorMessage={errors.streetNumber?.message}
442
- {...register('streetNumber')}
443
- />
444
- </div>
445
- </div>
446
-
447
- <div className="flex gap-4">
448
- <div className="flex-1">
449
- <FormField
450
- label={translate('common.billing.requiredData.neighborhood')}
451
- placeholder={translate('common.billing.requiredData.typeHere')}
452
- error={!!errors.neighborhood}
453
- errorMessage={errors.neighborhood?.message}
454
- {...register('neighborhood')}
455
- />
456
- </div>
457
- <div className="flex-1">
458
- <FormField
459
- label={translate('common.billing.requiredData.complement')}
460
- optional
461
- placeholder={translate('common.billing.requiredData.typeHere')}
462
- {...register('complement')}
463
- />
464
- </div>
465
- </div>
466
-
467
- <div className="flex gap-4">
468
- <div className="flex-1">
469
- <FormField
470
- label={translate('common.billing.requiredData.city')}
471
- placeholder={translate('common.billing.requiredData.typeHere')}
472
- error={!!errors.city}
473
- errorMessage={errors.city?.message}
474
- {...register('city')}
475
- />
476
- </div>
477
- <div className="flex-1">
478
- <Controller
479
- name="state"
480
- control={control}
481
- render={({ field }) => (
482
- <SelectField
483
- label={translate('common.billing.requiredData.state')}
484
- placeholder={translate('common.billing.requiredData.select')}
485
- value={field.value || undefined}
486
- onChange={(value) =>
487
- setValue('state', String(value), { shouldValidate: true })
488
- }
489
- options={BR_STATE_OPTIONS}
490
- error={!!errors.state}
491
- errorMessage={errors.state?.message}
492
- />
493
- )}
494
- />
495
- </div>
496
- </div>
497
- </div>
498
- </>
499
- )}
500
- </div>
501
-
502
- <div className="flex items-center justify-end p-4 lg:p-5 border-t border-zinc-200 shrink-0">
503
- <Button
504
- type="submit"
505
- className="h-10! w-full lg:w-fit"
506
- disabled={!isValid || isSubmitting}
507
- loading={isSubmitting}
508
- >
509
- {isSubmitting
510
- ? translate('common.billing.requiredData.submitting')
511
- : translate('common.billing.requiredData.submit')}
512
- </Button>
513
- </div>
514
- </form>
42
+ <BillingDataForm account={account} onSaved={() => setIsSubmitted(true)} />
515
43
  </DialogContent>
516
44
  </Dialog>
517
45
  );
@@ -5,10 +5,10 @@ import { DEFAULT_LOCALE, Locale, LOCALE_COOKIE } from './config';
5
5
  import { localeFromCountry } from './country-language';
6
6
  import { normalizeLocale } from './normalize';
7
7
  import { decodeJwtPayload } from '../utils/jwt-payload';
8
+ import { DEFAULT_WHITELABEL_ID } from '../modules/whitelabel/constants/whitelabel.constants';
8
9
 
9
10
  const AUTH_COOKIE = 'greatapps';
10
11
  const GEO_HEADER = 'cf-ipcountry';
11
- const DEFAULT_WHITELABEL_ID = 1;
12
12
 
13
13
  interface JwtLocaleInfo {
14
14
  id_wl?: number;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Types
2
2
  export * from "./modules/auth/schema";
3
3
  export * from "./modules/users/schema";
4
+ export { UserProfile } from "./modules/users/schema";
4
5
  export * from "./modules/whitelabel/schema";
5
6
  // Style types now come from whitelabel schema directly
6
7
  export * from "./infra/api/types";
@@ -281,6 +282,8 @@ export { default as PaidPlanRequiredModal } from "./components/modals/PaidPlanRe
281
282
  export { default as AccountFrozenModal } from "./components/modals/AccountFrozenModal";
282
283
  export { default as AccountDeletionWarningModal } from "./components/modals/AccountDeletionWarningModal";
283
284
  export { default as RequiredBillingDataModal } from "./components/modals/billing/RequiredBillingDataModal";
285
+ export { BillingDataForm } from "./components/modals/billing/BillingDataForm";
286
+ export type { BillingDataFormProps } from "./components/modals/billing/BillingDataForm";
284
287
  export { default as AddCardModal } from "./components/modals/cards/AddCardModal";
285
288
  export { DeleteCardModal } from "./components/modals/cards/DeleteCardModal";
286
289
  export { CannotDeleteCardModal } from "./components/modals/cards/CannotDeleteCardModal";
@@ -71,6 +71,9 @@ const PurchaseIaCreditsPixDataSchema = z.object({
71
71
  export const PurchaseIaCreditsResultSchema = z.object({
72
72
  status: z.number(),
73
73
  message: z.string().optional(),
74
+ /** Code estável da recusa (ex.: `FISCAL_DOCUMENT_REQUIRED`) — sem isso o `z.object` faz strip e o
75
+ * code do backend morre antes de chegar em quem decide reabrir um passo do fluxo por ele. */
76
+ code: z.string().optional(),
74
77
  client_secret: z.string().optional(),
75
78
  data: z
76
79
  .array(
@@ -7,13 +7,7 @@ import { ApiError } from "../../../infra/api/types";
7
7
  import { cache } from "react";
8
8
 
9
9
  const findWhitelabelByHost = cache(async (host: string) => {
10
- const whitelabel = await whitelabelService.getTokenByDomain(host);
11
-
12
- if (!whitelabel) {
13
- throw new ApiError('Whitelabel not found', 'WL_NOT_FOUND', 404);
14
- }
15
-
16
- return whitelabel;
10
+ return whitelabelService.getTokenByDomain(host);
17
11
  });
18
12
 
19
13
  export const findWhitelabel = async () => {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Whitelabel 1 é a Great por definição — dono do domínio-mãe e o único destino
3
+ * seguro quando um host não tem whitelabel própria (ver `WhitelabelService.getDefault`).
4
+ */
5
+ export const DEFAULT_WHITELABEL_ID = 1;
6
+
7
+ /** Domínio da whitelabel padrão, usado quando `WHITELABEL_DEFAULT_DOMAIN` não está configurada. */
8
+ export const DEFAULT_WHITELABEL_DOMAIN = 'greatpages.com.br';
9
+
10
+ /** TTL do cache positivo (token resolvido com sucesso) — uma semana. */
11
+ export const WHITELABEL_CACHE_TTL_SECONDS = 604800;
12
+
13
+ /** TTL do cache negativo (host sem whitelabel) — curto, pra não travar 5 minutos um domínio novo. */
14
+ export const MISSING_WHITELABEL_CACHE_TTL_SECONDS = 300;