@seedgrid/fe-subdomain-tenancy 2026.7.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.
Files changed (40) hide show
  1. package/README.md +75 -0
  2. package/dist/client/index.d.ts +2 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +3 -0
  5. package/dist/client/tenant-provider.d.ts +11 -0
  6. package/dist/client/tenant-provider.d.ts.map +1 -0
  7. package/dist/client/tenant-provider.js +19 -0
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +5 -0
  11. package/dist/module.d.ts +3 -0
  12. package/dist/module.d.ts.map +1 -0
  13. package/dist/module.js +16 -0
  14. package/dist/module.json +14 -0
  15. package/dist/paths.d.ts +9 -0
  16. package/dist/paths.d.ts.map +1 -0
  17. package/dist/paths.js +10 -0
  18. package/dist/server/create-subdomain-tenancy-server.d.ts +12 -0
  19. package/dist/server/create-subdomain-tenancy-server.d.ts.map +1 -0
  20. package/dist/server/create-subdomain-tenancy-server.js +18 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +2 -0
  24. package/dist/subdomain-rules.d.ts +13 -0
  25. package/dist/subdomain-rules.d.ts.map +1 -0
  26. package/dist/subdomain-rules.js +82 -0
  27. package/dist/tenant-signup.d.ts +59 -0
  28. package/dist/tenant-signup.d.ts.map +1 -0
  29. package/dist/tenant-signup.js +5 -0
  30. package/package.json +55 -0
  31. package/scaffold/README.md +70 -0
  32. package/scaffold/src/app/(public)/signup/page.tsx +1165 -0
  33. package/scaffold/src/app/api/locations/cities/route.ts +125 -0
  34. package/scaffold/src/app/api/locations/states/route.ts +120 -0
  35. package/scaffold/src/modules/subdomain-tenancy/index.ts +20 -0
  36. package/scaffold/src/modules/subdomain-tenancy/server/create-public-tenant-action.ts +113 -0
  37. package/scaffold/src/modules/subdomain-tenancy/server/public-api-base-url.ts +28 -0
  38. package/scaffold/src/modules/subdomain-tenancy/server/resend-public-tenant-activation-email-action.ts +118 -0
  39. package/scaffold/src/modules/subdomain-tenancy/server/validate-public-tenant-email-action.ts +138 -0
  40. package/scaffold/src/modules/subdomain-tenancy/signup/types.ts +129 -0
