Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)

Adiciona o frontend Next.js em frontend/ (setup, design system,
autenticação/tema, layout/navegação, área master, área tenant completa:
clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas,
equipe e perfil). Estende o backend de forma aditiva e autorizada:
cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe +
edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo
e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 09:41:13 -03:00
co-authored by Claude Sonnet 5
parent f9738e54da
commit 323912748f
179 changed files with 29312 additions and 22 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* Contraste WCAG AA (seção 6 do schema de arquitetura). Usado para
* garantir que a cor de marca de um tenant, quando usada como texto/ícone
* sobre fundo claro, continue legível — mesmo que o tenant tenha
* escolhido uma cor originalmente clara demais para isso.
*/
interface Rgb {
r: number;
g: number;
b: number;
}
function hexToRgb(hex: string): Rgb {
const normalized = hex.replace('#', '');
const full = normalized.length === 3 ? normalized.replace(/(.)/g, '$1$1') : normalized;
const value = parseInt(full, 16);
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function rgbToHex({ r, g, b }: Rgb): string {
return `#${[r, g, b].map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('')}`;
}
function relativeLuminance({ r, g, b }: Rgb): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const channel = c / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function contrastRatio(hexA: string, hexB: string): number {
const lA = relativeLuminance(hexToRgb(hexA));
const lB = relativeLuminance(hexToRgb(hexB));
const lighter = Math.max(lA, lB);
const darker = Math.min(lA, lB);
return (lighter + 0.05) / (darker + 0.05);
}
const WCAG_AA_MIN_CONTRAST = 4.5;
/**
* Se `hex` já tem contraste AA (≥4.5:1) contra `background`, devolve ele
* mesmo. Senão, escurece progressivamente até passar (ou até chegar em
* preto). Não deixa a cor original para uso como fundo — só gera a
* variante seguro para texto/ícone.
*/
export function ensureAccessibleTextColor(hex: string, background = '#FFFFFF'): string {
if (contrastRatio(hex, background) >= WCAG_AA_MIN_CONTRAST) {
return hex;
}
const base = hexToRgb(hex);
for (let step = 1; step <= 20; step += 1) {
const factor = step / 20;
const darkened = rgbToHex({
r: Math.round(base.r * (1 - factor)),
g: Math.round(base.g * (1 - factor)),
b: Math.round(base.b * (1 - factor)),
});
if (contrastRatio(darkened, background) >= WCAG_AA_MIN_CONTRAST) {
return darkened;
}
}
return '#000000';
}
+32
View File
@@ -0,0 +1,32 @@
import { resolveAssetUrl } from '@/lib/api/asset-url';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export interface TenantTheme {
nome: string;
logoUrl: string | null;
corPrimaria: string | null;
}
interface PublicTenantThemeResponse {
nome: string;
logo_url: string | null;
cor_primaria: string | null;
}
/**
* `GET /public/tenants/:id/tema` (Etapa 8 do backend) — sem autenticação,
* usado tanto antes do login (se o tenant for identificável, ver
* `lib/theme/theme-provider.tsx`) quanto depois (a partir do `tenant_id`
* do usuário autenticado).
*/
export async function fetchTenantTheme(tenantId: string): Promise<TenantTheme | null> {
try {
const response = await fetch(`${API_URL}/public/tenants/${tenantId}/tema`);
if (!response.ok) return null;
const data = (await response.json()) as PublicTenantThemeResponse;
return { nome: data.nome, logoUrl: resolveAssetUrl(data.logo_url), corPrimaria: data.cor_primaria };
} catch {
return null;
}
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { fetchTenantTheme, type TenantTheme } from '@/lib/theme/resolve-theme';
import { ensureAccessibleTextColor } from '@/lib/theme/contrast';
interface TenantThemeContextValue {
theme: TenantTheme | null;
loading: boolean;
/**
* Atualiza o tema em runtime sem depender de um novo fetch (Etapa 18 —
* `/app/perfil`, critério de aceite "reflete instantaneamente sem
* reload"): aplica o patch tanto no estado (consumido por `TenantBrand`
* para nome/logo) quanto nas CSS variables de `:root` quando
* `corPrimaria` muda, mesma técnica de `ensureAccessibleTextColor` já
* usada na resolução inicial do tema.
*/
applyThemeUpdate: (patch: Partial<TenantTheme>) => void;
}
const TenantThemeContext = React.createContext<TenantThemeContextValue>({
theme: null,
loading: false,
applyThemeUpdate: () => {},
});
/**
* Resolve e aplica o tema do tenant (seção 6 do schema) via CSS variables
* em `:root`, com o ajuste de contraste da Etapa 13 embutido.
*
* `tenantId` é passado explicitamente pelo consumidor porque a origem
* muda conforme o contexto:
* - Pré-login (`/login`): só é conhecido se vier de `?tenant=<uuid>` na
* URL — o schema não tem `slug`/subdomínio (ver observações da Etapa
* 13 no controle de progresso), então sem esse parâmetro não há como
* identificar o tenant antes de autenticar, e o tema padrão da
* plataforma é usado (já é o fallback em `styles/tokens.css`).
* - Pós-login (`/app/*`, a partir da Etapa 14): `tenant_id` decodificado
* do access token do usuário autenticado.
*
* Sem `tenantId`, não faz nada — os valores padrão de `tokens.css`
* continuam valendo.
*/
export function TenantThemeProvider({
tenantId,
children,
}: {
tenantId: string | null;
children: React.ReactNode;
}) {
const [theme, setTheme] = React.useState<TenantTheme | null>(null);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!tenantId) {
document.documentElement.style.removeProperty('--color-primary');
document.documentElement.style.removeProperty('--color-primary-on-light');
setTheme(null);
return;
}
let cancelled = false;
setLoading(true);
fetchTenantTheme(tenantId).then((result) => {
if (cancelled) return;
setLoading(false);
setTheme(result);
if (result?.corPrimaria) {
document.documentElement.style.setProperty('--color-primary', result.corPrimaria);
document.documentElement.style.setProperty(
'--color-primary-on-light',
ensureAccessibleTextColor(result.corPrimaria),
);
}
});
return () => {
cancelled = true;
};
}, [tenantId]);
const applyThemeUpdate = React.useCallback((patch: Partial<TenantTheme>) => {
if (patch.corPrimaria) {
document.documentElement.style.setProperty('--color-primary', patch.corPrimaria);
document.documentElement.style.setProperty(
'--color-primary-on-light',
ensureAccessibleTextColor(patch.corPrimaria),
);
}
setTheme((prev) => ({
nome: patch.nome ?? prev?.nome ?? 'SeConectai Vendas',
logoUrl: patch.logoUrl !== undefined ? patch.logoUrl : (prev?.logoUrl ?? null),
corPrimaria: patch.corPrimaria ?? prev?.corPrimaria ?? null,
}));
}, []);
const value = React.useMemo(
() => ({ theme, loading, applyThemeUpdate }),
[theme, loading, applyThemeUpdate],
);
return <TenantThemeContext.Provider value={value}>{children}</TenantThemeContext.Provider>;
}
export function useTenantTheme(): TenantThemeContextValue {
return React.useContext(TenantThemeContext);
}