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:
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user