@@ -0,0 +1,1165 @@
1
+ "use client";
2
+
3
+ // seedgrid:managed
4
+
5
+ import { useEffect, useRef, useState } from "react";
6
+
7
+ import {
8
+ SgAccordion,
9
+ SgAutocomplete,
10
+ SgBadge,
11
+ SgButton,
12
+ SgCard,
13
+ SgGrid,
14
+ SgInputBirthDate,
15
+ SgInputCNPJ,
16
+ SgInputCPF,
17
+ SgInputEmail,
18
+ SgInputOTP,
19
+ SgInputPassword,
20
+ SgInputPhone,
21
+ SgInputPostalCode,
22
+ SgPanel,
23
+ SgStack,
24
+ SgInputText,
25
+ SgWizard,
26
+ SgWizardPage,
27
+ toast,
28
+ type ViaCepResponse,
29
+ } from "@seedgrid/fe-components";
30
+ import {
31
+ Building2,
32
+ MailCheck,
33
+ MapPinned,
34
+ RefreshCcw,
35
+ Rocket,
36
+ ShieldCheck,
37
+ } from "lucide-react";
38
+
39
+ import { appEnvironment } from "@/config/environment";
40
+ import { useI18n, type Translator } from "@/i18n";
41
+ import { captureException } from "@/modules/core";
42
+ import { createPublicTenantAction } from "@/modules/subdomain-tenancy/server/create-public-tenant-action";
43
+ import { resendPublicTenantActivationEmailAction } from "@/modules/subdomain-tenancy/server/resend-public-tenant-activation-email-action";
44
+ import { validatePublicTenantEmailAction } from "@/modules/subdomain-tenancy/server/validate-public-tenant-email-action";
45
+ import { normalizeSubdomain, validateSubdomain } from "@seedgrid/fe-subdomain-tenancy";
46
+ import {
47
+ buildCreatePublicTenantPayload,
48
+ createEmptyPublicTenantSignupForm,
49
+ type CreatePublicTenantActionResult,
50
+ type PublicTenantSignupFormValues,
51
+ } from "@/modules/subdomain-tenancy/signup/types";
52
+
53
+ const TENANT_HOST_SUFFIX = appEnvironment.hosts.tenantHostSuffix;
54
+ const DEPLOYMENT_PROTOCOL = appEnvironment.deployment.protocol;
55
+
56
+ type LocationOption = {
57
+ id: string;
58
+ label: string;
59
+ value?: string;
60
+ data?: {
61
+ stateId?: string;
62
+ cityId?: string;
63
+ };
64
+ };
65
+
66
+ async function fetchLocationOptions(path: string): Promise<LocationOption[]> {
67
+ try {
68
+ const response = await fetch(path, {
69
+ method: "GET",
70
+ cache: "no-store",
71
+ });
72
+
73
+ if (!response.ok) {
74
+ return [];
75
+ }
76
+
77
+ const payload = await response.json();
78
+ return Array.isArray(payload) ? (payload as LocationOption[]) : [];
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ function searchBrazilStates(query: string) {
85
+ return fetchLocationOptions(
86
+ `/api/locations/states?query=${encodeURIComponent(query)}`
87
+ );
88
+ }
89
+
90
+ function searchBrazilCities(query: string, stateId: string) {
91
+ if (!stateId) {
92
+ return Promise.resolve([]);
93
+ }
94
+
95
+ return fetchLocationOptions(
96
+ `/api/locations/cities?stateId=${encodeURIComponent(stateId)}&query=${encodeURIComponent(query)}`
97
+ );
98
+ }
99
+
100
+ export default function TenantSignupPage() {
101
+ const { t } = useI18n();
102
+ const [formValues, setFormValues] = useState<PublicTenantSignupFormValues>(
103
+ createEmptyPublicTenantSignupForm()
104
+ );
105
+ const [selectedAddressStateId, setSelectedAddressStateId] = useState("");
106
+ const [submissionResult, setSubmissionResult] =
107
+ useState<CreatePublicTenantActionResult | null>(null);
108
+ const [isSubmitting, setIsSubmitting] = useState(false);
109
+ const [emailVerificationCode, setEmailVerificationCode] = useState("");
110
+ const [emailVerificationError, setEmailVerificationError] = useState<
111
+ string | null
112
+ >(null);
113
+ const [isValidatingVerificationEmail, setIsValidatingVerificationEmail] =
114
+ useState(false);
115
+ const [isResendingVerificationEmail, setIsResendingVerificationEmail] =
116
+ useState(false);
117
+ const lastSubmittedEmailVerificationCodeRef = useRef<string | null>(null);
118
+ const successResult =
119
+ submissionResult && submissionResult.ok ? submissionResult : null;
120
+ const errorResult =
121
+ submissionResult && !submissionResult.ok ? submissionResult : null;
122
+ const emailVerificationDigits = emailVerificationCode.replace(/\D/g, "");
123
+ const isEmailVerificationCodeComplete =
124
+ emailVerificationDigits.length === 6;
125
+ const productName = formatProductName(
126
+ appEnvironment.namespace,
127
+ t("multitenancy.signup.product.fallback")
128
+ );
129
+ const emptyValue = t("common.empty");
130
+
131
+ const updateValue = (
132
+ key: keyof PublicTenantSignupFormValues,
133
+ value: string
134
+ ) => {
135
+ setFormValues((current) => ({
136
+ ...current,
137
+ [key]: value,
138
+ }));
139
+ };
140
+
141
+ useEffect(() => {
142
+ if (!successResult || !isEmailVerificationCodeComplete) {
143
+ return;
144
+ }
145
+
146
+ if (isValidatingVerificationEmail) {
147
+ return;
148
+ }
149
+
150
+ if (lastSubmittedEmailVerificationCodeRef.current === emailVerificationDigits) {
151
+ return;
152
+ }
153
+
154
+ const tenantSubdomain = successResult.tenantSubdomain;
155
+ lastSubmittedEmailVerificationCodeRef.current = emailVerificationDigits;
156
+
157
+ void (async () => {
158
+ setIsValidatingVerificationEmail(true);
159
+ setEmailVerificationError(null);
160
+
161
+ try {
162
+ const result = await validatePublicTenantEmailAction({
163
+ subdomain: tenantSubdomain,
164
+ code: emailVerificationDigits,
165
+ });
166
+
167
+ if (result.ok) {
168
+ window.location.assign(buildTenantLoginUrl(tenantSubdomain));
169
+ return;
170
+ }
171
+
172
+ setEmailVerificationError(result.message);
173
+ toast.error(result.message);
174
+ } catch (error) {
175
+ await captureException(error, {
176
+ area: "client",
177
+ source: "multitenancy.signup.page.validateVerificationEmail",
178
+ });
179
+
180
+ const message = t("multitenancy.signup.feedback.validate_unavailable");
181
+ setEmailVerificationError(message);
182
+ toast.error(message);
183
+ } finally {
184
+ setIsValidatingVerificationEmail(false);
185
+ }
186
+ })();
187
+ }, [
188
+ emailVerificationDigits,
189
+ isEmailVerificationCodeComplete,
190
+ isValidatingVerificationEmail,
191
+ successResult,
192
+ t,
193
+ ]);
194
+
195
+ useEffect(() => {
196
+ const stateCode = formValues.addressState.trim().toUpperCase();
197
+
198
+ if (!stateCode) {
199
+ setSelectedAddressStateId("");
200
+ return;
201
+ }
202
+
203
+ let isActive = true;
204
+
205
+ void (async () => {
206
+ const options = await searchBrazilStates(stateCode);
207
+ const matched = options.find(
208
+ (item) => String(item.value ?? item.id).trim().toUpperCase() === stateCode
209
+ );
210
+
211
+ if (!isActive) {
212
+ return;
213
+ }
214
+
215
+ setSelectedAddressStateId(
216
+ matched ? String((matched.data as Record<string, unknown>)?.stateId ?? matched.id ?? "") : ""
217
+ );
218
+ })();
219
+
220
+ return () => {
221
+ isActive = false;
222
+ };
223
+ }, [formValues.addressState]);
224
+
225
+ async function handleFinish() {
226
+ setIsSubmitting(true);
227
+ setSubmissionResult(null);
228
+
229
+ try {
230
+ const result = await createPublicTenantAction(
231
+ buildCreatePublicTenantPayload(formValues)
232
+ );
233
+ setSubmissionResult(result);
234
+ } catch (error) {
235
+ await captureException(error, {
236
+ area: "client",
237
+ source: "multitenancy.signup.page",
238
+ });
239
+ setSubmissionResult({
240
+ ok: false,
241
+ message: t("multitenancy.signup.feedback.submit_unavailable"),
242
+ });
243
+ } finally {
244
+ setIsSubmitting(false);
245
+ }
246
+ }
247
+
248
+ async function handleResendVerificationEmail() {
249
+ if (!successResult || isResendingVerificationEmail) {
250
+ return;
251
+ }
252
+
253
+ setIsResendingVerificationEmail(true);
254
+
255
+ try {
256
+ const result = await resendPublicTenantActivationEmailAction(
257
+ successResult.tenantSubdomain
258
+ );
259
+
260
+ if (result.ok) {
261
+ setEmailVerificationCode("");
262
+ setEmailVerificationError(null);
263
+ lastSubmittedEmailVerificationCodeRef.current = null;
264
+ toast.info(result.message);
265
+ return;
266
+ }
267
+
268
+ toast.error(result.message);
269
+ } catch (error) {
270
+ await captureException(error, {
271
+ area: "client",
272
+ source: "multitenancy.signup.page.resendVerificationEmail",
273
+ });
274
+ toast.error(t("multitenancy.signup.feedback.resend_unavailable"));
275
+ } finally {
276
+ setIsResendingVerificationEmail(false);
277
+ }
278
+ }
279
+
280
+ function handleEmailVerificationCodeChange(value: string) {
281
+ setEmailVerificationCode(value);
282
+ setEmailVerificationError(null);
283
+
284
+ if (value.replace(/\D/g, "").length < 6) {
285
+ lastSubmittedEmailVerificationCodeRef.current = null;
286
+ }
287
+ }
288
+
289
+ function handlePublicaCnpjResult(data: unknown | null) {
290
+ setFormValues((current) => ({
291
+ ...current,
292
+ corporateName: resolveLookupValue(
293
+ readLookupString(data, "razao_social"),
294
+ current.corporateName
295
+ ),
296
+ tradeName: resolveLookupValue(
297
+ readLookupString(data, "estabelecimento", "nome_fantasia"),
298
+ current.tradeName
299
+ ),
300
+ addressStreet: resolveLookupValue(
301
+ joinStreetLine(
302
+ readLookupString(data, "estabelecimento", "tipo_logradouro"),
303
+ readLookupString(data, "estabelecimento", "logradouro")
304
+ ),
305
+ current.addressStreet
306
+ ),
307
+ addressNumber: resolveLookupValue(
308
+ readLookupString(data, "estabelecimento", "numero"),
309
+ current.addressNumber
310
+ ),
311
+ addressComplement: resolveLookupValue(
312
+ readLookupString(data, "estabelecimento", "complemento"),
313
+ current.addressComplement
314
+ ),
315
+ addressDistrict: resolveLookupValue(
316
+ readLookupString(data, "estabelecimento", "bairro"),
317
+ current.addressDistrict
318
+ ),
319
+ addressPostalCode: resolveLookupValue(
320
+ formatPostalCode(readLookupString(data, "estabelecimento", "cep")),
321
+ current.addressPostalCode
322
+ ),
323
+ addressCity: resolveLookupValue(
324
+ readLookupString(data, "estabelecimento", "cidade", "nome"),
325
+ current.addressCity
326
+ ),
327
+ addressState: resolveLookupValue(
328
+ normalizeStateCode(
329
+ readLookupString(data, "estabelecimento", "estado", "sigla")
330
+ ),
331
+ current.addressState
332
+ ),
333
+ addressPhone: resolveLookupValue(
334
+ formatBrazilPhone(
335
+ readLookupString(data, "estabelecimento", "ddd1"),
336
+ readLookupString(data, "estabelecimento", "telefone1")
337
+ ),
338
+ current.addressPhone
339
+ ),
340
+ }));
341
+ }
342
+
343
+ function handleViaCepResult(data: ViaCepResponse) {
344
+ if (data.erro) {
345
+ return;
346
+ }
347
+
348
+ setFormValues((current) => ({
349
+ ...current,
350
+ addressStreet: resolveLookupValue(
351
+ normalizeLookupValue(data.logradouro),
352
+ current.addressStreet
353
+ ),
354
+ addressComplement: resolveLookupValue(
355
+ normalizeLookupValue(data.complemento),
356
+ current.addressComplement
357
+ ),
358
+ addressDistrict: resolveLookupValue(
359
+ normalizeLookupValue(data.bairro),
360
+ current.addressDistrict
361
+ ),
362
+ addressCity: resolveLookupValue(
363
+ normalizeLookupValue(data.localidade),
364
+ current.addressCity
365
+ ),
366
+ addressState: resolveLookupValue(
367
+ normalizeStateCode(data.uf),
368
+ current.addressState
369
+ ),
370
+ }));
371
+ }
372
+
373
+ return (
374
+ <main className="min-h-screen bg-slate-100 px-4 py-6 text-slate-950 sm:px-6 sm:py-10">
375
+ <SgStack className="mx-auto w-full max-w-5xl" gap={20}>
376
+ <SgCard
377
+ cardStyle="elevated"
378
+ size="lg"
379
+ className="border border-white/70 bg-white/90 backdrop-blur"
380
+ headerClassName="border-b border-slate-200/80"
381
+ title={
382
+ <div className="min-w-0">
383
+ <div className="text-sm font-medium uppercase tracking-[0.18em] text-slate-500">
384
+ {t("multitenancy.signup.header.eyebrow")}
385
+ </div>
386
+ <div className="mt-1 text-2xl font-semibold text-slate-950 sm:text-3xl">
387
+ {t("multitenancy.signup.header.title", { productName })}
388
+ </div>
389
+ </div>
390
+ }
391
+ >
392
+ {successResult ? (
393
+ <SgStack gap={20}>
394
+ <SgPanel className="rounded-[28px] border border-emerald-200 bg-emerald-50 p-6">
395
+ <SgStack direction="row" align="start" gap={16}>
396
+ <div className="rounded-2xl bg-emerald-600 p-3 text-white">
397
+ <MailCheck className="size-6" />
398
+ </div>
399
+ <SgStack gap={8}>
400
+ <SgBadge
401
+ value={t("multitenancy.signup.verification.badge")}
402
+ severity="success"
403
+ badgeStyle="soft"
404
+ rounded
405
+ />
406
+ <h2 className="text-2xl font-semibold text-emerald-950">
407
+ {t("multitenancy.signup.verification.title")}
408
+ </h2>
409
+ <p className="text-sm leading-6 text-emerald-900/80">
410
+ {t("multitenancy.signup.verification.description", {
411
+ email: successResult.rootEmail,
412
+ tenantHost: buildTenantHost(successResult.tenantSubdomain),
413
+ })}
414
+ </p>
415
+ </SgStack>
416
+ </SgStack>
417
+ </SgPanel>
418
+
419
+ <SgPanel className="rounded-[28px] border border-slate-200 bg-white p-5 sm:p-6">
420
+ <SgGrid minItemWidth={320} gap={16} align="end">
421
+ <SgInputOTP
422
+ id="tenant-email-verification-code"
423
+ label={t("multitenancy.signup.verification.code.label")}
424
+ hintText={t("multitenancy.signup.verification.code.hint")}
425
+ mask="999-999"
426
+ width="100%"
427
+ required
428
+ requiredMessage={t(
429
+ "multitenancy.signup.verification.code.required"
430
+ )}
431
+ validateOnBlur
432
+ validation={(rawValue) =>
433
+ rawValue.replace(/\D/g, "").length === 6
434
+ ? null
435
+ : t("multitenancy.signup.verification.code.invalid")
436
+ }
437
+ value={emailVerificationCode}
438
+ onChange={handleEmailVerificationCodeChange}
439
+ inputProps={{
440
+ autoComplete: "one-time-code",
441
+ disabled: isValidatingVerificationEmail,
442
+ inputMode: "numeric",
443
+ }}
444
+ />
445
+
446
+ <SgStack direction="row" wrap gap={12}>
447
+ <SgButton
448
+ appearance="outline"
449
+ severity="primary"
450
+ shape="rounded"
451
+ label={t("multitenancy.signup.verification.resend")}
452
+ disabled={isValidatingVerificationEmail}
453
+ loading={isResendingVerificationEmail}
454
+ type="button"
455
+ leftIcon={<RefreshCcw className="size-4" />}
456
+ onClick={handleResendVerificationEmail}
457
+ />
458
+ </SgStack>
459
+ </SgGrid>
460
+
461
+ <div
462
+ aria-live="polite"
463
+ className={`mt-3 text-sm leading-6 ${
464
+ emailVerificationError
465
+ ? "text-rose-700"
466
+ : isValidatingVerificationEmail
467
+ ? "text-sky-700"
468
+ : "text-slate-500"
469
+ }`}
470
+ >
471
+ {emailVerificationError
472
+ ? emailVerificationError
473
+ : isValidatingVerificationEmail
474
+ ? t("multitenancy.signup.verification.validating")
475
+ : t("multitenancy.signup.verification.idle")}
476
+ </div>
477
+ </SgPanel>
478
+
479
+ <SummaryGrid values={formValues} t={t} emptyValue={emptyValue} />
480
+
481
+ <SgStack direction="row" wrap gap={12}>
482
+ <SgButton
483
+ appearance="solid"
484
+ severity="primary"
485
+ shape="rounded"
486
+ label={t("multitenancy.signup.verification.back")}
487
+ type="button"
488
+ onClick={() => {
489
+ window.location.assign(buildProductUrl());
490
+ }}
491
+ />
492
+ </SgStack>
493
+ </SgStack>
494
+ ) : (
495
+ <SgStack gap={20}>
496
+ {errorResult ? (
497
+ <SgPanel className="rounded-[24px] border border-rose-200 bg-rose-50 p-4">
498
+ <SgStack direction="row" wrap align="center" gap={12}>
499
+ <p className="text-sm leading-6 text-rose-900">
500
+ {errorResult.message}
501
+ </p>
502
+ </SgStack>
503
+ </SgPanel>
504
+ ) : null}
505
+
506
+ <SgWizard
507
+ stepper="icons"
508
+ labels={{
509
+ previous: t("multitenancy.signup.wizard.previous"),
510
+ next: t("multitenancy.signup.wizard.next"),
511
+ finish: isSubmitting
512
+ ? t("multitenancy.signup.wizard.finishing")
513
+ : t("multitenancy.signup.wizard.finish"),
514
+ }}
515
+ onFinish={handleFinish}
516
+ className="rounded-[28px] border border-slate-200 bg-slate-50/80 p-3 sm:rounded-[32px] sm:p-5"
517
+ >
518
+ <SgWizardPage
519
+ title={t("multitenancy.signup.steps.company")}
520
+ icon={<Building2 className="size-5" />}
521
+ >
522
+ <SgStack gap={20}>
523
+ <SgStack gap={16}>
524
+ <SgInputCNPJ
525
+ id="tenant-cnpj"
526
+ label={t("multitenancy.signup.fields.cnpj.label")}
527
+ required
528
+ requiredMessage={t(
529
+ "multitenancy.signup.fields.cnpj.required"
530
+ )}
531
+ validateOnBlur
532
+ validateWithPublicaCnpj
533
+ publicaCnpjErrorMessage={t(
534
+ "multitenancy.signup.fields.cnpj.lookup_error"
535
+ )}
536
+ onPublicaCnpjResult={handlePublicaCnpjResult}
537
+ onPublicaCnpjError={(error) => {
538
+ void captureException(error, {
539
+ area: "client",
540
+ source: "multitenancy.signup.page.publicaCnpj",
541
+ });
542
+ }}
543
+ inputProps={{
544
+ value: formValues.cnpj,
545
+ onChange: (event) =>
546
+ updateValue("cnpj", event.target.value),
547
+ }}
548
+ />
549
+ <SgInputText
550
+ id="tenant-subdomain"
551
+ label={t("multitenancy.signup.fields.subdomain.label")}
552
+ hintText={t("multitenancy.signup.fields.subdomain.hint", {
553
+ deploymentProtocol: DEPLOYMENT_PROTOCOL,
554
+ tenantHostSuffix: TENANT_HOST_SUFFIX,
555
+ })}
556
+ required
557
+ requiredMessage={t(
558
+ "multitenancy.signup.fields.subdomain.required"
559
+ )}
560
+ validateOnBlur
561
+ prefixText={`${DEPLOYMENT_PROTOCOL}://`}
562
+ suffixText={`.${TENANT_HOST_SUFFIX}`}
563
+ validation={(value) => validateSubdomain(value, t)}
564
+ inputProps={{
565
+ value: formValues.subdomain,
566
+ onChange: (event) =>
567
+ updateValue(
568
+ "subdomain",
569
+ normalizeSubdomain(event.target.value)
570
+ ),
571
+ }}
572
+ />
573
+ <SgInputText
574
+ id="tenant-corporate-name"
575
+ label={t(
576
+ "multitenancy.signup.fields.corporate_name.label"
577
+ )}
578
+ required
579
+ requiredMessage={t(
580
+ "multitenancy.signup.fields.corporate_name.required"
581
+ )}
582
+ validateOnBlur
583
+ inputProps={{
584
+ value: formValues.corporateName,
585
+ onChange: (event) =>
586
+ updateValue("corporateName", event.target.value),
587
+ }}
588
+ />
589
+ <SgInputText
590
+ id="tenant-trade-name"
591
+ label={t("multitenancy.signup.fields.trade_name.label")}
592
+ required
593
+ requiredMessage={t(
594
+ "multitenancy.signup.fields.trade_name.required"
595
+ )}
596
+ validateOnBlur
597
+ inputProps={{
598
+ value: formValues.tradeName,
599
+ onChange: (event) =>
600
+ updateValue("tradeName", event.target.value),
601
+ }}
602
+ />
603
+ <SgInputPhone
604
+ id="tenant-address-phone"
605
+ label={t("multitenancy.signup.fields.address_phone.label")}
606
+ required
607
+ requiredMessage={t(
608
+ "multitenancy.signup.fields.address_phone.required"
609
+ )}
610
+ validateOnBlur
611
+ inputProps={{
612
+ value: formValues.addressPhone,
613
+ onChange: (event) =>
614
+ updateValue("addressPhone", event.target.value),
615
+ }}
616
+ />
617
+ </SgStack>
618
+
619
+ <SgPanel className="rounded-[24px] border border-slate-200 bg-white p-4">
620
+ <SgStack gap={8}>
621
+ <div className="text-sm font-semibold text-slate-900">
622
+ {t("multitenancy.signup.fields.subdomain.rules.title")}
623
+ </div>
624
+ <SgStack gap={4} className="text-sm leading-6 text-slate-600">
625
+ <p>
626
+ {t(
627
+ "multitenancy.signup.fields.subdomain.rules.characters"
628
+ )}
629
+ </p>
630
+ <p>
631
+ {t("multitenancy.signup.fields.subdomain.rules.host", {
632
+ deploymentProtocol: DEPLOYMENT_PROTOCOL,
633
+ tenantHost:
634
+ formValues.subdomain.trim().length > 0
635
+ ? buildTenantHost(formValues.subdomain)
636
+ : TENANT_HOST_SUFFIX,
637
+ })}
638
+ </p>
639
+ </SgStack>
640
+ </SgStack>
641
+ </SgPanel>
642
+ </SgStack>
643
+ </SgWizardPage>
644
+ <SgWizardPage
645
+ title={t("multitenancy.signup.steps.address")}
646
+ icon={<MapPinned className="size-5" />}
647
+ >
648
+ <SgStack gap={16}>
649
+ <SgInputPostalCode
650
+ id="tenant-address-postal-code"
651
+ label={t("multitenancy.signup.fields.postal_code.label")}
652
+ required
653
+ requiredMessage={t(
654
+ "multitenancy.signup.fields.postal_code.required"
655
+ )}
656
+ validateOnBlur
657
+ validateWithViaCep
658
+ viaCepErrorMessage={t(
659
+ "multitenancy.signup.fields.postal_code.lookup_error"
660
+ )}
661
+ onViaCepResult={handleViaCepResult}
662
+ onViaCepError={(error) => {
663
+ void captureException(error, {
664
+ area: "client",
665
+ source: "multitenancy.signup.page.viaCep",
666
+ });
667
+ }}
668
+ inputProps={{
669
+ value: formValues.addressPostalCode,
670
+ onChange: (event) =>
671
+ updateValue("addressPostalCode", event.target.value),
672
+ }}
673
+ />
674
+ <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_120px]">
675
+ <SgInputText
676
+ id="tenant-address-street"
677
+ label={t("multitenancy.signup.fields.street.label")}
678
+ required
679
+ requiredMessage={t(
680
+ "multitenancy.signup.fields.street.required"
681
+ )}
682
+ maxLength={60}
683
+ validateOnBlur
684
+ inputProps={{
685
+ value: formValues.addressStreet,
686
+ onChange: (event) =>
687
+ updateValue("addressStreet", event.target.value),
688
+ }}
689
+ />
690
+ <SgInputText
691
+ id="tenant-address-number"
692
+ label={t("multitenancy.signup.fields.number.label")}
693
+ required
694
+ requiredMessage={t(
695
+ "multitenancy.signup.fields.number.required"
696
+ )}
697
+ maxLength={6}
698
+ validateOnBlur
699
+ inputProps={{
700
+ inputMode: "numeric",
701
+ value: formValues.addressNumber,
702
+ onChange: (event) =>
703
+ updateValue(
704
+ "addressNumber",
705
+ event.target.value.replace(/\D/g, "").slice(0, 6)
706
+ ),
707
+ }}
708
+ />
709
+ </div>
710
+ <SgInputText
711
+ id="tenant-address-complement"
712
+ label={t("multitenancy.signup.fields.complement.label")}
713
+ inputProps={{
714
+ value: formValues.addressComplement,
715
+ onChange: (event) =>
716
+ updateValue("addressComplement", event.target.value),
717
+ }}
718
+ />
719
+ <SgInputText
720
+ id="tenant-address-district"
721
+ label={t("multitenancy.signup.fields.district.label")}
722
+ required
723
+ requiredMessage={t(
724
+ "multitenancy.signup.fields.district.required"
725
+ )}
726
+ validateOnBlur
727
+ inputProps={{
728
+ value: formValues.addressDistrict,
729
+ onChange: (event) =>
730
+ updateValue("addressDistrict", event.target.value),
731
+ }}
732
+ />
733
+ <SgGrid minItemWidth={220} gap={16}>
734
+ <SgAutocomplete
735
+ id="tenant-address-state"
736
+ label={t("multitenancy.signup.fields.state.label")}
737
+ hintText={t("multitenancy.signup.fields.state.label")}
738
+ source={searchBrazilStates}
739
+ minLengthForSearch={0}
740
+ openOnFocus
741
+ showDropDownButton
742
+ required
743
+ onSelect={(item) => {
744
+ const nextStateCode = String(item.value ?? item.id)
745
+ .trim()
746
+ .toUpperCase();
747
+ const nextStateId = String(
748
+ (item.data as Record<string, unknown>)?.stateId ?? item.id ?? ""
749
+ );
750
+
751
+ setSelectedAddressStateId(nextStateId);
752
+ setFormValues((current) => ({
753
+ ...current,
754
+ addressState: nextStateCode,
755
+ addressCity: "",
756
+ }));
757
+ }}
758
+ formatSelection={(item) => String(item.value ?? item.id)}
759
+ renderItem={(item) => (
760
+ <SgStack
761
+ direction="row"
762
+ justify="between"
763
+ align="center"
764
+ gap={12}
765
+ >
766
+ <span>{item.label}</span>
767
+ <span className="text-xs text-slate-400">
768
+ {String(item.value ?? item.id)}
769
+ </span>
770
+ </SgStack>
771
+ )}
772
+ />
773
+ <SgAutocomplete
774
+ id="tenant-address-city"
775
+ label={t("multitenancy.signup.fields.city.label")}
776
+ hintText={t("multitenancy.signup.fields.city.label")}
777
+ source={(query) =>
778
+ searchBrazilCities(query, selectedAddressStateId)
779
+ }
780
+ minLengthForSearch={0}
781
+ openOnFocus
782
+ showDropDownButton
783
+ required
784
+ onSelect={(item) => {
785
+ updateValue(
786
+ "addressCity",
787
+ String(item.value ?? item.label ?? item.id)
788
+ );
789
+ }}
790
+ formatSelection={(item) =>
791
+ String(item.value ?? item.label ?? item.id)
792
+ }
793
+ />
794
+ </SgGrid>
795
+ </SgStack>
796
+ </SgWizardPage>
797
+
798
+ <SgWizardPage
799
+ title={t("multitenancy.signup.steps.responsible")}
800
+ icon={<ShieldCheck className="size-5" />}
801
+ >
802
+ <SgStack gap={16}>
803
+ <div className="grid gap-4 md:grid-cols-[220px_minmax(0,1.35fr)_minmax(0,1fr)]">
804
+ <SgInputCPF
805
+ id="tenant-legal-cpf"
806
+ label={t("multitenancy.signup.fields.cpf.label")}
807
+ required
808
+ requiredMessage={t(
809
+ "multitenancy.signup.fields.cpf.required"
810
+ )}
811
+ validateOnBlur
812
+ inputProps={{
813
+ value: formValues.legalCpf,
814
+ onChange: (event) =>
815
+ updateValue("legalCpf", event.target.value),
816
+ }}
817
+ />
818
+ <SgInputText
819
+ id="tenant-legal-first-name"
820
+ label={t("multitenancy.signup.fields.first_name.label")}
821
+ required
822
+ requiredMessage={t(
823
+ "multitenancy.signup.fields.first_name.required"
824
+ )}
825
+ validateOnBlur
826
+ inputProps={{
827
+ value: formValues.legalFirstName,
828
+ onChange: (event) =>
829
+ updateValue("legalFirstName", event.target.value),
830
+ }}
831
+ />
832
+ <SgInputText
833
+ id="tenant-legal-last-name"
834
+ label={t("multitenancy.signup.fields.last_name.label")}
835
+ required
836
+ requiredMessage={t(
837
+ "multitenancy.signup.fields.last_name.required"
838
+ )}
839
+ validateOnBlur
840
+ inputProps={{
841
+ value: formValues.legalLastName,
842
+ onChange: (event) =>
843
+ updateValue("legalLastName", event.target.value),
844
+ }}
845
+ />
846
+ </div>
847
+ <SgGrid minItemWidth={220} gap={16}>
848
+ <SgInputBirthDate
849
+ id="tenant-legal-birth-date"
850
+ label={t("multitenancy.signup.fields.birth_date.label")}
851
+ required
852
+ requiredMessage={t(
853
+ "multitenancy.signup.fields.birth_date.required"
854
+ )}
855
+ minAge={18}
856
+ maxAge={80}
857
+ validateOnBlur
858
+ inputProps={{
859
+ value: formValues.legalBirthDate,
860
+ onChange: (event) =>
861
+ updateValue("legalBirthDate", event.target.value),
862
+ }}
863
+ />
864
+ <SgInputPhone
865
+ id="tenant-legal-phone"
866
+ label={t("multitenancy.signup.fields.phone.label")}
867
+ required
868
+ requiredMessage={t(
869
+ "multitenancy.signup.fields.phone.required"
870
+ )}
871
+ validateOnBlur
872
+ inputProps={{
873
+ value: formValues.legalPhone,
874
+ onChange: (event) =>
875
+ updateValue("legalPhone", event.target.value),
876
+ }}
877
+ />
878
+ <SgInputPhone
879
+ id="tenant-legal-whatsapp"
880
+ label={t("multitenancy.signup.fields.whatsapp.label")}
881
+ required
882
+ requiredMessage={t(
883
+ "multitenancy.signup.fields.whatsapp.required"
884
+ )}
885
+ validateOnBlur
886
+ inputProps={{
887
+ value: formValues.legalWhatsapp,
888
+ onChange: (event) =>
889
+ updateValue("legalWhatsapp", event.target.value),
890
+ }}
891
+ />
892
+ <SgInputEmail
893
+ id="tenant-root-email"
894
+ label={t("multitenancy.signup.fields.root_email.label")}
895
+ required
896
+ requiredMessage={t(
897
+ "multitenancy.signup.fields.root_email.required"
898
+ )}
899
+ validateOnBlur
900
+ inputProps={{
901
+ value: formValues.rootEmail,
902
+ onChange: (event) =>
903
+ updateValue("rootEmail", event.target.value),
904
+ }}
905
+ />
906
+ </SgGrid>
907
+ <div className="grid gap-4 md:grid-cols-2">
908
+ <SgInputPassword
909
+ id="tenant-root-password"
910
+ label={t("multitenancy.signup.fields.root_password.label")}
911
+ required
912
+ requiredMessage={t(
913
+ "multitenancy.signup.fields.root_password.required"
914
+ )}
915
+ validateOnBlur
916
+ minSize={8}
917
+ showStrengthBar
918
+ inputProps={{
919
+ value: formValues.plainRootPassword,
920
+ onChange: (event) =>
921
+ updateValue("plainRootPassword", event.target.value),
922
+ }}
923
+ />
924
+ <SgInputPassword
925
+ id="tenant-root-password-confirm"
926
+ label={t(
927
+ "multitenancy.signup.fields.confirm_password.label"
928
+ )}
929
+ required
930
+ requiredMessage={t(
931
+ "multitenancy.signup.fields.confirm_password.required"
932
+ )}
933
+ validateOnBlur
934
+ showStrengthBar
935
+ validation={(value) =>
936
+ value === formValues.plainRootPassword
937
+ ? null
938
+ : t(
939
+ "multitenancy.signup.fields.confirm_password.mismatch"
940
+ )
941
+ }
942
+ inputProps={{
943
+ value: formValues.confirmPassword,
944
+ onChange: (event) =>
945
+ updateValue("confirmPassword", event.target.value),
946
+ }}
947
+ />
948
+ </div>
949
+ </SgStack>
950
+ </SgWizardPage>
951
+
952
+ <SgWizardPage
953
+ title={t("multitenancy.signup.steps.review")}
954
+ icon={<Rocket className="size-5" />}
955
+ >
956
+ <SummaryGrid values={formValues} t={t} emptyValue={emptyValue} />
957
+ </SgWizardPage>
958
+ </SgWizard>
959
+ </SgStack>
960
+ )}
961
+ </SgCard>
962
+ </SgStack>
963
+ </main>
964
+ );
965
+ }
966
+
967
+ function SummaryGrid({
968
+ values,
969
+ t,
970
+ emptyValue,
971
+ }: {
972
+ values: PublicTenantSignupFormValues;
973
+ t: Translator;
974
+ emptyValue: string;
975
+ }) {
976
+ const summarySections = [
977
+ {
978
+ id: "company",
979
+ title: t("multitenancy.signup.summary.company"),
980
+ rows: [
981
+ [t("multitenancy.signup.summary.subdomain"), values.subdomain || emptyValue],
982
+ [
983
+ t("multitenancy.signup.summary.final_host"),
984
+ values.subdomain ? buildTenantHost(values.subdomain) : emptyValue,
985
+ ],
986
+ [t("multitenancy.signup.summary.cnpj"), values.cnpj || emptyValue],
987
+ [
988
+ t("multitenancy.signup.summary.corporate_name"),
989
+ values.corporateName || emptyValue,
990
+ ],
991
+ [
992
+ t("multitenancy.signup.summary.trade_name"),
993
+ values.tradeName || emptyValue,
994
+ ],
995
+ ],
996
+ },
997
+ {
998
+ id: "address",
999
+ title: t("multitenancy.signup.summary.address"),
1000
+ rows: [
1001
+ [t("multitenancy.signup.summary.street"), values.addressStreet || emptyValue],
1002
+ [t("multitenancy.signup.summary.number"), values.addressNumber || emptyValue],
1003
+ [
1004
+ t("multitenancy.signup.summary.complement"),
1005
+ values.addressComplement || emptyValue,
1006
+ ],
1007
+ [t("multitenancy.signup.summary.city"), values.addressCity || emptyValue],
1008
+ [t("multitenancy.signup.summary.state"), values.addressState || emptyValue],
1009
+ [
1010
+ t("multitenancy.signup.summary.district"),
1011
+ values.addressDistrict || emptyValue,
1012
+ ],
1013
+ [
1014
+ t("multitenancy.signup.summary.postal_code"),
1015
+ values.addressPostalCode || emptyValue,
1016
+ ],
1017
+ [t("multitenancy.signup.summary.phone"), values.addressPhone || emptyValue],
1018
+ ],
1019
+ },
1020
+ {
1021
+ id: "responsible",
1022
+ title: t("multitenancy.signup.summary.responsible"),
1023
+ rows: [
1024
+ [
1025
+ t("multitenancy.signup.summary.name"),
1026
+ [values.legalFirstName, values.legalLastName].filter(Boolean).join(" ") ||
1027
+ emptyValue,
1028
+ ],
1029
+ [t("multitenancy.signup.summary.cpf"), values.legalCpf || emptyValue],
1030
+ [
1031
+ t("multitenancy.signup.summary.birth_date"),
1032
+ values.legalBirthDate || emptyValue,
1033
+ ],
1034
+ [t("multitenancy.signup.summary.phone"), values.legalPhone || emptyValue],
1035
+ [
1036
+ t("multitenancy.signup.summary.whatsapp"),
1037
+ values.legalWhatsapp || emptyValue,
1038
+ ],
1039
+ [
1040
+ t("multitenancy.signup.summary.root_email"),
1041
+ values.rootEmail || emptyValue,
1042
+ ],
1043
+ ],
1044
+ },
1045
+ ] as const;
1046
+
1047
+ const items = summarySections.map((section) => ({
1048
+ id: section.id,
1049
+ title: section.title,
1050
+ content: (
1051
+ <SgStack gap={12} className="text-sm">
1052
+ {section.rows.map(([label, value]) => (
1053
+ <SgGrid
1054
+ key={label}
1055
+ minItemWidth={192}
1056
+ gap={16}
1057
+ className="border-b border-slate-100 pb-3 last:border-b-0 last:pb-0"
1058
+ >
1059
+ <span className="font-medium text-slate-500">{label}</span>
1060
+ <span className="break-words text-left text-slate-900">
1061
+ {value}
1062
+ </span>
1063
+ </SgGrid>
1064
+ ))}
1065
+ </SgStack>
1066
+ ),
1067
+ }));
1068
+
1069
+ return (
1070
+ <SgAccordion
1071
+ items={items}
1072
+ orientation="vertical"
1073
+ defaultActiveIndex={0}
1074
+ panelClassName="border border-slate-200 bg-white"
1075
+ headerClassName="text-slate-950"
1076
+ headerBackgroundColor="rgba(248,250,252,0.9)"
1077
+ className="space-y-3"
1078
+ />
1079
+ );
1080
+ }
1081
+
1082
+ function formatProductName(value: string, fallbackValue: string) {
1083
+ const normalized = value
1084
+ .split(/[^a-zA-Z0-9]+/)
1085
+ .map((part) => part.trim())
1086
+ .filter(Boolean);
1087
+
1088
+ if (normalized.length === 0) {
1089
+ return fallbackValue;
1090
+ }
1091
+
1092
+ return normalized
1093
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1094
+ .join(" ");
1095
+ }
1096
+
1097
+ function buildTenantHost(subdomain: string) {
1098
+ return `${normalizeSubdomain(subdomain)}.${TENANT_HOST_SUFFIX}`;
1099
+ }
1100
+
1101
+ function buildProductUrl() {
1102
+ return `${DEPLOYMENT_PROTOCOL}://${TENANT_HOST_SUFFIX}`;
1103
+ }
1104
+
1105
+ function buildTenantLoginUrl(subdomain: string) {
1106
+ return `${DEPLOYMENT_PROTOCOL}://${buildTenantHost(subdomain)}/auth/login`;
1107
+ }
1108
+
1109
+ function normalizeLookupValue(value: unknown) {
1110
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1111
+ }
1112
+
1113
+ function readLookupString(value: unknown, ...path: string[]) {
1114
+ let current: unknown = value;
1115
+
1116
+ for (const key of path) {
1117
+ if (!current || typeof current !== "object") {
1118
+ return null;
1119
+ }
1120
+
1121
+ current = Reflect.get(current, key);
1122
+ }
1123
+
1124
+ return normalizeLookupValue(current);
1125
+ }
1126
+
1127
+ function resolveLookupValue(nextValue: string | null, currentValue: string) {
1128
+ return nextValue ?? currentValue;
1129
+ }
1130
+
1131
+ function joinStreetLine(type: string | null, street: string | null) {
1132
+ const parts = [type, street].filter(Boolean);
1133
+ return parts.length ? parts.join(" ") : null;
1134
+ }
1135
+
1136
+ function formatPostalCode(value: string | null) {
1137
+ const digits = (value ?? "").replace(/\D/g, "").slice(0, 8);
1138
+ if (digits.length <= 5) {
1139
+ return digits || null;
1140
+ }
1141
+
1142
+ return `${digits.slice(0, 5)}-${digits.slice(5)}`;
1143
+ }
1144
+
1145
+ function formatBrazilPhone(ddd: string | null, phone: string | null) {
1146
+ const digits = `${ddd ?? ""}${phone ?? ""}`.replace(/\D/g, "").slice(0, 11);
1147
+
1148
+ if (digits.length < 10) {
1149
+ return null;
1150
+ }
1151
+
1152
+ const areaCode = digits.slice(0, 2);
1153
+ const isMobile = digits.length === 11;
1154
+ const firstPart = digits.slice(2, isMobile ? 7 : 6);
1155
+ const secondPart = digits.slice(isMobile ? 7 : 6);
1156
+
1157
+ return `(${areaCode}) ${firstPart}-${secondPart}`;
1158
+ }
1159
+
1160
+ function normalizeStateCode(value: string | null | undefined) {
1161
+ return typeof value === "string" && value.trim()
1162
+ ? value.trim().toUpperCase()
1163
+ : null;
1164
+ }
1165
+