Sistema de slugs — guia para replicar em outro frontend
Este documento explica como o default_crm-front usa slugs e como aplicar o mesmo modelo em outro frontend (React, Vue, Angular, mobile, etc.).
Há dois slugs diferentes no ecossistema. Não misture um com o outro.
| Conceito | O que identifica | De onde vem | Para que serve |
|---|---|---|---|
| Slug do tenant (empresa) | A empresa dona do CRM (white-label) | Subdomínio da URL (gevtech.gevtech.com.br → gevtech) | Branding, login do CRM, APIs GET /tenants/:slug/* |
| Slug do estabelecimento comercial (EC) | O lojista / cliente final | Campo slug no cadastro do EC (API/metadata) | URL do portal do cliente (https://meta.gevtech.com.br/customer/login) |
1. Slug do tenant (multi-tenant por subdomínio)
Como funciona
O tenant é resolvido pelo hostname do browser:
https://{tenantSlug}.gevtech.com.br/manager/login
└─ tenantSlug = "gevtech"
Em desenvolvimento local:
http://horizonte-obras.localhost:3000/login
└─ tenantSlug = "horizonte-obras"
Algoritmo de resolução (ordem de prioridade)
Implementação de referência: src/common/helpers/company/tenant-subdomain.ts → resolveTenantSlug().
-
Subdomínio do hostname (
getTenantSlugFromHostname)- Ignora
www,app,api localhostpuro → sem subdomínio*.localhost→ primeiro segmentoa.b.dominio.com→a(primeiro segmento)dominio.com(2 partes) → sem subdomínio
- Ignora
-
Session storage (
tenant_slug) — persistido após login bem-sucedido -
Variável de ambiente
NEXT_PUBLIC_TENANT_SLUG -
Fallback de dev (
gevtech) — somenteNODE_ENV === "development" -
Produção sem subdomínio → string vazia (não inventar slug)
Branding do tenant
Com o slug resolvido, o frontend busca nome, logo e banner:
GET /api/tenants/{tenantSlug}/nome
GET /api/tenants/{tenantSlug}/icon?json=true
No CRM, isso alimenta a tela de login (TenantBrandingProvider → useTenantBranding).
Login do CRM — atenção ao campo tenantSlug
O subdomínio identifica qual tenant carregar visualmente, mas o body do login envia outro valor:
POST /api/auth/login
{
"tenantSlug": "GEV TECNOLOGIA & INTELIGENCIA LTDA",
"email": "user@exemplo.com",
"password": "..."
}
- No formulário,
tenantSlugé preenchido com a razão social / nome da empresa (vindo deGET /tenants/:slug/nome), não com o slug do subdomínio. - O proxy (
src/app/api/auth/login/route.ts) converte pararazao_socialno backend legado.
Resumo para outro frontend:
| Contexto | Valor usado |
|---|---|
| Subdomínio da URL | gevtech |
GET /tenants/:slug/nome | :slug = subdomínio |
POST /auth/login → tenantSlug | razão social retornada pelo branding |
Variáveis de ambiente (tenant)
# Dev sem subdomínio
NEXT_PUBLIC_TENANT_SLUG=gevtech
NEXT_PUBLIC_TENANT_ID=uuid-do-tenant
# Opcional — UUID fixo do tenant em dev
Checklist — tenant em outro frontend
- Parser de subdomínio compatível com
*.localhoste produção - Lista de subdomínios reservados (
www,app,api) - Cache de branding por slug (session/local storage, TTL ~24h)
- Tela de login bloqueada se tenant inativo/suspenso
- Não confundir slug do subdomínio com
tenantSlugdo POST de login - Em dev, fallback via env quando não há subdomínio
2. Slug do estabelecimento comercial (portal do cliente)
O que é
Cada estabelecimento comercial (loja/EC) tem um slug único usado como subdomínio do portal do cliente:
slug: "meta"
→ https://meta.gevtech.com.br/customer/login
Onde fica na API
O backend pode devolver o slug em:
data.slug(raiz do objeto), oudata.metadata.slug(seção complementar)
Helper de referência: pickEstabelecimentoComercialSlugFromApiData() em
src/common/helpers/estabelecimento/estabelecimento-comercial-slug.ts.
Sempre use esse helper (ou equivalente) em vez de assumir um único caminho.
Regras de validação
| Regra | Valor |
|---|---|
| Regex | ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
| Tamanho máximo | 100 caracteres |
| Vazio | permitido no formulário (backend pode exigir antes de criar painel) |
| Unicidade | conflito 409 → exibir erro no campo slug |
Normalização (slugify)
Ao digitar nome fantasia, o CRM sugere slug automaticamente (paridade com backend):
function slugifyEstabelecimentoComercial(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // remove acentos
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-") // não-alfanumérico → hífen
.replace(/^-+|-+$/g, "") // trim hífens
.slice(0, 100);
}
Exemplo: "Padaria Central 24h" → "padaria-central-24h".
Montagem da URL do portal
function buildCustomerPortalLoginUrl(slug: string): string {
const clean = slug.trim();
if (!clean) return "";
// Opção A: template completo
const template = process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_LOGIN_URL;
// ex.: "https://{slug}.gevtech.com.br/customer/login"
if (template?.includes("{slug}")) {
return template.replaceAll("{slug}", clean);
}
// Opção B: domínio base + path fixo
const baseDomain =
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_BASE_DOMAIN ?? "gevtech.com.br";
const protocol = baseDomain.includes("localhost") ? "http" : "https";
return `${protocol}://${clean}.${baseDomain}/customer/login`;
}
Formato correto:
https://{slug-ec}.gevtech.com.br/customer/login
Formato incorreto (não usar):
https://gevtech.com.br/customer/{slug}/login ❌
http://localhost:3002/customer/{slug}/login ❌
Cadastro e edição do EC
| Ação | API | Campo |
|---|---|---|
| Criar EC | POST /estabelecimentos-comerciais | slug opcional no body |
| Editar EC | PUT /estabelecimentos-comerciais/:uuid | slug no diff parcial |
| Conflito | HTTP 409 | mapear mensagem → campo slug |
Payload de criação (trecho):
{
"nomeFantasia": "META",
"slug": "meta",
"cpfCnpj": "..."
}
Criar painel do estabelecimento
Fluxo no CRM após selecionar contatos:
GET /estabelecimentos-comerciais/:uuid— ler slug- Exibir URL:
buildCustomerPortalLoginUrl(slug) POST /estabelecimentos-comerciais/:uuid/painel— criar acessosPOST /estabelecimentos-comerciais/:uuid/welcome-email— e-mail de boas-vindas por contato
Sem slug configurado → bloquear criação do painel e orientar edição do cadastro.
Variáveis de ambiente (portal EC)
NEXT_PUBLIC_CUSTOMER_PORTAL_BASE_DOMAIN=gevtech.com.br
NEXT_PUBLIC_CUSTOMER_PORTAL_LOGIN_URL=https://{slug}.gevtech.com.br/customer/login
Use uma das duas abordagens (template completo ou base domain + path no código).
Checklist — EC em outro frontend
- Campo
slugno formulário de cadastro/edição - Slugify ao alterar nome fantasia (opcional, mas recomendado)
- Validação client-side com a mesma regex do backend
- Tratar 409 como slug duplicado
- Ler slug com fallback
data.slugedata.metadata.slug - Montar URL do portal só com
buildCustomerPortalLoginUrl - Exigir slug antes de fluxos que dependem do portal (painel, links públicos)
3. Portal do cliente (outro frontend) — recebendo o slug
Se você está construindo o frontend do portal (/customer/login), o slug do EC vem do subdomínio, de forma análoga ao tenant no CRM:
https://meta.gevtech.com.br/customer/login
└─ ecSlug = "meta"
Passos:
- Extrair
metado hostname (mesma lógica degetTenantSlugFromHostname, adaptando o domínio base). - Opcional:
GET /api/tenants/:slug/nomeou endpoint específico do EC para branding. - No login do portal, enviar credenciais + identificador do estabelecimento conforme contrato da API de auth do portal.
O CRM e o portal podem compartilhar o mesmo padrão de subdomínio, mas o significado do slug muda: no CRM é tenant; no portal do cliente é o EC.
4. Estrutura mínima de código (portável)
Sugestão de módulos em qualquer stack:
lib/
tenant/
resolve-tenant-slug.ts # subdomínio → tenant
fetch-tenant-branding.ts # GET /tenants/:slug/nome
estabelecimento/
slugify.ts # normalização
validate-slug.ts # regex + tamanho
pick-slug-from-api.ts # data.slug | metadata.slug
build-portal-login-url.ts # URL pública do EC
Exemplo integrado (pseudo-código)
// Após salvar ou abrir detalhe do EC
const detail = await api.get(`/estabelecimentos-comerciais/${uuid}`);
const slug = pickEstabelecimentoComercialSlugFromApiData(detail.data);
if (!slug) {
showWarning("Configure o slug antes de gerar o painel.");
return;
}
const portalUrl = buildCustomerPortalLoginUrl(slug);
// "https://meta.gevtech.com.br/customer/login"
// Cadastro — ao blur do nome fantasia
onNomeFantasiaChange((nome) => {
setSlug(slugifyEstabelecimentoComercial(nome));
});
// Login CRM — após carregar branding
const tenantSlugFromHost = resolveTenantSlug(); // "gevtech"
const branding = await fetchTenantBranding(tenantSlugFromHost);
const razaoSocial = branding.nome; // para POST login
await api.post("/auth/login", {
tenantSlug: razaoSocial,
email,
password,
});
5. Fluxograma resumido
flowchart TB
subgraph CRM["Frontend CRM"]
A[Usuário acessa gevtech.gevtech.com.br] --> B[resolveTenantSlug → gevtech]
B --> C[GET /tenants/gevtech/nome]
C --> D[Tela de login com branding]
D --> E[POST /auth/login com razão social]
F[Cadastro EC slug meta] --> G[POST /estabelecimentos-comerciais]
G --> H[Criar painel]
H --> I[buildCustomerPortalLoginUrl meta]
I --> J[https://meta.gevtech.com.br/customer/login]
end
subgraph Portal["Frontend portal do cliente"]
K[Usuário acessa meta.gevtech.com.br] --> L[Extrai ecSlug meta]
L --> M[/customer/login]
end
6. Erros comuns
| Erro | Correção |
|---|---|
| Usar slug do subdomínio no login do CRM | Enviar razão social em tenantSlug |
URL .../customer/{slug}/login | Usar subdomínio: {slug}.dominio.com/customer/login |
Ler só data.slug e falhar quando API manda em metadata | Usar pickEstabelecimentoComercialSlugFromApiData |
| Slug com maiúsculas ou acentos | Normalizar com slugify antes de enviar |
| Criar painel sem slug | Bloquear UI e pedir edição do cadastro |
| Confundir tenant slug com EC slug | São entidades e URLs diferentes |
7. Referências no repositório
| Arquivo | Responsabilidade |
|---|---|
src/common/helpers/company/tenant-subdomain.ts | Resolução do slug do tenant |
src/common/helpers/estabelecimento/estabelecimento-comercial-slug.ts | Slug do EC, URL do portal |
src/controllers/hooks/company/useTenantBranding.ts | Branding por slug do tenant |
src/views/components/LoginForm.tsx | Login com razão social |
src/common/helpers/estabelecimento/estabelecimento-comercial-create-payload.ts | Envio do slug no POST |
src/views/components/estabelecimento/lista/modals/CriarPainelEstabelecimentoModal.tsx | URL do portal + painel |
src/__tests__/common/helpers/estabelecimento-comercial-slug.test.ts | Testes de slugify e URL |
src/__tests__/common/helpers/tenant-subdomain.test.ts | Testes de subdomínio |
.env.example | Variáveis do portal do cliente |
8. Testes recomendados ao portar
Copie ou reimplemente os casos dos testes existentes:
Tenant
horizonte-obras.localhost→horizonte-obrasgevtech.gevtech.com.br→gevtechwww.gevtech.com.br→ sem slug (reservado)- Fallback
NEXT_PUBLIC_TENANT_SLUGem dev
EC
slugify("Padaria Central 24h")→padaria-central-24hbuildCustomerPortalLoginUrl("meta")→https://meta.gevtech.com.br/customer/login- Template customizado via
NEXT_PUBLIC_CUSTOMER_PORTAL_LOGIN_URL pickEstabelecimentoComercialSlugFromApiData({ metadata: { slug: "x" } })→"x"
9. Resumo executivo
- Tenant = subdomínio do CRM → branding + contexto; login usa razão social.
- EC = campo
slugno cadastro → subdomínio do portal do cliente. - Reutilize as mesmas funções de normalização, validação e montagem de URL para manter paridade com o backend.
- Configure envs de domínio do portal por ambiente (dev/staging/prod).
- Trate
metadata.slugeslugna raiz ao ler respostas da API.
Com isso, qualquer frontend consegue integrar o mesmo comportamento sem depender do Next.js ou deste repositório diretamente.