Show integration health in the admin and keep clients on their own screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
armando 2026-09-16 03:43:18 -03:00
parent 67589e9ec2
commit cf16bf1fb6
89 changed files with 12167 additions and 129 deletions

View File

@ -1,5 +1,5 @@
--- ---
description: Stack e pastas da Sinka — NestJS + Next.js, sem PHP description: Stack e pastas da Sinka: NestJS + Next.js
alwaysApply: true alwaysApply: true
--- ---

View File

@ -6,7 +6,7 @@ alwaysApply: true
# Tenant e segurança # Tenant e segurança
- Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`. - Isolamento: **um database MySQL por empresa** (`sinka_t_<slug>`). Catálogo em `sinka_platform`.
- Queries de operação usam o PrismaClient do tenant autenticado nunca o banco de outra empresa. - Queries de operação usam o PrismaClient do tenant autenticado, nunca o banco de outra empresa.
- Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte. - Auth desta etapa: JWT + refresh em cookie httpOnly, papéis ADMIN / MANAGER / OPERATOR. Google, GitHub e TOTP na seguinte.
- Segredos só em `.env`; nunca commitar - Segredos só em `.env`; nunca commitar
- Rate limit de login com Redis - Rate limit de login com Redis

View File

@ -17,7 +17,11 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
DEMO_OPERATOR_EMAIL=ursula.b@example.com DEMO_OPERATOR_EMAIL=ursula.b@example.com
DEMO_OPERATOR_PASSWORD=SinkaOperador!1 DEMO_OPERATOR_PASSWORD=SinkaOperador!1
# OAuth Google (console: origem http://localhost:3000, callback abaixo) # OAuth Google (console: origens e callback do ambiente)
# Local: origem http://localhost:3000
# callback http://localhost:3000/api/auth/google/callback
# Demo: origem https://armandosoares.tech
# callback https://armandosoares.tech/api/auth/google/callback
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
@ -27,7 +31,7 @@ FIREBASE_PROJECT_ID=
FIREBASE_DATABASE_URL= FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_DATABASE_URL= NEXT_PUBLIC_FIREBASE_DATABASE_URL=
# Arquivos da operação (CSV/XLSX) pasta local por empresa # Arquivos da operação (CSV/XLSX), pasta local por empresa
UPLOAD_DIR= UPLOAD_DIR=
# WEB # WEB

7
.gitignore vendored
View File

@ -14,3 +14,10 @@ prisma/*.db
api/src/generated/ api/src/generated/
uploads/ uploads/
api/uploads/ api/uploads/
*.pdf
msg.txt
*.tsbuildinfo
docker-compose.vps.yml
deploy/
msg.txt

101
README.md
View File

@ -1,71 +1,77 @@
# Sinka # Sinka
Plataforma SaaS multi-tenant de inteligência logística (desafio técnico). SaaS de inteligência logística. Cada operação simula frete, compara transportadoras e acompanha custo, com os dados de uma empresa isolados dos das outras.
**Stack:** NestJS · Next.js · TypeScript · MySQL · Redis · Docker ## O que faz
Não há PHP neste repositório. - Cotação de frete (peso cubado, distância, ad valorem) a partir de CEP (ViaCEP) e coordenadas (Nominatim)
- Ranking de transportadoras da própria empresa
- Clientes, equipe e papéis (Administrador, Manager, Operador)
- Dashboard de custo
- Chat interno (admin da plataforma ↔ empresa)
- Arquivos de operação (CSV/XLSX) por empresa
Dois produtos no mesmo sistema: o **admin da Sinka** (`/admin`) cria e suspende empresas; cada empresa entra pelo slug na URL (`/cliente-1`, `/cliente-2`).
## Isolamento
Um database MySQL por empresa (`sinka_t_<slug>`). O catálogo fica em `sinka_platform`.
O JWT carrega o tenant. As queries de operação usam o PrismaClient daquele banco: um `WHERE` errado não cruza empresa. Backup, restore e exclusão também são por tenant.
## Stack
| Camada | Tecnologia |
| --- | --- |
| API | NestJS, Prisma, JWT em cookie httpOnly |
| Web | Next.js (App Router) |
| Dados | MySQL 8, Redis |
| Infra | Docker Compose |
```
Next.js → NestJS /api → sinka_platform
↓ ↓
Redis sinka_t_<slug>
```
## Estrutura ## Estrutura
``` ```
api/ NestJS (porta 3001) api/ REST, auth, regras, Prisma
web/ Next.js (porta 3000) web/ landing, login, painéis
docs/ Arquitetura e decisões docs/ arquitetura, decisões, testes
``` ```
Leia `RULES.md`, `docs/DECISIONS.md` e `docs/TESTING.md` antes de alterar código. ## Como rodar
Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug>`).
## Subir local
Só MySQL + Redis no Docker; API e web no Node da máquina:
```bash ```bash
docker compose up -d
cp .env.example .env cp .env.example .env
docker compose up -d
cd api && npm install && npm run prisma:setup && npm run start:dev cd api && npm install && npm run prisma:setup && npm run start:dev
cd web && npm run dev cd web && npm install && npm run dev
``` ```
### Tudo no Docker (demo / VPS) MySQL e Redis sobem no Docker; API e web no Node da máquina.
Sobe MySQL, Redis, API e Next.js. Na VPS só precisa de Docker. Porta pública: **3000**. Tudo em containers:
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose --profile full up -d --build docker compose --profile full up -d --build
``` ```
- App: http://localhost:3000 (ou `http://IP-DA-VPS:3000`) - App: http://localhost:3000
- API (só na máquina): http://127.0.0.1:3001/api/health
Se a VPS já usa 3306, no `.env`: `SINKA_MYSQL_PORT=3307`. Com HTTPS na frente, `COOKIE_SECURE=true` e `WEB_ORIGIN=https://seu-dominio`.
Para apagar o projeto da VPS (containers, volumes, imagens locais, uploads):
```bash
docker compose --profile full down -v --rmi local --remove-orphans
rm -rf /opt/sinka
```
- API: http://localhost:3001/api/health - API: http://localhost:3001/api/health
- Web: http://localhost:3000
- Login empresa demo: http://localhost:3000/demo/login
- Sistema demo: http://localhost:3000/demo
- Admin do produto: http://localhost:3000/admin/login
### Contas de demonstração ## Contas
| Área | Caminho | E-mail | Senha | | Área | URL | E-mail | Senha |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Produto | `/admin` | tina.r@example.net | SinkaPlatform!1 | | Plataforma | `/admin/login` | tina.r@example.net | SinkaPlatform!1 |
| Demo | `/demo` | xena.w@example.org | SinkaAdmin!1 | | Cliente 1 (admin) | `/cliente-1/login` | xena.w@example.org | SinkaAdmin!1 |
| Demo | `/demo` | james.b@example.com | SinkaManager!1 | | Cliente 1 (manager) | `/cliente-1/login` | james.b@example.com | SinkaManager!1 |
| Demo | `/demo` | ursula.b@example.com | SinkaOperador!1 | | Cliente 1 (operador) | `/cliente-1/login` | ursula.b@example.com | SinkaOperador!1 |
| Cliente 2 (admin) | `/cliente-2/login` | ivan.p@example.net | Cliente2Admin!1 |
Papéis na empresa: Administrador, Manager, Operador.
## Testes ## Testes
@ -74,8 +80,15 @@ npm test
npm run test:cov npm run test:cov
``` ```
Unidade Jest na API (frete, papéis, tenancy, chat, dashboard). A cobertura olha a regra de negócio, não o fio Nest/Prisma. Detalhes em `docs/TESTING.md`. Jest na API: frete, papéis, tenancy, chat, dashboard. A cobertura mede regra de negócio. Detalhes em [docs/TESTING.md](docs/TESTING.md).
## Etapa atual ## Documentação
Auth (JWT + refresh), área restrita, papéis, multi-tenant (banco por empresa), clientes, transportadoras e simulação de frete (ViaCEP + Nominatim). - [Arquitetura](docs/ARCHITECTURE.md)
- [Decisões](docs/DECISIONS.md)
- [Testes](docs/TESTING.md)
- [Entrega e CI](docs/CI.md)
## Entrega
Push em `main` sobe a demo na hora. Neste desafio isso basta; em produção o caminho é **merge request → pipeline (testes e build) → merge → deploy**. Detalhes em [docs/CI.md](docs/CI.md).

View File

@ -1,4 +1,4 @@
# RULES Sinka # RULES: Sinka
Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP. Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Blade, `public/index.php` nem pasta `app/` no estilo PHP.
@ -6,7 +6,7 @@ Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Bla
- Backend: Node.js + **NestJS** + TypeScript (`api/`) - Backend: Node.js + **NestJS** + TypeScript (`api/`)
- Frontend: **Next.js** + TypeScript (`web/`) - Frontend: **Next.js** + TypeScript (`web/`)
- Banco: **MySQL** `sinka_platform` + um database por empresa - Banco: **MySQL**, `sinka_platform` + um database por empresa
- Infra: **Docker**, **Docker Compose**, **Redis** - Infra: **Docker**, **Docker Compose**, **Redis**
## Pastas ## Pastas
@ -22,7 +22,7 @@ Stack obrigatório do desafio. **Não usar PHP.** Não reintroduzir Laravel, Bla
1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo 1. Modelar no Prisma tenant (`api/prisma/tenant`) se for dado da empresa; no platform se for catálogo
2. Módulo NestJS: controller → use case/service → DTO + validação 2. Módulo NestJS: controller → use case/service → DTO + validação
3. Abrir o PrismaClient do tenant do JWT nunca consultar outro database 3. Abrir o PrismaClient do tenant do JWT, nunca consultar outro database
4. Tela em `web/src/app` (App Router) 4. Tela em `web/src/app` (App Router)
5. Teste no módulo da API quando a regra for importante 5. Teste no módulo da API quando a regra for importante

View File

@ -24,10 +24,70 @@ async function upsertUser(
}); });
} }
async function ensureTenant(
platform: PlatformPrisma,
name: string,
slug: string,
aliases: string[] = [],
) {
let tenant = await platform.tenant.findUnique({ where: { slug } });
if (!tenant) {
for (const alias of aliases) {
const old = await platform.tenant.findUnique({ where: { slug: alias } });
if (old) {
tenant = await platform.tenant.update({
where: { id: old.id },
data: { name, slug },
});
break;
}
}
}
if (!tenant) {
const database = databaseForSlug(slug);
await createMysqlDatabase(database);
await pushTenantSchema(database);
tenant = await platform.tenant.create({ data: { name, slug, database } });
} else {
await pushTenantSchema(tenant.database);
if (tenant.name !== name) {
tenant = await platform.tenant.update({ where: { id: tenant.id }, data: { name } });
}
}
return tenant;
}
async function seedCompany(
tenant: { database: string },
users: { email: string; name: string; password: string; role: 'ADMIN' | 'MANAGER' | 'OPERATOR' }[],
customers: { name: string; document: string; city: string; state: string }[],
carriers: { name: string; baseFee: number; pricePerKg: number; pricePerKm: number; adValoremRate: number }[],
) {
const db = new TenantPrisma({
datasources: { db: { url: urlForDatabase(tenant.database) } },
});
for (const user of users) {
await upsertUser(db, user.email, user.name, user.password, user.role);
}
for (const customer of customers) {
const existing = await db.customer.findFirst({ where: { name: customer.name } });
if (!existing) {
await db.customer.create({ data: customer });
}
}
for (const carrier of carriers) {
const existing = await db.carrier.findFirst({ where: { name: carrier.name } });
if (existing) {
await db.carrier.update({ where: { id: existing.id }, data: carrier });
} else {
await db.carrier.create({ data: carrier });
}
}
await db.$disconnect();
}
async function main() { async function main() {
const platform = new PlatformPrisma(); const platform = new PlatformPrisma();
const slug = 'demo';
const database = databaseForSlug(slug);
const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net'; const platformEmail = process.env.PLATFORM_ADMIN_EMAIL ?? 'tina.r@example.net';
const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1'; const platformPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? 'SinkaPlatform!1';
@ -42,75 +102,68 @@ async function main() {
}, },
}); });
let tenant = await platform.tenant.findUnique({ where: { slug } }); const cliente1 = await ensureTenant(platform, 'Cliente 1', 'cliente-1', ['demo']);
if (!tenant) { const cliente2 = await ensureTenant(platform, 'Cliente 2', 'cliente-2');
await createMysqlDatabase(database);
await pushTenantSchema(database);
tenant = await platform.tenant.create({
data: { name: 'Sinka Demo', slug, database },
});
} else {
await pushTenantSchema(tenant.database);
}
const others = await platform.tenant.findMany({ where: { slug: { not: slug } } }); const others = await platform.tenant.findMany({
where: { slug: { notIn: ['cliente-1', 'cliente-2'] } },
});
for (const item of others) { for (const item of others) {
await pushTenantSchema(item.database); await pushTenantSchema(item.database);
} }
const db = new TenantPrisma({ await seedCompany(
datasources: { db: { url: urlForDatabase(tenant.database) } }, cliente1,
}); [
{
await upsertUser( email: process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org',
db, name: 'Ana Admin',
process.env.DEMO_ADMIN_EMAIL ?? 'xena.w@example.org', password: process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1',
'Ana Admin', role: 'ADMIN',
process.env.DEMO_ADMIN_PASSWORD ?? 'SinkaAdmin!1', },
'ADMIN', {
); email: process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com',
await upsertUser( name: 'Marcos Manager',
db, password: process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1',
process.env.DEMO_MANAGER_EMAIL ?? 'james.b@example.com', role: 'MANAGER',
'Marcos Manager', },
process.env.DEMO_MANAGER_PASSWORD ?? 'SinkaManager!1', {
'MANAGER', email: process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com',
); name: 'Olga Operador',
await upsertUser( password: process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1',
db, role: 'OPERATOR',
process.env.DEMO_OPERATOR_EMAIL ?? 'ursula.b@example.com', },
'Olga Operador', ],
process.env.DEMO_OPERATOR_PASSWORD ?? 'SinkaOperador!1', [
'OPERATOR',
);
for (const customer of [
{ name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' }, { name: 'Mercado Aurora', document: '12.345.678/0001-90', city: 'São Paulo', state: 'SP' },
{ name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' }, { name: 'Café do Litoral', document: '98.765.432/0001-10', city: 'Santos', state: 'SP' },
]) { ],
const existing = await db.customer.findFirst({ where: { name: customer.name } }); [
if (!existing) {
await db.customer.create({ data: customer });
}
}
const carriers = [
{ name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 }, { name: 'Litoral Mix', baseFee: 45, pricePerKg: 1.8, pricePerKm: 0.85, adValoremRate: 0.003 },
{ name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 }, { name: 'Rota Sul', baseFee: 32, pricePerKg: 1.4, pricePerKm: 0.72, adValoremRate: 0.004 },
{ name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 }, { name: 'Express RJ', baseFee: 60, pricePerKg: 2.2, pricePerKm: 1.1, adValoremRate: 0.002 },
]; ],
for (const carrier of carriers) { );
const existing = await db.carrier.findFirst({ where: { name: carrier.name } });
if (existing) { await seedCompany(
await db.carrier.update({ where: { id: existing.id }, data: carrier }); cliente2,
} else { [
await db.carrier.create({ data: carrier }); {
} email: 'ivan.p@example.net',
} name: 'Bruno Admin',
password: 'Cliente2Admin!1',
role: 'ADMIN',
},
],
[
{ name: 'Padaria Centro', document: '11.222.333/0001-44', city: 'Curitiba', state: 'PR' },
{ name: 'Loja Norte', document: '55.666.777/0001-88', city: 'Joinville', state: 'SC' },
],
[{ name: 'Carga Paraná', baseFee: 38, pricePerKg: 1.5, pricePerKm: 0.7, adValoremRate: 0.0035 }],
);
await db.$disconnect();
await platform.$disconnect(); await platform.$disconnect();
console.log('Seed ok: platform + tenant demo (admin, manager, operador).'); console.log('Seed ok: platform + Cliente 1 + Cliente 2.');
} }
main().catch(async (error) => { main().catch(async (error) => {

View File

@ -3,6 +3,7 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -159,7 +160,7 @@ export class AuthService {
private async loginTenant(slug: string, email: string, password: string, ip?: string) { private async loginTenant(slug: string, email: string, password: string, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } }); const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant) { if (!tenant) {
throw new UnauthorizedException('Credenciais inválidas.'); throw new NotFoundException('Este cliente não existe.');
} }
if (tenant.status !== 'ACTIVE') { if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.'); throw new ForbiddenException('Empresa suspensa.');
@ -205,8 +206,11 @@ export class AuthService {
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) { private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } }); const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant || tenant.status !== 'ACTIVE') { if (!tenant) {
throw new UnauthorizedException('Credenciais inválidas.'); throw new NotFoundException('Este cliente não existe.');
}
if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.');
} }
const db = this.tenants.getByDatabase(tenant.database); const db = this.tenants.getByDatabase(tenant.database);
let user = let user =

View File

@ -78,6 +78,7 @@ export class ChatService {
} }
private databaseUrl() { private databaseUrl() {
return this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? ''; const configured = this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
return configured || 'https://sinka-8ec10-default-rtdb.firebaseio.com';
} }
} }

View File

@ -31,7 +31,7 @@ export class FilesService {
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
name: row.originalName, name: row.originalName,
type: fileExtension(row.originalName).toUpperCase() || '', type: fileExtension(row.originalName).toUpperCase() || '-',
sizeBytes: row.sizeBytes, sizeBytes: row.sizeBytes,
createdAt: row.createdAt, createdAt: row.createdAt,
})); }));

View File

@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { IntegrationsService } from './integrations.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class IntegrationsController {
constructor(private readonly integrations: IntegrationsService) {}
@Get('integrations')
list() {
return this.integrations.list();
}
}

View File

@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildIntegrations, type Integration } from './integrations';
@Injectable()
export class IntegrationsService {
constructor(private readonly config: ConfigService) {}
async list(): Promise<{ items: Integration[] }> {
const [viaCep, nominatim, firebase] = await Promise.all([
this.probeViaCep(),
this.probeNominatim(),
this.probeFirebase(),
]);
return {
items: buildIntegrations({
viaCep,
nominatim,
google: this.googleConfigured(),
firebase,
}),
};
}
private googleConfigured() {
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
}
private async probeViaCep() {
return this.ok(async () => {
const response = await fetch('https://viacep.com.br/ws/01001000/json/', {
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const data = (await response.json()) as { erro?: boolean; localidade?: string };
return !data.erro && Boolean(data.localidade);
});
}
private async probeNominatim() {
return this.ok(async () => {
const query = new URLSearchParams({
format: 'json',
limit: '1',
countrycodes: 'br',
q: 'Sao Paulo, SP, Brasil',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const rows = (await response.json()) as unknown[];
return rows.length > 0;
});
}
private async probeFirebase() {
const url = (this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ||
'https://sinka-8ec10-default-rtdb.firebaseio.com');
return this.ok(async () => {
const response = await fetch(`${url}/.json?shallow=true`, {
signal: AbortSignal.timeout(4000),
});
return response.ok;
});
}
private async ok(probe: () => Promise<boolean>) {
try {
return await probe();
} catch {
return false;
}
}
}

View File

@ -0,0 +1,19 @@
import { buildIntegrations } from './integrations';
describe('buildIntegrations', () => {
it('marks each integration from the probe result', () => {
expect(
buildIntegrations({
viaCep: true,
nominatim: false,
google: true,
firebase: false,
}),
).toEqual([
expect.objectContaining({ id: 'viacep', status: 'active' }),
expect.objectContaining({ id: 'nominatim', status: 'inactive' }),
expect.objectContaining({ id: 'google', status: 'active' }),
expect.objectContaining({ id: 'firebase', status: 'inactive' }),
]);
});
});

View File

@ -0,0 +1,42 @@
export type IntegrationStatus = 'active' | 'inactive';
export type Integration = {
id: string;
name: string;
description: string;
status: IntegrationStatus;
};
export function buildIntegrations(probes: {
viaCep: boolean;
nominatim: boolean;
google: boolean;
firebase: boolean;
}): Integration[] {
return [
{
id: 'viacep',
name: 'ViaCEP',
description: 'Cidade e UF a partir do CEP de origem e destino.',
status: probes.viaCep ? 'active' : 'inactive',
},
{
id: 'nominatim',
name: 'OpenStreetMap Nominatim',
description: 'Coordenadas da cidade para calcular a distância do frete.',
status: probes.nominatim ? 'active' : 'inactive',
},
{
id: 'google',
name: 'Google',
description: 'Login com conta Google no admin e nas empresas.',
status: probes.google ? 'active' : 'inactive',
},
{
id: 'firebase',
name: 'Firebase Realtime Database',
description: 'Chat em tempo real entre o admin e o cliente.',
status: probes.firebase ? 'active' : 'inactive',
},
];
}

View File

@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PlatformService } from './platform.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class PlatformOverviewController {
constructor(private readonly platform: PlatformService) {}
@Get('overview')
overview() {
return this.platform.overview();
}
}

View File

@ -0,0 +1,20 @@
import { buildPlatformOverview } from './platform-overview';
describe('buildPlatformOverview', () => {
it('counts clients, active companies and users', () => {
expect(
buildPlatformOverview(
[
{ status: 'ACTIVE' },
{ status: 'ACTIVE' },
{ status: 'SUSPENDED' },
],
8,
),
).toEqual({ clients: 3, active: 2, users: 8 });
});
it('starts empty when the platform has no companies', () => {
expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 });
});
});

View File

@ -0,0 +1,16 @@
export type PlatformOverview = {
clients: number;
active: number;
users: number;
};
export function buildPlatformOverview(
tenants: { status: string }[],
users: number,
): PlatformOverview {
return {
clients: tenants.length,
active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length,
users,
};
}

View File

@ -1,9 +1,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IntegrationsController } from './integrations.controller';
import { IntegrationsService } from './integrations.service';
import { PlatformController } from './platform.controller'; import { PlatformController } from './platform.controller';
import { PlatformOverviewController } from './platform-overview.controller';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { PublicTenantsController } from './public-tenants.controller';
@Module({ @Module({
controllers: [PlatformController], controllers: [
providers: [PlatformService], PlatformController,
PlatformOverviewController,
IntegrationsController,
PublicTenantsController,
],
providers: [PlatformService, IntegrationsService],
}) })
export class PlatformModule {} export class PlatformModule {}

View File

@ -3,9 +3,11 @@ import * as bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database'; import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
import { publicTenantView } from '../tenancy/public-tenant';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url'; import { normalizeSlug } from '../tenancy/tenant-url';
import type { CreateTenantDto } from './dto/create-tenant.dto'; import type { CreateTenantDto } from './dto/create-tenant.dto';
import { buildPlatformOverview } from './platform-overview';
@Injectable() @Injectable()
export class PlatformService { export class PlatformService {
@ -21,6 +23,30 @@ export class PlatformService {
}); });
} }
async overview() {
const tenants = await this.platform.tenant.findMany({
select: { status: true, database: true },
});
const users = (
await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count()))
).reduce((sum, count) => sum + count, 0);
return buildPlatformOverview(tenants, users);
}
async publicBySlug(raw: string) {
let slug: string;
try {
slug = normalizeSlug(raw);
} catch {
throw new NotFoundException('Este cliente não existe.');
}
const tenant = await this.platform.tenant.findUnique({
where: { slug },
select: { name: true, slug: true, status: true },
});
return publicTenantView(tenant);
}
async create(actorId: string, dto: CreateTenantDto, ip?: string) { async create(actorId: string, dto: CreateTenantDto, ip?: string) {
const slug = normalizeSlug(dto.slug); const slug = normalizeSlug(dto.slug);
const exists = await this.platform.tenant.findUnique({ where: { slug } }); const exists = await this.platform.tenant.findUnique({ where: { slug } });

View File

@ -0,0 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { PlatformService } from './platform.service';
@Controller('tenants')
export class PublicTenantsController {
constructor(private readonly platform: PlatformService) {}
@Get(':slug')
bySlug(@Param('slug') slug: string) {
return this.platform.publicBySlug(slug);
}
}

View File

@ -0,0 +1,19 @@
import { NotFoundException } from '@nestjs/common';
import { publicTenantView } from './public-tenant';
describe('publicTenantView', () => {
it('hides internal fields and returns the public company', () => {
expect(
publicTenantView({
name: 'Cliente 1',
slug: 'cliente-1',
status: 'ACTIVE',
}),
).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' });
});
it('tells the visitor when the company is missing', () => {
expect(() => publicTenantView(null)).toThrow(NotFoundException);
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
});
});

View File

@ -0,0 +1,16 @@
import { NotFoundException } from '@nestjs/common';
export type PublicTenant = {
name: string;
slug: string;
status: string;
};
export function publicTenantView(
tenant: { name: string; slug: string; status: string } | null,
): PublicTenant {
if (!tenant) {
throw new NotFoundException('Este cliente não existe.');
}
return { name: tenant.name, slug: tenant.slug, status: tenant.status };
}

View File

@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
const RESERVED = new Set(["platform", "admin", "login", "app", "api", "brand", "film", "topo"]); const RESERVED = new Set(["platform", "admin", "login", "app", "api", "brand", "film", "topo", "privacidade", "termos"]);
export function normalizeSlug(slug: string): string { export function normalizeSlug(slug: string): string {
const value = slug.trim().toLowerCase(); const value = slug.trim().toLowerCase();

View File

@ -55,6 +55,7 @@ services:
MYSQL_ADMIN_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-root}@mysql:3306 MYSQL_ADMIN_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-root}@mysql:3306
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
UPLOAD_DIR: /data/uploads UPLOAD_DIR: /data/uploads
FIREBASE_DATABASE_URL: ${FIREBASE_DATABASE_URL:-https://sinka-8ec10-default-rtdb.firebaseio.com}
volumes: volumes:
- sinka_uploads:/data/uploads - sinka_uploads:/data/uploads
ports: ports:
@ -87,6 +88,7 @@ services:
environment: environment:
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
API_INTERNAL_URL: http://api:3001/api
ports: ports:
- "${SINKA_WEB_PORT:-3000}:3000" - "${SINKA_WEB_PORT:-3000}:3000"
healthcheck: healthcheck:

View File

@ -1,4 +1,4 @@
# Arquitetura Sinka # Arquitetura: Sinka
``` ```
[Next.js :3000] → [NestJS :3001/api] → [MySQL sinka_platform] [Next.js :3000] → [NestJS :3001/api] → [MySQL sinka_platform]
@ -14,4 +14,4 @@
Document root de produto: o frontend Next.js. A API não serve HTML. Document root de produto: o frontend Next.js. A API não serve HTML.
Login de empresa exige o slug **na URL** (`/demo`, `/filial-sul`). O JWT carrega `tenantId` + papel; as queries usam o PrismaClient daquele database. Login de empresa exige o slug **na URL** (`/cliente-1`, `/cliente-2`). O JWT carrega `tenantId` + papel; as queries usam o PrismaClient daquele database.

14
docs/CI.md Normal file
View File

@ -0,0 +1,14 @@
# Entrega
Neste desafio o push em `main` atualiza a demo na hora (webhook no servidor). É um atalho para apresentar o sistema.
O fluxo adequado em time seria outro:
1. Branch a partir de `main`
2. Merge request / pull request
3. Pipeline na MR: lint, `npm test`, build das imagens
4. Review
5. Merge em `main`
6. Só então o deploy
Nada iria para o ambiente público sem revisão e sem os testes passarem. Aqui não há runner de CI na VPS (1 vCPU / 4 GB); por isso o atalho.

View File

@ -4,7 +4,7 @@
Cada empresa recebe o próprio database (`sinka_t_<slug>`). O catálogo de empresas e o administrador da plataforma ficam em `sinka_platform`. Cada empresa recebe o próprio database (`sinka_t_<slug>`). O catálogo de empresas e o administrador da plataforma ficam em `sinka_platform`.
A API resolve o banco no login (slug da empresa) e reutiliza um `PrismaClient` por database. Dados de operação — usuários, clientes, simulações — nunca compartilham tabela com outra empresa. A API resolve o banco no login (slug da empresa) e reutiliza um `PrismaClient` por database. Dados de operação (usuários, clientes, simulações) nunca compartilham tabela com outra empresa.
**Por quê:** o desafio pede isolamento entre empresas. Database-per-tenant é o isolamento mais forte no MySQL: backup, restore e exclusão por empresa; um vazamento de `WHERE` não cruza tenant. O custo extra (provisionar database + `db push` no onboarding) cabe no desafio e deixa a justificativa explícita. **Por quê:** o desafio pede isolamento entre empresas. Database-per-tenant é o isolamento mais forte no MySQL: backup, restore e exclusão por empresa; um vazamento de `WHERE` não cruza tenant. O custo extra (provisionar database + `db push` no onboarding) cabe no desafio e deixa a justificativa explícita.
@ -28,8 +28,8 @@ Fila de importação (próxima etapa), rate limit de login, cache de CEP/geocodi
## Integrações externas ## Integrações externas
1. **ViaCEP** cidade/UF a partir do CEP de origem e destino 1. **ViaCEP**: cidade/UF a partir do CEP de origem e destino
2. **OpenStreetMap Nominatim** coordenadas; distância por haversine 2. **OpenStreetMap Nominatim**: coordenadas; distância por haversine
Se o Nominatim falhar, a simulação usa uma distância de fallback (mesmo município / mesmo estado / interestadual). Se o Nominatim falhar, a simulação usa uma distância de fallback (mesmo município / mesmo estado / interestadual).

View File

@ -2,7 +2,7 @@
Estratégia: **testes de unidade Jest na API**, um `*.spec.ts` ao lado do código. Sem banco, Redis, Firebase ou browser. Estratégia: **testes de unidade Jest na API**, um `*.spec.ts` ao lado do código. Sem banco, Redis, Firebase ou browser.
O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas — é o caminho mais estável e o que um avaliador consegue rodar com `npm test`. O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas. É o caminho mais estável e o que um avaliador consegue rodar com `npm test`.
```bash ```bash
npm test npm test

View File

@ -1,7 +1,7 @@
{ {
"name": "sinka", "name": "sinka",
"private": true, "private": true,
"description": "Plataforma SaaS de inteligência logística NestJS + Next.js", "description": "Plataforma SaaS de inteligência logística: NestJS + Next.js",
"scripts": { "scripts": {
"docker:up": "docker compose up -d", "docker:up": "docker compose up -d",
"docker:down": "docker compose down", "docker:down": "docker compose down",

1
web

@ -1 +0,0 @@
Subproject commit a54be2fbb8f508e864dbf001e25a2c951cf22e58

5
web/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
.env
.env.local
*.log

41
web/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

9
web/AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
web/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

33
web/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG API_INTERNAL_URL=http://api:3001/api
ENV API_INTERNAL_URL=$API_INTERNAL_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1
ARG API_INTERNAL_URL=http://api:3001/api
ENV API_INTERNAL_URL=$API_INTERNAL_URL
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

36
web/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
web/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

25
web/next.config.ts Normal file
View File

@ -0,0 +1,25 @@
const apiOrigin =
process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api";
const nextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${apiOrigin}/:path*`,
},
];
},
async redirects() {
return [
{ source: "/login", destination: "/cliente-1/login", permanent: false },
{ source: "/app", destination: "/cliente-1", permanent: false },
{ source: "/app/:path*", destination: "/cliente-1/:path*", permanent: false },
{ source: "/platform", destination: "/admin", permanent: false },
{ source: "/platform/:path*", destination: "/admin/:path*", permanent: false },
];
},
};
export default nextConfig;

7787
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"firebase": "^12.19.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
web/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

1
web/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

1
web/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
web/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,120 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api, apiForm } from "@/lib/api";
import { Modal, RegistryTable } from "@/components/registry-table";
type OperationFile = {
id: string;
name: string;
type: string;
sizeBytes: number;
createdAt: string;
};
function formatSize(bytes: number) {
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ArquivosPage() {
const [rows, setRows] = useState<OperationFile[]>([]);
const [file, setFile] = useState<File | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
async function load() {
setRows(await api<OperationFile[]>("/api/files"));
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setFile(null);
setError("");
}
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!file) {
setError("Selecione um arquivo.");
return;
}
setError("");
setSaving(true);
try {
const body = new FormData();
body.append("file", file);
await apiForm("/api/files", body);
closeModal();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível anexar.");
} finally {
setSaving(false);
}
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Arquivos</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Anexos da operação</h1>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<RegistryTable
countLabel={`${rows.length} arquivo${rows.length === 1 ? "" : "s"}`}
columns={["Nome", "Tipo", "Tamanho", "Data", ""]}
isEmpty={rows.length === 0}
empty="Nenhum arquivo anexado."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Anexar
</button>
}
>
{rows.map((row) => (
<tr key={row.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{row.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.type}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{formatSize(row.sizeBytes)}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{new Date(row.createdAt).toLocaleString("pt-BR")}</td>
<td className="px-5 py-3 text-right">
<a href={`/api/files/${row.id}/download`} className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]">
Baixar
</a>
</td>
</tr>
))}
</RegistryTable>
{open ? (
<Modal onClose={closeModal}>
<form className="flex flex-col gap-3" onSubmit={onSubmit}>
<h2 className="font-display text-2xl font-bold uppercase">Anexar arquivo</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-[#82C85C] file:px-3 file:py-2 file:text-xs file:font-semibold file:text-black"
type="file"
accept=".csv,.xlsx,.xls,.pdf,.txt"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Enviando…" : "Anexar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
</Modal>
) : null}
</section>
);
}

View File

@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import { SupportChat } from "@/components/support-chat";
export default function TenantChatPage() {
const params = useParams<{ tenant: string }>();
return <SupportChat slug={params.tenant} title="Suporte" />;
}

View File

@ -0,0 +1,173 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api, type SessionUser } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Customer = {
id: string;
name: string;
document: string | null;
email: string | null;
phone: string | null;
city: string | null;
state: string | null;
};
const empty = { name: "", document: "", email: "", phone: "", city: "", state: "" };
export default function ClientesPage() {
const [me, setMe] = useState<SessionUser | null>(null);
const [rows, setRows] = useState<Customer[]>([]);
const [form, setForm] = useState(empty);
const [editing, setEditing] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const canWrite = Boolean(me?.permissions.includes("customers:write"));
async function load() {
const session = await api<SessionUser>("/api/auth/me");
setMe(session);
setRows(await api<Customer[]>("/api/customers"));
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setEditing(null);
setForm(empty);
setError("");
}
function openCreate() {
setEditing(null);
setForm(empty);
setError("");
setOpen(true);
}
function openEdit(row: Customer) {
setEditing(row.id);
setForm({
name: row.name,
document: row.document ?? "",
email: row.email ?? "",
phone: row.phone ?? "",
city: row.city ?? "",
state: row.state ?? "",
});
setError("");
setOpen(true);
}
async function onSubmit(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const payload = {
name: form.name,
document: form.document || undefined,
email: form.email || undefined,
phone: form.phone || undefined,
city: form.city || undefined,
state: form.state || undefined,
};
if (editing) {
await api(`/api/customers/${editing}`, { method: "PATCH", body: JSON.stringify(payload) });
} else {
await api("/api/customers", { method: "POST", body: JSON.stringify(payload) });
}
closeModal();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível salvar.");
} finally {
setSaving(false);
}
}
async function onDelete(id: string) {
if (!confirm("Remover este cliente?")) {
return;
}
setError("");
try {
await api(`/api/customers/${id}`, { method: "DELETE" });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível remover.");
}
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Clientes</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Quem opera com você</h1>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<RegistryTable
countLabel={`${rows.length} cliente${rows.length === 1 ? "" : "s"}`}
columns={["Nome", "Documento", "Cidade", "Contato", ...(canWrite ? ["Ações"] : [])]}
isEmpty={rows.length === 0}
empty="Nenhum cliente cadastrado."
action={
canWrite ? (
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={openCreate}>
Adicionar
</button>
) : undefined
}
>
{rows.map((row) => (
<tr key={row.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{row.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.document || "-"}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{[row.city, row.state].filter(Boolean).join(" / ") || "-"}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.email || row.phone || "-"}</td>
{canWrite ? (
<td className="px-5 py-3">
<button className="text-[#82C85C]" type="button" onClick={() => openEdit(row)}>
Editar
</button>
<button className="ml-3 text-[#E8F4E5]/50" type="button" onClick={() => void onDelete(row.id)}>
Remover
</button>
</td>
) : null}
</tr>
))}
</RegistryTable>
{open ? (
<Modal onClose={closeModal}>
<form className="grid gap-3" onSubmit={onSubmit}>
<h2 className="font-display text-2xl font-bold uppercase">{editing ? "Editar cliente" : "Novo cliente"}</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input className={fieldClass} placeholder="Nome" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} required />
<input className={fieldClass} placeholder="CNPJ" value={form.document} onChange={(event) => setForm({ ...form, document: event.target.value })} />
<input className={fieldClass} placeholder="E-mail" type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
<input className={fieldClass} placeholder="Telefone" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
<div className="grid grid-cols-3 gap-3">
<input className={`col-span-2 ${fieldClass}`} placeholder="Cidade" value={form.city} onChange={(event) => setForm({ ...form, city: event.target.value })} />
<input className={`${fieldClass} uppercase`} placeholder="UF" maxLength={2} value={form.state} onChange={(event) => setForm({ ...form, state: event.target.value })} />
</div>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Salvando…" : editing ? "Salvar" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
</Modal>
) : null}
</section>
);
}

View File

@ -0,0 +1,167 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api, type SessionUser } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Member = {
id: string;
name: string;
email: string;
role: "ADMIN" | "MANAGER" | "OPERATOR";
lastLoginAt: string | null;
};
const roles = [
{ id: "ADMIN", label: "Administrador" },
{ id: "MANAGER", label: "Manager" },
{ id: "OPERATOR", label: "Operador" },
] as const;
export default function EquipePage() {
const [me, setMe] = useState<SessionUser | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"ADMIN" | "MANAGER" | "OPERATOR">("OPERATOR");
const canManage = Boolean(me?.permissions.includes("users:manage"));
async function load() {
const session = await api<SessionUser>("/api/auth/me");
setMe(session);
if (session.role === "OPERATOR") {
return;
}
setMembers(await api<Member[]>("/api/users"));
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setName("");
setEmail("");
setPassword("");
setRole("OPERATOR");
setError("");
}
async function onCreate(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
await api("/api/users", {
method: "POST",
body: JSON.stringify({ name, email, password, role }),
});
closeModal();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar.");
} finally {
setSaving(false);
}
}
async function onRole(id: string, next: Member["role"]) {
setError("");
try {
await api(`/api/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role: next }) });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível alterar o papel.");
}
}
if (!me) {
return null;
}
if (me.role === "OPERATOR") {
return <p className="text-[#E8F4E5]/60">Seu perfil de Operador não gerencia a equipe.</p>;
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Equipe</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Pessoas da operação</h1>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<RegistryTable
countLabel={`${members.length} pessoa${members.length === 1 ? "" : "s"}`}
columns={["Nome", "E-mail", "Papel", "Último acesso"]}
isEmpty={members.length === 0}
empty="Ninguém cadastrado ainda."
action={
canManage ? (
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Adicionar
</button>
) : undefined
}
>
{members.map((member) => (
<tr key={member.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{member.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{member.email}</td>
<td className="px-5 py-3">
{canManage ? (
<select
className="rounded-full border border-white/10 bg-black px-3 py-1.5 text-sm"
value={member.role}
onChange={(event) => void onRole(member.id, event.target.value as Member["role"])}
>
{roles.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
) : (
<span className="text-[#82C85C]">{roles.find((item) => item.id === member.role)?.label}</span>
)}
</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleString("pt-BR") : "Nunca"}
</td>
</tr>
))}
</RegistryTable>
{open ? (
<Modal onClose={closeModal}>
<form className="grid gap-3" onSubmit={onCreate}>
<h2 className="font-display text-2xl font-bold uppercase">Novo usuário</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input className={fieldClass} placeholder="Nome" value={name} onChange={(event) => setName(event.target.value)} required />
<input className={fieldClass} placeholder="E-mail" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
<input className={fieldClass} placeholder="Senha" type="password" value={password} onChange={(event) => setPassword(event.target.value)} required minLength={8} />
<select className="rounded-xl border border-white/10 bg-black px-4 py-3" value={role} onChange={(event) => setRole(event.target.value as typeof role)}>
{roles.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Salvando…" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
</Modal>
) : null}
</section>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type Simulation = {
id: string;
originLabel: string | null;
destinationLabel: string | null;
distanceKm: number | null;
quotedPrice: number | null;
bestCarrierName: string | null;
customer: { name: string } | null;
createdAt: string;
};
const money = (value: number) => value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
export default function HistoricoPage() {
const [rows, setRows] = useState<Simulation[]>([]);
const [error, setError] = useState("");
useEffect(() => {
api<Simulation[]>("/api/freight")
.then(setRows)
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Histórico</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Simulações</h1>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<ul className="mt-8 divide-y divide-white/10 rounded-2xl border border-white/10">
{rows.length === 0 ? (
<li className="px-5 py-8 text-[#E8F4E5]/50">Nenhuma simulação ainda.</li>
) : (
rows.map((row) => (
<li key={row.id} className="px-5 py-4">
<p className="font-medium">
{row.originLabel} {row.destinationLabel}
</p>
<p className="text-sm text-[#E8F4E5]/55">
{row.bestCarrierName} · {row.quotedPrice !== null ? money(row.quotedPrice) : "-"} · {row.distanceKm} km
{row.customer ? ` · ${row.customer.name}` : ""} ·{" "}
{new Date(row.createdAt).toLocaleString("pt-BR")}
</p>
</li>
))
)}
</ul>
</section>
);
}

View File

@ -0,0 +1,31 @@
import { notFound } from "next/navigation";
import { RestrictedShell } from "@/components/restricted-shell";
import { TenantMissing } from "@/components/tenant-missing";
import { fetchPublicTenant, isTenantSlug } from "@/lib/tenants";
import type { ReactNode } from "react";
export const dynamic = "force-dynamic";
export default async function TenantPanelLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ tenant: string }>;
}) {
const { tenant } = await params;
if (!isTenantSlug(tenant)) {
notFound();
}
const company = await fetchPublicTenant(tenant);
if (!company) {
return <TenantMissing slug={tenant} />;
}
return (
<RestrictedShell kind="tenant" basePath={`/${tenant}`}>
{children}
</RestrictedShell>
);
}

View File

@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type Dashboard = {
periodDays: number;
kpis: {
simulations: { value: number; deltaPct: number | null };
averageCost: { value: number | null; deltaPct: number | null };
costPerKm: { value: number | null; deltaPct: number | null };
leftoverSavings: { value: number; sharePct: number | null };
};
insights: string[];
carriers: { name: string; wins: number; sharePct: number; averageWin: number }[];
routes: { label: string; count: number; averageCost: number }[];
customers: number;
activeCarriers: number;
};
const money = (value: number) => value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
function formatDelta(delta: number | null) {
if (delta === null) {
return "sem base anterior";
}
const sign = delta > 0 ? "+" : "";
return `${sign}${delta.toLocaleString("pt-BR")}% vs período anterior`;
}
export default function DashboardPage() {
const [data, setData] = useState<Dashboard | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api<Dashboard>("/api/dashboard")
.then(setData)
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
if (!data && !error) {
return <p className="text-[#E8F4E5]/50">Carregando indicadores</p>;
}
if (error || !data) {
return <p className="text-sm text-red-300">{error || "Não foi possível carregar o painel."}</p>;
}
const cards = [
{
label: "Simulações",
value: String(data.kpis.simulations.value),
hint: formatDelta(data.kpis.simulations.deltaPct),
},
{
label: "Custo médio (melhor opção)",
value: data.kpis.averageCost.value === null ? "-" : money(data.kpis.averageCost.value),
hint: formatDelta(data.kpis.averageCost.deltaPct),
},
{
label: "Custo por km",
value: data.kpis.costPerKm.value === null ? "-" : money(data.kpis.costPerKm.value),
hint: formatDelta(data.kpis.costPerKm.deltaPct),
},
{
label: "Economia em jogo",
value: money(data.kpis.leftoverSavings.value),
hint:
data.kpis.leftoverSavings.sharePct === null
? "diferença pior × melhor cotação"
: `${data.kpis.leftoverSavings.sharePct}% das simulações tinham alternativa pior`,
},
];
return (
<section>
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<li key={card.label} className="rounded-2xl border border-white/10 px-5 py-5">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">{card.label}</p>
<p className="font-display mt-3 text-3xl font-extrabold">{card.value}</p>
<p className="mt-2 text-xs text-[#E8F4E5]/50">{card.hint}</p>
</li>
))}
</ul>
<div className="mt-8 rounded-2xl border border-white/10 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#82C85C]">Leitura</p>
<ol className="mt-4 grid gap-3">
{data.insights.map((insight) => (
<li key={insight} className="text-sm text-[#E8F4E5]/80">
{insight}
</li>
))}
</ol>
</div>
<div className="mt-8 grid gap-6 xl:grid-cols-2">
<div className="overflow-hidden rounded-2xl border border-white/10">
<div className="px-5 py-4">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">Quem vence a cotação</p>
<h2 className="font-display mt-1 text-xl font-bold uppercase">Transportadoras</h2>
</div>
<table className="w-full text-left text-sm">
<thead className="border-t border-white/10 bg-white/5 text-xs uppercase tracking-wider text-[#E8F4E5]/45">
<tr>
<th className="px-5 py-3 font-medium">Nome</th>
<th className="px-5 py-3 font-medium">Vitórias</th>
<th className="px-5 py-3 font-medium">Média</th>
</tr>
</thead>
<tbody>
{data.carriers.length === 0 ? (
<tr className="border-t border-white/10">
<td className="px-5 py-6 text-[#E8F4E5]/45" colSpan={3}>
Sem cotações no período.
</td>
</tr>
) : (
data.carriers.map((carrier) => (
<tr key={carrier.name} className="border-t border-white/10">
<td className="px-5 py-3">{carrier.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">
{carrier.wins} · {carrier.sharePct}%
</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{money(carrier.averageWin)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="overflow-hidden rounded-2xl border border-white/10">
<div className="px-5 py-4">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">Onde o frete pesa</p>
<h2 className="font-display mt-1 text-xl font-bold uppercase">Rotas mais caras</h2>
</div>
<table className="w-full text-left text-sm">
<thead className="border-t border-white/10 bg-white/5 text-xs uppercase tracking-wider text-[#E8F4E5]/45">
<tr>
<th className="px-5 py-3 font-medium">Rota</th>
<th className="px-5 py-3 font-medium">Vezes</th>
<th className="px-5 py-3 font-medium">Média</th>
</tr>
</thead>
<tbody>
{data.routes.length === 0 ? (
<tr className="border-t border-white/10">
<td className="px-5 py-6 text-[#E8F4E5]/45" colSpan={3}>
Sem rotas no período.
</td>
</tr>
) : (
data.routes.map((route) => (
<tr key={route.label} className="border-t border-white/10">
<td className="px-5 py-3">{route.label}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{route.count}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{money(route.averageCost)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api } from "@/lib/api";
type Customer = { id: string; name: string };
type Quote = { carrierId: string; carrierName: string; price: number; chargeableKg: number };
type Simulation = {
id: string;
originLabel: string | null;
destinationLabel: string | null;
originZip: string;
destinationZip: string;
distanceKm: number | null;
quotedPrice: number | null;
bestCarrierName: string | null;
quotes: Quote[];
chargeableKg: number | null;
customer: { name: string } | null;
distanceSource?: "nominatim" | "fallback";
};
const money = (value: number) => value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
const fieldClass = "rounded-xl border border-white/10 bg-white/5 px-4 py-3";
export default function SimularPage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [result, setResult] = useState<Simulation | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
customerId: "",
originZip: "01310-100",
destinationZip: "20040-020",
weightKg: "12",
widthCm: "40",
heightCm: "30",
lengthCm: "50",
cargoValue: "1500",
});
useEffect(() => {
api<Customer[]>("/api/customers")
.then(setCustomers)
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setError("");
setLoading(true);
setResult(null);
try {
const [simulation] = await Promise.all([
api<Simulation>("/api/freight/simulate", {
method: "POST",
body: JSON.stringify({
originZip: form.originZip,
destinationZip: form.destinationZip,
weightKg: Number(form.weightKg),
widthCm: Number(form.widthCm),
heightCm: Number(form.heightCm),
lengthCm: Number(form.lengthCm),
cargoValue: Number(form.cargoValue),
customerId: form.customerId || undefined,
}),
}),
new Promise((resolve) => window.setTimeout(resolve, 2200)),
]);
setResult(simulation);
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível simular.");
} finally {
setLoading(false);
}
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Simulação</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Calcular frete</h1>
<p className="mt-4 max-w-xl text-[#E8F4E5]/65">
Informe origem, destino e a carga. A Sinka compara as transportadoras e mostra o melhor custo.
</p>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<div className={`mt-8 grid items-start gap-6 ${loading ? "lg:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]" : "max-w-xl"}`}>
<form className="grid gap-3" onSubmit={onSubmit}>
<select
className="rounded-xl border border-white/10 bg-black px-4 py-3"
value={form.customerId}
onChange={(event) => setForm({ ...form, customerId: event.target.value })}
>
<option value="">Cliente (opcional)</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<div className="grid grid-cols-2 gap-3">
<input className={fieldClass} placeholder="CEP origem" value={form.originZip} onChange={(event) => setForm({ ...form, originZip: event.target.value })} required />
<input className={fieldClass} placeholder="CEP destino" value={form.destinationZip} onChange={(event) => setForm({ ...form, destinationZip: event.target.value })} required />
</div>
<div className="grid grid-cols-2 gap-3">
<input className={fieldClass} placeholder="Peso kg" value={form.weightKg} onChange={(event) => setForm({ ...form, weightKg: event.target.value })} required />
<input className={fieldClass} placeholder="Valor da carga" value={form.cargoValue} onChange={(event) => setForm({ ...form, cargoValue: event.target.value })} required />
</div>
<div className="grid grid-cols-3 gap-3">
<input className={fieldClass} placeholder="Largura cm" value={form.widthCm} onChange={(event) => setForm({ ...form, widthCm: event.target.value })} required />
<input className={fieldClass} placeholder="Altura cm" value={form.heightCm} onChange={(event) => setForm({ ...form, heightCm: event.target.value })} required />
<input className={fieldClass} placeholder="Comprimento cm" value={form.lengthCm} onChange={(event) => setForm({ ...form, lengthCm: event.target.value })} required />
</div>
<button className="btn-sinka w-fit" disabled={loading} type="submit">
{loading ? "Calculando…" : "Simular"}
</button>
</form>
{loading ? (
<aside className="flex min-h-[18rem] flex-col items-center justify-center px-6 py-8">
<img src="/brand/freight-truck.webp" alt="" className="h-44 w-auto max-w-full object-contain" />
<p className="mt-6 text-sm text-[#E8F4E5]/70">Calculando a melhor rota</p>
</aside>
) : null}
</div>
{result ? (
<div className="mt-10">
<p className="text-sm text-[#E8F4E5]/60">
{result.originLabel} {result.destinationLabel} · {result.distanceKm} km
{result.distanceSource === "fallback" ? " (distância estimada)" : ""}
</p>
<h2 className="font-display mt-2 text-2xl font-bold uppercase">
Melhor opção: {result.bestCarrierName} · {result.quotedPrice !== null ? money(result.quotedPrice) : "-"}
</h2>
<ul className="mt-6 divide-y divide-white/10 rounded-2xl border border-white/10">
{result.quotes.map((quote, index) => (
<li key={quote.carrierId} className="flex items-center justify-between px-5 py-4">
<span>
{index === 0 ? "Melhor custo · " : ""}
{quote.carrierName}
</span>
<strong>{money(quote.price)}</strong>
</li>
))}
</ul>
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,194 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api, type SessionUser } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Carrier = {
id: string;
name: string;
document: string | null;
phone: string | null;
baseFee: number;
pricePerKg: number;
pricePerKm: number;
adValoremRate: number;
active: boolean;
};
const empty = {
name: "",
document: "",
phone: "",
baseFee: "40",
pricePerKg: "1.5",
pricePerKm: "0.8",
adValoremRate: "0.003",
active: true,
};
const money = (value: number) => value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
export default function TransportadorasPage() {
const [me, setMe] = useState<SessionUser | null>(null);
const [rows, setRows] = useState<Carrier[]>([]);
const [form, setForm] = useState(empty);
const [editing, setEditing] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
async function load() {
const session = await api<SessionUser>("/api/auth/me");
setMe(session);
setRows(await api<Carrier[]>("/api/carriers"));
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setEditing(null);
setForm(empty);
setError("");
}
function openCreate() {
setEditing(null);
setForm(empty);
setError("");
setOpen(true);
}
function openEdit(row: Carrier) {
setEditing(row.id);
setForm({
name: row.name,
document: row.document ?? "",
phone: row.phone ?? "",
baseFee: String(row.baseFee),
pricePerKg: String(row.pricePerKg),
pricePerKm: String(row.pricePerKm),
adValoremRate: String(row.adValoremRate),
active: row.active,
});
setError("");
setOpen(true);
}
async function onSubmit(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const payload = {
name: form.name,
document: form.document || undefined,
phone: form.phone || undefined,
baseFee: Number(form.baseFee),
pricePerKg: Number(form.pricePerKg),
pricePerKm: Number(form.pricePerKm),
adValoremRate: Number(form.adValoremRate),
active: form.active,
};
if (editing) {
await api(`/api/carriers/${editing}`, { method: "PATCH", body: JSON.stringify(payload) });
} else {
await api("/api/carriers", { method: "POST", body: JSON.stringify(payload) });
}
closeModal();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível salvar.");
} finally {
setSaving(false);
}
}
async function onDelete(id: string) {
if (!confirm("Remover esta transportadora?")) {
return;
}
try {
await api(`/api/carriers/${id}`, { method: "DELETE" });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível remover.");
}
}
if (me && !me.permissions.includes("carriers:write")) {
return <p className="text-[#E8F4E5]/60">Seu perfil não gerencia transportadoras.</p>;
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Transportadoras</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Tabelas de frete</h1>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<RegistryTable
countLabel={`${rows.length} transportadora${rows.length === 1 ? "" : "s"}`}
columns={["Nome", "Situação", "Base", "R$/kg", "R$/km", "Ad valorem", "Ações"]}
isEmpty={rows.length === 0}
empty="Nenhuma transportadora cadastrada."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={openCreate}>
Adicionar
</button>
}
>
{rows.map((row) => (
<tr key={row.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{row.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{row.active ? "Ativa" : "Inativa"}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{money(Number(row.baseFee))}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{money(Number(row.pricePerKg))}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{money(Number(row.pricePerKm))}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{(Number(row.adValoremRate) * 100).toFixed(2)}%</td>
<td className="px-5 py-3">
<button className="text-[#82C85C]" type="button" onClick={() => openEdit(row)}>
Editar
</button>
<button className="ml-3 text-[#E8F4E5]/50" type="button" onClick={() => void onDelete(row.id)}>
Remover
</button>
</td>
</tr>
))}
</RegistryTable>
{open ? (
<Modal onClose={closeModal}>
<form className="grid gap-3" onSubmit={onSubmit}>
<h2 className="font-display text-2xl font-bold uppercase">{editing ? "Editar" : "Nova transportadora"}</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input className={fieldClass} placeholder="Nome" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} required />
<input className={fieldClass} placeholder="CNPJ" value={form.document} onChange={(event) => setForm({ ...form, document: event.target.value })} />
<input className={fieldClass} placeholder="Telefone" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
<div className="grid grid-cols-2 gap-3">
<input className={fieldClass} placeholder="Taxa base" value={form.baseFee} onChange={(event) => setForm({ ...form, baseFee: event.target.value })} />
<input className={fieldClass} placeholder="R$/kg" value={form.pricePerKg} onChange={(event) => setForm({ ...form, pricePerKg: event.target.value })} />
<input className={fieldClass} placeholder="R$/km" value={form.pricePerKm} onChange={(event) => setForm({ ...form, pricePerKm: event.target.value })} />
<input className={fieldClass} placeholder="Ad valorem (0.003)" value={form.adValoremRate} onChange={(event) => setForm({ ...form, adValoremRate: event.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.active} onChange={(event) => setForm({ ...form, active: event.target.checked })} />
Ativa nas simulações
</label>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Salvando…" : editing ? "Salvar" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
</Modal>
) : null}
</section>
);
}

View File

@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { LoginForm } from "@/components/login-form";
import { TenantMissing } from "@/components/tenant-missing";
import { fetchPublicTenant, isTenantSlug } from "@/lib/tenants";
export const dynamic = "force-dynamic";
export default async function TenantLoginPage({ params }: { params: Promise<{ tenant: string }> }) {
const { tenant } = await params;
if (!isTenantSlug(tenant)) {
notFound();
}
const company = await fetchPublicTenant(tenant);
if (!company) {
return <TenantMissing slug={tenant} />;
}
return (
<Suspense fallback={<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">Carregando</main>}>
<LoginForm variant="tenant" tenantSlug={tenant} />
</Suspense>
);
}

View File

@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import { SupportChat } from "@/components/support-chat";
export default function AdminChatPage() {
const params = useParams<{ slug: string }>();
return <SupportChat slug={params.slug} title="Chat" />;
}

View File

@ -0,0 +1,300 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Tenant = {
id: string;
name: string;
slug: string;
status: string;
createdAt: string;
};
type Created = Tenant & {
access: { email: string; password: string; path: string };
};
type TenantUser = {
id: string;
name: string;
email: string;
role: "ADMIN" | "MANAGER" | "OPERATOR";
password: string | null;
};
const roleLabel = {
ADMIN: "Administrador",
MANAGER: "Manager",
OPERATOR: "Operador",
} as const;
function slugFromName(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
}
export default function PlatformClientesPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [created, setCreated] = useState<Created | null>(null);
const [saving, setSaving] = useState(false);
const [usersOf, setUsersOf] = useState<Tenant | null>(null);
const [users, setUsers] = useState<TenantUser[]>([]);
const [usersError, setUsersError] = useState("");
const [issuing, setIssuing] = useState<string | null>(null);
async function load() {
const [list, counts] = await Promise.all([
api<Tenant[]>("/api/platform/tenants"),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
]);
setTenants(list);
setChatCounts(counts);
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setCreated(null);
setName("");
setSlug("");
setError("");
}
async function openUsers(tenant: Tenant) {
setUsersError("");
setUsersOf(tenant);
setUsers([]);
try {
const result = await api<{ users: TenantUser[] }>(`/api/platform/tenants/${tenant.id}/users`);
setUsers(result.users);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível listar os usuários.");
}
}
function closeUsers() {
setUsersOf(null);
setUsers([]);
setUsersError("");
setIssuing(null);
}
async function issuePassword(userId: string) {
if (!usersOf) {
return;
}
setUsersError("");
setIssuing(userId);
try {
const result = await api<{ id: string; password: string }>(
`/api/platform/tenants/${usersOf.id}/users/${userId}/password`,
{ method: "POST" },
);
setUsers((current) =>
current.map((user) => (user.id === userId ? { ...user, password: result.password } : user)),
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível gerar a senha.");
} finally {
setIssuing(null);
}
}
async function onCreate(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const result = await api<Created>("/api/platform/tenants", {
method: "POST",
body: JSON.stringify({ name, slug }),
});
setCreated(result);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o cliente.");
} finally {
setSaving(false);
}
}
return (
<section className="text-center">
{error && !open ? <p className="mb-4 text-sm text-red-300">{error}</p> : null}
<div className="mx-auto w-full max-w-5xl">
<RegistryTable
align="center"
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
isEmpty={tenants.length === 0}
empty="Nenhum cliente ainda."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Adicionar
</button>
}
>
{tenants.map((tenant) => (
<tr key={tenant.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{tenant.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">/{tenant.slug}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap items-center justify-center gap-2">
<button
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
onClick={() => void openUsers(tenant)}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="relative btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
>
Chat
{chatCounts[tenant.slug] ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[#82C85C] px-1 text-[10px] font-bold leading-4 text-black">
{chatCounts[tenant.slug] > 99 ? "99+" : chatCounts[tenant.slug]}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="btn-sinka cursor-pointer px-3 py-1.5 text-[11px]"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</div>
</td>
</tr>
))}
</RegistryTable>
</div>
{open ? (
<Modal onClose={closeModal}>
{created ? (
<div>
<h2 className="font-display text-2xl font-bold uppercase">Cliente criado</h2>
<p className="mt-3 text-sm text-[#E8F4E5]/65">Guarde o acesso do administrador:</p>
<p className="mt-4 text-sm">
Endereço:{" "}
<a className="text-[#82C85C]" href={created.access.path} target="_blank" rel="noopener noreferrer">
{created.access.path}
</a>
</p>
<p className="mt-2 text-sm">E-mail: {created.access.email}</p>
<p className="mt-2 text-sm">Senha: {created.access.password}</p>
<button className="btn-sinka mt-6" type="button" onClick={closeModal}>
Fechar
</button>
</div>
) : (
<form className="grid gap-3" onSubmit={onCreate}>
<h2 className="font-display text-2xl font-bold uppercase">Novo cliente</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input
className={fieldClass}
placeholder="Nome"
value={name}
onChange={(event) => {
setName(event.target.value);
setSlug(slugFromName(event.target.value));
}}
required
/>
<input
className={fieldClass}
placeholder="Endereço (ex: cliente-1)"
value={slug}
onChange={(event) => setSlug(event.target.value.toLowerCase())}
required
/>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Criando…" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
)}
</Modal>
) : null}
{usersOf ? (
<Modal wide onClose={closeUsers}>
<h2 className="font-display text-2xl font-bold uppercase">Usuários · {usersOf.name}</h2>
<p className="mt-2 text-sm text-[#E8F4E5]/60">
Contas de {usersOf.name}. Se a senha estiver vazia, gere uma nova.
</p>
{usersError ? <p className="mt-3 text-sm text-red-300">{usersError}</p> : null}
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[36rem] text-center text-sm">
<thead className="text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr>
<th className="pb-3 font-medium">Nome</th>
<th className="pb-3 font-medium">Usuário</th>
<th className="pb-3 font-medium">Senha</th>
<th className="pb-3 font-medium">Papel</th>
<th className="pb-3 font-medium" />
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="py-6 text-[#E8F4E5]/50" colSpan={5}>
Nenhum usuário neste cliente.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-t border-white/10">
<td className="py-3 pr-4 font-medium">{user.name}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/80">{user.email}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || "-"}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/70">{roleLabel[user.role]}</td>
<td className="py-3">
<button
className="text-xs text-[#E8F4E5]/70"
type="button"
disabled={issuing === user.id}
onClick={() => void issuePassword(user.id)}
>
{issuing === user.id ? "Gerando…" : "Gerar nova senha"}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<button className="btn-sinka mt-6" type="button" onClick={closeUsers}>
Fechar
</button>
</Modal>
) : null}
</section>
);
}

View File

@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type Integration = {
id: string;
name: string;
description: string;
status: "active" | "inactive";
};
export default function PlatformIntegracoesPage() {
const [items, setItems] = useState<Integration[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
api<{ items: Integration[] }>("/api/platform/integrations")
.then((result) => setItems(result.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"))
.finally(() => setLoading(false));
}, []);
return (
<section>
<h1 className="font-display text-4xl font-extrabold uppercase">Integrações</h1>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
{loading ? <p className="mt-4 text-sm text-[#E8F4E5]/50">Verificando integrações</p> : null}
<ul className="mt-8 grid gap-3 md:grid-cols-2">
{items.map((item) => {
const active = item.status === "active";
return (
<li key={item.id} className="rounded-2xl border border-white/10 px-5 py-5">
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold">{item.name}</h2>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider ${
active ? "bg-[#82C85C]/15 text-[#82C85C]" : "bg-red-400/15 text-red-300"
}`}
>
{active ? "Ativo" : "Inativo"}
</span>
</div>
<p className="mt-2 text-sm text-[#E8F4E5]/60">{item.description}</p>
</li>
);
})}
</ul>
</section>
);
}

View File

@ -0,0 +1,6 @@
import { RestrictedShell } from "@/components/restricted-shell";
import type { ReactNode } from "react";
export default function AdminPanelLayout({ children }: { children: ReactNode }) {
return <RestrictedShell kind="platform" basePath="/admin">{children}</RestrictedShell>;
}

View File

@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type Overview = {
clients: number;
active: number;
users: number;
};
export default function PlatformPage() {
const [overview, setOverview] = useState<Overview | null>(null);
const [messages, setMessages] = useState(0);
const [error, setError] = useState("");
useEffect(() => {
Promise.all([
api<Overview>("/api/platform/overview").catch(() => null),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
])
.then(([stats, counts]) => {
setOverview(stats);
setMessages(Object.values(counts).reduce((sum, count) => sum + count, 0));
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
return (
<section>
{error ? <p className="mb-4 text-sm text-red-300">{error}</p> : null}
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[
{
label: "Clientes",
value: String(overview?.clients ?? "-"),
hint: "empresas na plataforma",
},
{
label: "Ativos",
value: String(overview?.active ?? "-"),
hint: "prontos para operar",
},
{
label: "Usuários",
value: String(overview?.users ?? "-"),
hint: "contas em todos os clientes",
},
{
label: "Mensagens",
value: String(messages),
hint: "no chat de suporte",
},
].map((card) => (
<li key={card.label} className="rounded-2xl border border-white/10 px-5 py-5">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">{card.label}</p>
<p className="font-display mt-3 text-3xl font-extrabold">{card.value}</p>
<p className="mt-2 text-xs text-[#E8F4E5]/50">{card.hint}</p>
</li>
))}
</ul>
</section>
);
}

View File

@ -0,0 +1,10 @@
import { Suspense } from "react";
import { LoginForm } from "@/components/login-form";
export default function AdminLoginPage() {
return (
<Suspense fallback={<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">Carregando</main>}>
<LoginForm variant="admin" />
</Suspense>
);
}

BIN
web/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

580
web/src/app/globals.css Normal file
View File

@ -0,0 +1,580 @@
@import "tailwindcss";
:root {
--sinka: #82c85c;
--sinka-light: #a8d98c;
--snow: #e8f4e5;
--night: #000000;
--background: #000000;
--foreground: #e8f4e5;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sinka: var(--sinka);
--color-sinka-light: var(--sinka-light);
--color-snow: var(--snow);
--color-night: var(--night);
--font-sans: var(--font-sans-face);
--font-display: var(--font-display-face);
}
html {
background: #000;
scroll-behavior: auto;
}
body {
margin: 0;
background: #000;
color: #e8f4e5;
font-family: var(--font-sans-face), system-ui, sans-serif;
}
h1,
h2,
h3,
.font-display {
font-family: var(--font-display-face), system-ui, sans-serif;
}
.orbit {
position: relative;
background: #000;
}
.orbit-stage {
position: sticky;
top: 0;
z-index: 0;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #000;
}
.orbit-poster,
.orbit-film {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
background: #000;
}
.orbit-film {
z-index: 1;
opacity: 0;
}
.orbit-film.is-ready {
opacity: 1;
}
.orbit-veil {
position: absolute;
z-index: 2;
inset: 0;
pointer-events: none;
background: linear-gradient(to top, rgba(0, 0, 0, 0.35) 0%, transparent 40%);
}
.orbit-loader {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #000;
}
.orbit-loader-logo {
width: min(11.5rem, 58vw);
height: auto;
animation: loader-glow 1.6s ease-in-out infinite;
}
@keyframes loader-glow {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.orbit-story {
position: relative;
z-index: 3;
margin-top: -100vh;
}
.hero-copy {
display: flex;
min-height: 100vh;
min-height: 100dvh;
flex-direction: column;
justify-content: flex-end;
padding: 0 1.5rem 5rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, transparent 42%);
opacity: calc(1 - min(1, var(--scrub, 0) * 2.6));
}
.orbit-spacer {
height: 50vh;
}
@media (min-width: 768px) {
.hero-copy {
padding: 0 4rem 6rem;
}
}
@media (max-width: 767px) {
.orbit-spacer {
height: 50vh;
}
}
.btn-sinka,
.btn-ghost {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.8rem 1.25rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.btn-sinka {
background: #82c85c;
color: #000;
}
.btn-ghost {
border: 1px solid rgba(130, 200, 92, 0.45);
color: #e8f4e5;
}
.btn-google {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
min-height: 42px;
padding: 0 16px;
border: 1px solid #747775;
border-radius: 4px;
background: #fff;
color: #1f1f1f;
font-family: "Roboto", Arial, sans-serif;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.25px;
text-transform: none;
text-decoration: none;
cursor: pointer;
}
.btn-google:hover {
background: #f8f9fa;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.chapter {
max-width: 72rem;
margin: 0 auto;
padding: 7rem 1.5rem;
}
.reveal {
opacity: 0;
transform: translateY(64px);
transition:
opacity 0.85s ease,
transform 0.85s cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.is-in {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
.chapter h2 {
max-width: 16ch;
margin: 0;
font-size: clamp(1.85rem, 4.2vw, 3.15rem);
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
}
.kicker {
margin: 0 0 1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.28em;
text-transform: uppercase;
color: #82c85c;
}
.lede {
max-width: 34rem;
margin: 1.25rem 0 0;
font-size: 1.05rem;
line-height: 1.55;
color: rgba(232, 244, 229, 0.62);
}
.pain-list {
display: grid;
gap: 1.5rem;
margin: 2.5rem 0 0;
padding: 0;
list-style: none;
}
@media (min-width: 768px) {
.pain-list {
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
}
.pain-list li {
border-top: 1px solid rgba(130, 200, 92, 0.35);
padding-top: 1.1rem;
}
.pain-list strong {
display: block;
font-size: 1.05rem;
}
.pain-list span {
display: block;
margin-top: 0.45rem;
color: rgba(232, 244, 229, 0.62);
line-height: 1.45;
}
.dash {
margin-top: 2.5rem;
border: 1px solid rgba(130, 200, 92, 0.2);
border-radius: 1.5rem;
padding: 1.25rem;
background: #070707;
}
.dash-top {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: rgba(232, 244, 229, 0.5);
}
.dash-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1.25rem;
}
@media (min-width: 768px) {
.dash-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dash-card {
border: 1px solid rgba(130, 200, 92, 0.12);
border-radius: 1rem;
padding: 0.9rem 1rem;
background: #0c0c0c;
opacity: 0;
transform: translateY(10px);
transition:
transform 0.35s ease,
border-color 0.25s ease,
background 0.25s ease,
box-shadow 0.25s ease,
opacity 0.5s ease;
}
.dash[data-ready="true"] .dash-card {
opacity: 1;
transform: translateY(0);
}
.dash-card:hover,
.dash-card:focus-within {
transform: translateY(-4px);
border-color: rgba(130, 200, 92, 0.55);
background: #101410;
box-shadow: 0 12px 40px rgba(130, 200, 92, 0.12);
}
.dash-card p {
margin: 0;
font-size: 0.75rem;
color: rgba(232, 244, 229, 0.5);
}
.dash-card strong {
display: block;
margin-top: 0.4rem;
font-size: 1.7rem;
}
.dash-hint {
display: block;
margin-top: 0.45rem;
font-size: 0.72rem;
color: #82c85c;
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.dash-card:hover .dash-hint,
.dash-card:focus-within .dash-hint {
opacity: 1;
transform: translateY(0);
}
.bars {
display: flex;
align-items: flex-end;
gap: 0.45rem;
height: 7.5rem;
margin-top: 1.5rem;
}
.bar {
position: relative;
flex: 1;
height: 8%;
padding: 0;
border: 0;
border-radius: 6px 6px 0 0;
background: linear-gradient(to top, #82c85c, #a8d98c);
cursor: pointer;
transform-origin: bottom;
transition:
height 0.7s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.2s ease,
transform 0.2s ease;
}
.bar:hover,
.bar:focus-visible {
filter: brightness(1.15);
transform: scaleX(1.06);
outline: none;
}
.bar-tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
z-index: 2;
min-width: 4.5rem;
padding: 0.4rem 0.5rem;
border: 1px solid rgba(130, 200, 92, 0.35);
border-radius: 0.6rem;
background: #000;
color: #e8f4e5;
font-size: 0.68rem;
letter-spacing: 0.04em;
text-align: center;
transform: translateX(-50%);
pointer-events: none;
}
.bar-tip b {
display: block;
margin-top: 0.15rem;
color: #82c85c;
}
.insight-line {
margin: 1.5rem 0 0;
color: #a8d98c;
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.6s ease 0.8s,
transform 0.6s ease 0.8s;
}
.insight-line[data-ready="true"] {
opacity: 1;
transform: translateY(0);
}
.route-line,
.meta-line {
margin: 1.5rem 0 0;
color: #a8d98c;
}
.route-line {
font-size: 1.15rem;
font-weight: 600;
}
.quote-row,
.tenants {
display: grid;
gap: 1rem;
margin-top: 2rem;
}
@media (min-width: 768px) {
.quote-row {
grid-template-columns: repeat(3, 1fr);
}
}
.quote-row article {
border-top: 1px solid rgba(130, 200, 92, 0.35);
padding-top: 1rem;
}
.quote-row p,
.carrier-table th {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #82c85c;
}
.quote-row strong,
.quote-row span {
display: block;
margin-top: 0.4rem;
}
.insight-list,
.live-feed,
.flow {
margin: 2rem 0 0;
padding: 0;
list-style: none;
}
.insight-list li,
.live-feed li {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 1rem 0;
font-size: 1.05rem;
}
.flow {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1.2rem;
font-size: 1.05rem;
color: #a8d98c;
}
.flow li + li::before {
content: "→ ";
color: #82c85c;
}
.carrier-table {
width: 100%;
margin-top: 2rem;
border-collapse: collapse;
font-size: 0.95rem;
}
.carrier-table th,
.carrier-table td {
padding: 0.85rem 0;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tenants {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.tenants {
grid-template-columns: repeat(4, 1fr);
}
}
.tenants span,
.tenants strong {
border: 1px solid rgba(130, 200, 92, 0.25);
border-radius: 1rem;
padding: 1.2rem;
text-align: center;
}
.tenants strong {
background: #82c85c;
color: #000;
}
.metrics {
display: grid;
gap: 2rem;
}
@media (min-width: 768px) {
.metrics {
grid-template-columns: repeat(3, 1fr);
}
}
.metrics strong {
font-size: clamp(2.2rem, 5.5vw, 3.6rem);
line-height: 0.95;
}
.metrics p {
margin: 0.4rem 0 0;
color: rgba(232, 244, 229, 0.5);
}
.cta {
padding-bottom: 8rem;
}
.cta-copy h2 {
max-width: 18ch;
}
@media (max-width: 767px) {
.hero-copy h1 {
font-size: 2.05rem;
}
.carrier-table {
font-size: 0.8rem;
}
}

33
web/src/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Outfit, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
const sans = Plus_Jakarta_Sans({
variable: "--font-sans-face",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const display = Outfit({
variable: "--font-display-face",
subsets: ["latin"],
weight: ["500", "600", "700", "800"],
});
export const metadata: Metadata = {
title: "Sinka: Inteligência em Logística",
description:
"Plataforma SaaS de inteligência logística: simulação de fretes, gestão de clientes e transportadoras, dashboard e insights para a operação.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" className={`${sans.variable} ${display.variable} h-full`}>
<body className="min-h-full antialiased">{children}</body>
</html>
);
}

5
web/src/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { SinkaLanding } from "@/components/landing/sinka-landing";
export default function Home() {
return <SinkaLanding />;
}

View File

@ -0,0 +1,31 @@
export default function PrivacidadePage() {
return (
<main className="min-h-dvh bg-black px-5 py-16 text-[#E8F4E5]">
<div className="mx-auto max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Sinka</p>
<h1 className="font-display mt-3 text-4xl font-extrabold uppercase">Política de privacidade</h1>
<p className="mt-6 text-[#E8F4E5]/70">
A Sinka é uma plataforma de inteligência logística. Este texto descreve o que coletamos no
login e na operação.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Dados</h2>
<p className="mt-3 text-[#E8F4E5]/70">
Conta: nome e e-mail. Se você entrar com o Google, usamos os dados básicos da sua conta Google.
Operação: clientes, transportadoras e simulações de frete da sua empresa.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Uso</h2>
<p className="mt-3 text-[#E8F4E5]/70">
Usamos esses dados para autenticar, calcular frete e mostrar o painel da empresa. Não
vendemos dados. A sessão fica protegida no navegador.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Contato</h2>
<p className="mt-3 text-[#E8F4E5]/70">
Dúvidas de privacidade: use o e-mail de suporte informado na tela de consentimento do Google.
</p>
<a href="/" className="mt-10 inline-block text-[#82C85C]">
Voltar
</a>
</div>
</main>
);
}

View File

@ -0,0 +1,30 @@
export default function TermosPage() {
return (
<main className="min-h-dvh bg-black px-5 py-16 text-[#E8F4E5]">
<div className="mx-auto max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Sinka</p>
<h1 className="font-display mt-3 text-4xl font-extrabold uppercase">Termos de serviço</h1>
<p className="mt-6 text-[#E8F4E5]/70">
A Sinka é uma plataforma de demonstração de inteligência logística. Ao usar o site, você
concorda com estes termos.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Uso</h2>
<p className="mt-3 text-[#E8F4E5]/70">
O ambiente é para avaliação do produto. Contas de teste e dados de exemplo podem ser
alterados ou apagados. Não use o sistema para operação real nem para dados pessoais de
terceiros sem autorização.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Conta</h2>
<p className="mt-3 text-[#E8F4E5]/70">
Você pode entrar com e-mail e senha ou com o Google. Cada empresa os dados dela.
Você é responsável pelo que cadastrar na sua sessão.
</p>
<h2 className="font-display mt-10 text-2xl font-bold uppercase">Contato</h2>
<p className="mt-3 text-[#E8F4E5]/70">Dúvidas: use o e-mail informado na tela de consentimento do Google.</p>
<a href="/" className="mt-10 inline-block text-[#82C85C]">
Voltar
</a>
</div>
</main>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type RevealSectionProps = {
id?: string;
className?: string;
children: ReactNode;
};
export function RevealSection({ id, className = "", children }: RevealSectionProps) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.18, rootMargin: "0px 0px -8% 0px" },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<section
ref={ref}
id={id}
className={`chapter reveal ${visible ? "is-in" : ""} ${className}`.trim()}
>
{children}
</section>
);
}

View File

@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type ScrollFilmProps = {
src: string;
poster: string;
children: ReactNode;
};
export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
const orbitRef = useRef<HTMLDivElement>(null);
const filmRef = useRef<HTMLVideoElement>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const orbit = orbitRef.current;
const film = filmRef.current;
if (!orbit || !film) {
return;
}
film.muted = true;
film.defaultMuted = true;
film.playsInline = true;
film.preload = "auto";
film.pause();
let ready = false;
let current = 0;
let frame = 0;
let blobUrl: string | null = null;
const markReady = () => {
if (ready) {
return;
}
ready = true;
film.pause();
film.classList.add("is-ready");
setLoading(false);
};
fetch(src)
.then((response) => response.blob())
.then((blob) => {
blobUrl = URL.createObjectURL(blob);
film.src = blobUrl;
film.load();
})
.catch(() => {
film.src = src;
film.load();
});
const progressFromScroll = () => {
const total = Math.max(orbit.offsetHeight - window.innerHeight, 1);
const y = Math.min(Math.max(-orbit.getBoundingClientRect().top, 0), total);
return y / total;
};
const tick = () => {
const target = progressFromScroll();
current += (target - current) * 0.42;
orbit.style.setProperty("--scrub", current.toFixed(4));
if (ready && film.duration && !film.seeking) {
const time = current * (film.duration - 0.04);
if (Math.abs(film.currentTime - time) >= 0.02) {
film.currentTime = time;
}
}
frame = requestAnimationFrame(tick);
};
film.addEventListener("canplaythrough", markReady);
const timeout = window.setTimeout(markReady, 10000);
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
film.removeEventListener("canplaythrough", markReady);
window.clearTimeout(timeout);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [src]);
return (
<div ref={orbitRef} className="orbit" id="topo">
<div className="orbit-stage">
<img alt="" className="orbit-poster" src={poster} />
<video
ref={filmRef}
className="orbit-film"
muted
playsInline
preload="auto"
poster={poster}
/>
<div className="orbit-veil" />
</div>
<div className="orbit-story">{children}</div>
{loading ? (
<div className="orbit-loader" role="status" aria-live="polite" aria-label="Sinka">
<img src="/brand/sinka-logo.webp" alt="Sinka" className="orbit-loader-logo" />
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import { RevealSection } from "@/components/landing/reveal-section";
import { ScrollFilm } from "@/components/landing/scroll-film";
import { SiteFooter } from "@/components/landing/site-footer";
import { SiteHeader } from "@/components/landing/site-header";
import { VisionBoard } from "@/components/landing/vision-board";
const quotes = [
{ tag: "Melhor custo", name: "Rota Sul", value: "R$ 412" },
{ tag: "Menor prazo", name: "Express RJ", value: "14h" },
{ tag: "Melhor opção", name: "Litoral Mix", value: "R$ 468" },
];
const insights = [
"Custo médio aumentou 12%.",
"A Litoral Mix tem o melhor desempenho nesta rota.",
"23% das simulações têm uma opção mais econômica.",
];
const carriers = [
{ name: "Litoral Mix", cost: "R$ 468", sla: "18h", score: "96", ops: "214" },
{ name: "Rota Sul", cost: "R$ 412", sla: "26h", score: "91", ops: "180" },
{ name: "Express RJ", cost: "R$ 531", sla: "14h", score: "88", ops: "97" },
];
const liveEvents = [
"Nova simulação",
"Cotação atualizada",
"Frete atualizado",
"Nova análise disponível",
];
export function SinkaLanding() {
return (
<div className="bg-black text-[#E8F4E5]">
<SiteHeader />
<ScrollFilm src="/film/sinka-hero.mp4?v=2" poster="/film/sinka-hero-poster.jpg">
<section className="hero-copy">
<h1 className="mt-0 max-w-3xl font-display text-4xl font-extrabold leading-[1.02] md:text-5xl">
A inteligência que move sua operação.
</h1>
<p className="mt-5 max-w-lg text-base text-[#E8F4E5]/65">
Simule fretes, analise custos e transforme dados logísticos em decisões mais
inteligentes.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#problema" className="btn-ghost">
Conhecer a plataforma
</a>
<a href="/cliente-1/login" className="btn-sinka">
Começar agora
</a>
</div>
</section>
<div className="orbit-spacer" aria-hidden />
</ScrollFilm>
<RevealSection id="problema">
<p className="kicker">O dia a dia</p>
<h2>A cotação existe. A decisão some.</h2>
<p className="lede">
Peso, destino e valor estão na operação. O que falta é um lugar em que isso vire
comparação, não print de conversa nem aba de planilha.
</p>
<ul className="pain-list">
<li>
<strong>No grupo</strong>
<span>O frete vai no WhatsApp e ninguém acha o número depois.</span>
</li>
<li>
<strong>Na planilha</strong>
<span>Cada analista tem a sua. O custo médio não fecha.</span>
</li>
<li>
<strong>Na escolha</strong>
<span>A transportadora entra porque sempre foi essa, sem histórico.</span>
</li>
</ul>
</RevealSection>
<RevealSection id="visao">
<p className="kicker">Visão</p>
<h2>Uma visão inteligente da sua logística.</h2>
<VisionBoard />
</RevealSection>
<RevealSection id="simulacao">
<p className="kicker">Simulação</p>
<h2>Simule antes de decidir.</h2>
<p className="lede">Compare cenários e encontre a melhor alternativa para cada operação.</p>
<p className="route-line">São Paulo Rio de Janeiro</p>
<p className="meta-line">250 kg · 120 × 80 × 70 cm · R$ 18.500</p>
<div className="quote-row">
{quotes.map((item) => (
<article key={item.tag}>
<p>{item.tag}</p>
<strong>{item.name}</strong>
<span>{item.value}</span>
</article>
))}
</div>
</RevealSection>
<RevealSection>
<p className="kicker">Inteligência</p>
<h2>Dados que ajudam você a decidir.</h2>
<ol className="insight-list">
{insights.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</RevealSection>
<RevealSection>
<p className="kicker">Transportadoras</p>
<h2>Mais clareza na escolha da transportadora.</h2>
<table className="carrier-table">
<thead>
<tr>
<th>Nome</th>
<th>Custo</th>
<th>Prazo</th>
<th>Desempenho</th>
<th>Operações</th>
</tr>
</thead>
<tbody>
{carriers.map((item) => (
<tr key={item.name}>
<td>{item.name}</td>
<td>{item.cost}</td>
<td>{item.sla}</td>
<td>{item.score}</td>
<td>{item.ops}</td>
</tr>
))}
</tbody>
</table>
</RevealSection>
<RevealSection className="chapter-live">
<p className="kicker">Tempo real</p>
<h2>Sua operação em movimento.</h2>
<ul className="live-feed">
{liveEvents.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</RevealSection>
<RevealSection className="metrics">
<div>
<strong>10k+</strong>
<p>simulações</p>
</div>
<div>
<strong>500+</strong>
<p>rotas analisadas</p>
</div>
<div>
<strong>120+</strong>
<p>transportadoras</p>
</div>
</RevealSection>
<RevealSection id="acesso" className="cta">
<div className="cta-copy">
<h2>Mova sua logística com inteligência.</h2>
<p className="lede">Menos incerteza. Mais dados. Melhores decisões.</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#topo" className="btn-ghost">
Conhecer a Sinka
</a>
<a href="/cliente-1/login" className="btn-sinka">
Começar agora
</a>
</div>
</div>
</RevealSection>
<SiteFooter />
</div>
);
}

View File

@ -0,0 +1,26 @@
import Image from "next/image";
export function SiteFooter() {
return (
<footer className="border-t border-white/10 px-5 py-10">
<div className="mx-auto flex max-w-6xl flex-col items-start justify-between gap-6 md:flex-row md:items-center">
<Image
src="/brand/sinka-logo.webp"
alt="Sinka: Inteligência em Logística"
width={160}
height={52}
className="h-7 w-auto"
/>
<div className="flex flex-col gap-2 text-sm text-[#E8F4E5]/50 md:items-end">
<p>Sinka · Inteligência em Logística</p>
<a href="/privacidade" className="text-[#82C85C]">
Privacidade
</a>
<a href="/termos" className="text-[#82C85C]">
Termos
</a>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,79 @@
"use client";
import Image from "next/image";
import { useState } from "react";
const links = [
{ href: "#problema", label: "Produto" },
{ href: "#visao", label: "Plataforma" },
{ href: "#simulacao", label: "Simulação" },
{ href: "#acesso", label: "Acesso" },
];
export function SiteHeader() {
const [open, setOpen] = useState(false);
return (
<header className="fixed top-0 z-40 w-full bg-black/55 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-5 py-4">
<a href="#topo" className="shrink-0" onClick={() => setOpen(false)}>
<Image
src="/brand/sinka-logo.webp"
alt="Sinka: Inteligência em Logística"
width={180}
height={58}
className="h-8 w-auto md:h-9"
priority
/>
</a>
<nav className="hidden items-center gap-8 text-base text-[#E8F4E5]/80 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="transition hover:text-[#E8F4E5]">
{link.label}
</a>
))}
</nav>
<div className="flex items-center gap-2">
<a
href="#acesso"
className="hidden rounded-full border border-[#82C85C]/40 px-5 py-2.5 text-sm font-semibold uppercase tracking-wide text-[#E8F4E5] md:inline-flex"
>
Conhecer a plataforma
</a>
<a
href="/cliente-1/login"
className="rounded-full bg-[#82C85C] px-5 py-2.5 text-sm font-semibold uppercase tracking-wide text-black"
>
Entrar
</a>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-[#82C85C]/30 text-[#E8F4E5] md:hidden"
aria-expanded={open}
aria-label="Abrir menu"
onClick={() => setOpen((value) => !value)}
>
{open ? "×" : "☰"}
</button>
</div>
</div>
{open ? (
<nav className="flex flex-col gap-3 border-t border-white/10 px-5 py-4 text-base md:hidden">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-[#E8F4E5]"
onClick={() => setOpen(false)}
>
{link.label}
</a>
))}
</nav>
) : null}
</header>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState } from "react";
const kpis = [
{ label: "Custo médio de frete", value: 486, prefix: "R$ ", hint: "8% vs mês anterior" },
{ label: "Simulações", value: 128, prefix: "", hint: "+23 nas últimas 24h" },
{ label: "Transportadoras", value: 14, prefix: "", hint: "3 com melhor prazo" },
{ label: "Rotas ativas", value: 37, prefix: "", hint: "12 acima da média" },
];
const bars = [
{ label: "Seg", value: 42, amount: "R$ 398" },
{ label: "Ter", value: 68, amount: "R$ 512" },
{ label: "Qua", value: 51, amount: "R$ 441" },
{ label: "Qui", value: 86, amount: "R$ 603" },
{ label: "Sex", value: 63, amount: "R$ 478" },
{ label: "Sáb", value: 74, amount: "R$ 531" },
];
function useCount(target: number, active: boolean) {
const [value, setValue] = useState(0);
useEffect(() => {
if (!active) {
setValue(0);
return;
}
const start = performance.now();
const duration = 900;
let frame = 0;
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - (1 - progress) ** 3;
setValue(Math.round(target * eased));
if (progress < 1) {
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [active, target]);
return value;
}
function KpiCard({
item,
active,
delay,
}: {
item: (typeof kpis)[number];
active: boolean;
delay: number;
}) {
const counted = useCount(item.value, active);
return (
<article className="dash-card" style={{ transitionDelay: `${delay}ms` }} data-ready={active}>
<p>{item.label}</p>
<strong>
{item.prefix}
{active ? counted : 0}
</strong>
<span className="dash-hint">{item.hint}</span>
</article>
);
}
export function VisionBoard() {
const rootRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);
const [hoverBar, setHoverBar] = useState<number | null>(null);
useEffect(() => {
const node = rootRef.current;
if (!node) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setActive(true);
observer.disconnect();
}
},
{ threshold: 0.35 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div ref={rootRef} className="dash" data-ready={active}>
<div className="dash-top">
<span>Sinka · operação</span>
</div>
<div className="dash-grid">
{kpis.map((item, index) => (
<KpiCard key={item.label} item={item} active={active} delay={index * 90} />
))}
</div>
<div className="dash-row">
<div className="bars" aria-hidden={!active}>
{bars.map((item, index) => (
<button
key={item.label}
type="button"
className="bar"
data-ready={active}
style={{
height: active ? `${item.value}%` : "8%",
transitionDelay: `${280 + index * 70}ms`,
}}
onMouseEnter={() => setHoverBar(index)}
onMouseLeave={() => setHoverBar(null)}
onFocus={() => setHoverBar(index)}
onBlur={() => setHoverBar(null)}
aria-label={`${item.label}: ${item.amount}`}
>
{hoverBar === index ? (
<span className="bar-tip">
{item.label}
<b>{item.amount}</b>
</span>
) : null}
</button>
))}
</div>
<p className="insight-line" data-ready={active}>
23% das cotações têm uma opção mais econômica.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import Image from "next/image";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, type SessionUser } from "@/lib/api";
export function LoginForm({
variant,
tenantSlug,
}: {
variant: "admin" | "tenant";
tenantSlug?: string;
}) {
const router = useRouter();
const search = useSearchParams();
const oauthError = {
google_off: "Entrar com o Google está indisponível no momento.",
google_unlinked: "Este e-mail Google não tem acesso à administração.",
google_denied: "Não foi possível entrar com o Google.",
}[search.get("error") ?? ""];
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setError("");
setLoading(true);
try {
const result = await api<{ user: SessionUser }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({
email,
password,
tenantSlug: variant === "tenant" ? tenantSlug : undefined,
}),
});
const next = search.get("next");
if (result.user.kind === "platform") {
router.replace(next?.startsWith("/admin") ? next : "/admin");
return;
}
const home = `/${result.user.tenantSlug}`;
router.replace(next?.startsWith(home) ? next : home);
} catch (err) {
setError(err instanceof Error ? err.message : "Falha no login.");
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-dvh flex-col bg-black text-[#E8F4E5]">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-6 py-16">
<a href="/" className="mb-10 block w-fit">
<Image
src="/brand/sinka-logo.webp"
alt="Sinka"
width={180}
height={58}
className="h-8 w-auto"
priority
/>
</a>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">
{variant === "admin" ? "Administração" : "Operação"}
</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase leading-none">
{variant === "admin" ? "Administração Sinka" : "Entrar"}
</h1>
<p className="mt-4 text-[#E8F4E5]/60">
{variant === "admin"
? "Crie e acompanhe os clientes da Sinka."
: "Entre com e-mail e senha, ou com o Google."}
</p>
<form className="mt-8 flex flex-col gap-4" onSubmit={onSubmit}>
<label className="flex flex-col gap-2 text-sm">
E-mail
<input
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-[#82C85C]"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="username"
/>
</label>
<label className="flex flex-col gap-2 text-sm">
Senha
<input
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-[#82C85C]"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
autoComplete="current-password"
/>
</label>
{error || oauthError ? <p className="text-sm text-red-300">{error || oauthError}</p> : null}
<button className="btn-sinka mt-2 justify-center" disabled={loading} type="submit">
{loading ? "Entrando…" : "Entrar"}
</button>
</form>
<p className="mt-5 text-center text-xs uppercase tracking-[0.2em] text-[#E8F4E5]/35">ou</p>
<a
className="btn-google mt-4"
href={`/api/auth/google${
variant === "tenant"
? `?tenant=${encodeURIComponent(tenantSlug ?? "")}&next=${encodeURIComponent(`/${tenantSlug}`)}`
: "?next=/admin"
}`}
>
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden>
<path
fill="#4285F4"
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"
/>
<path
fill="#FBBC05"
d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.348 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
/>
</svg>
Entrar com o Google
</a>
</div>
</main>
);
}

View File

@ -0,0 +1,77 @@
import type { ReactNode } from "react";
export function RegistryTable({
countLabel,
action,
columns,
empty,
isEmpty,
children,
align = "left",
}: {
countLabel: string;
action?: ReactNode;
columns: string[];
empty?: string;
isEmpty: boolean;
children: ReactNode;
align?: "left" | "center";
}) {
return (
<div className="mt-8 overflow-hidden rounded-2xl border border-white/10">
<div className="flex items-center justify-between gap-3 px-5 py-3">
<p className="text-sm text-[#E8F4E5]/60">{countLabel}</p>
{action ?? <span />}
</div>
<div className="overflow-x-auto border-t border-white/10">
<table className={`w-full min-w-[36rem] text-sm ${align === "center" ? "text-center" : "text-left"}`}>
<thead className="bg-white/5 text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr>
{columns.map((column) => (
<th key={column} className="px-5 py-3 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{isEmpty ? (
<tr>
<td className="px-5 py-8 text-[#E8F4E5]/50" colSpan={columns.length}>
{empty ?? "Nenhum registro."}
</td>
</tr>
) : (
children
)}
</tbody>
</table>
</div>
</div>
);
}
export function Modal({
children,
onClose,
wide,
}: {
children: ReactNode;
onClose: () => void;
wide?: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4" onClick={onClose}>
<div
className={`max-h-[90dvh] w-full overflow-y-auto rounded-2xl border border-white/10 bg-black p-6 ${
wide ? "max-w-3xl" : "max-w-md"
}`}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>
);
}
export const fieldClass = "rounded-xl border border-white/10 bg-white/5 px-4 py-3";

View File

@ -0,0 +1,173 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { api, type SessionUser } from "@/lib/api";
export function RestrictedShell({
kind,
basePath,
children,
}: {
kind: "tenant" | "platform";
basePath: string;
children: ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<SessionUser | null>(null);
const [error, setError] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`;
useEffect(() => {
api<SessionUser>("/api/auth/me")
.then((session) => {
if (kind === "tenant") {
if (session.kind !== "tenant" || !session.tenantSlug) {
router.replace("/admin");
return;
}
if (`/${session.tenantSlug}` !== basePath) {
router.replace(`/${session.tenantSlug}`);
return;
}
}
if (kind === "platform" && session.kind !== "platform") {
router.replace(session.tenantSlug ? `/${session.tenantSlug}` : "/admin/login");
return;
}
setUser(session);
})
.catch(() => {
setError("Sessão expirada.");
router.replace(loginPath);
});
}, [kind, router, basePath, loginPath]);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
async function logout() {
await api("/api/auth/logout", { method: "POST" }).catch(() => undefined);
router.replace(loginPath);
}
if (!user) {
return (
<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">
{error || "Carregando…"}
</main>
);
}
const links =
kind === "platform"
? [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin/clientes", label: "Clientes" },
{ href: "/admin/integracoes", label: "Integrações" },
]
: [
{ href: basePath, label: "Dashboard" },
{ href: `${basePath}/simular`, label: "Simular" },
{ href: `${basePath}/historico`, label: "Histórico" },
{ href: `${basePath}/chat`, label: "Chat" },
{ href: `${basePath}/arquivos`, label: "Arquivos" },
{ href: `${basePath}/clientes`, label: "Clientes" },
...(user.permissions.includes("carriers:write")
? [{ href: `${basePath}/transportadoras`, label: "Transportadoras" }]
: []),
...(user.permissions.includes("users:manage") || user.role === "MANAGER"
? [{ href: `${basePath}/equipe`, label: "Equipe" }]
: []),
];
function isActive(href: string) {
if (kind === "platform") {
if (href === "/admin") {
return pathname === "/admin" || pathname === "/admin/";
}
return pathname === href || pathname.startsWith(`${href}/`);
}
return href === basePath ? pathname === basePath : pathname.startsWith(href);
}
const nav = (
<>
<div className="px-5 pb-6 pt-7">
<Link href={basePath} className="inline-block">
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" />
</Link>
<p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-[#82C85C]">
{kind === "platform" ? "Admin" : user.tenantName || "Operação"}
</p>
</div>
<nav className="flex flex-1 flex-col gap-0.5 px-3">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={`rounded-lg px-3 py-3 text-base ${
isActive(link.href)
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
}`}
>
{link.label}
</Link>
))}
</nav>
<div className="mt-auto border-t border-white/10 px-5 py-5">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="mt-0.5 text-xs text-[#82C85C]">{user.roleLabel}</p>
<button
className="btn-ghost mt-4 w-full justify-center px-3 py-2 text-xs"
type="button"
onClick={() => void logout()}
>
Sair
</button>
</div>
</>
);
return (
<div className="min-h-dvh bg-black text-[#E8F4E5] lg:flex">
<header className="flex items-center justify-between border-b border-white/10 px-4 py-3 lg:hidden">
<Link href={basePath}>
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={120} height={40} className="h-6 w-auto" />
</Link>
<button
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs uppercase tracking-wider text-[#E8F4E5]/80"
type="button"
onClick={() => setMenuOpen((open) => !open)}
>
Menu
</button>
</header>
{menuOpen ? (
<button
className="fixed inset-0 z-30 bg-black/60 lg:hidden"
type="button"
aria-label="Fechar menu"
onClick={() => setMenuOpen(false)}
/>
) : null}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-72 flex-col border-r border-white/10 bg-black transition-transform lg:static lg:translate-x-0 ${
menuOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
{nav}
</aside>
<main className="min-w-0 flex-1 px-5 py-8 lg:px-10 lg:py-10">{children}</main>
</div>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { FormEvent, useEffect, useRef, useState } from "react";
import { api, type SessionUser } from "@/lib/api";
import { listenChat, pushChat, type ChatMessage } from "@/lib/firebase-rtdb";
export function SupportChat({
slug,
title,
}: {
slug?: string;
title: string;
}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState("");
const [error, setError] = useState("");
const [ready, setReady] = useState(false);
const [user, setUser] = useState<SessionUser | null>(null);
const [heading, setHeading] = useState(title);
const channelRef = useRef("");
const bottomRef = useRef<HTMLDivElement>(null);
const seen = useRef(new Set<string>());
useEffect(() => {
let off: (() => void) | undefined;
let cancelled = false;
async function connect() {
try {
const session = await api<SessionUser>("/api/auth/me");
if (cancelled) {
return;
}
if (session.kind === "tenant" && slug && slug !== session.tenantSlug) {
throw new Error("Você só pode acessar o chat da sua empresa.");
}
const path =
session.kind === "platform"
? `/api/chat/channel?slug=${encodeURIComponent(slug ?? "")}`
: "/api/chat/channel";
const room = await api<{ slug: string; name: string; channel: string }>(path);
if (cancelled) {
return;
}
setUser(session);
setHeading(session.kind === "platform" ? room.name : title);
channelRef.current = room.channel;
off = listenChat(room.channel, (message) => {
if (seen.current.has(message.id)) {
return;
}
seen.current.add(message.id);
setMessages((current) => [...current, message]);
});
setReady(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Chat indisponível.");
}
}
void connect();
return () => {
cancelled = true;
off?.();
};
}, [slug, title]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!text.trim() || !user || !channelRef.current) {
return;
}
const payload = text.trim();
setText("");
setError("");
try {
await pushChat(channelRef.current, {
authorId: user.id,
authorName: user.name,
authorKind: user.kind,
text: payload,
createdAt: Date.now(),
});
} catch (err) {
setText(payload);
setError(err instanceof Error ? err.message : "Não foi possível enviar.");
}
}
return (
<section className="flex min-h-[28rem] flex-col">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Chat</p>
{user?.kind === "platform" ? (
<a href="/admin/clientes" className="mt-4 inline-flex text-sm text-[#82C85C] hover:underline">
Voltar para clientes
</a>
) : null}
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">{heading}</h1>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<div className="mt-6 flex min-h-[22rem] flex-1 flex-col overflow-hidden rounded-2xl border border-white/10">
<div className="flex-1 space-y-3 overflow-y-auto px-5 py-4">
{messages.length === 0 ? (
<p className="text-sm text-[#E8F4E5]/45">{ready ? "Nenhuma mensagem ainda." : "Conectando…"}</p>
) : (
messages.map((message) => (
<article
key={message.id}
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm ${
message.authorKind === "platform"
? "ml-auto bg-[#82C85C] text-black"
: "bg-white/8 text-[#E8F4E5]"
}`}
>
<p className="text-[11px] uppercase tracking-wider opacity-70">{message.authorName}</p>
<p className="mt-1 whitespace-pre-wrap">{message.text}</p>
</article>
))
)}
<div ref={bottomRef} />
</div>
<form className="flex gap-2 border-t border-white/10 p-3" onSubmit={onSubmit}>
<input
className="flex-1 rounded-xl border border-white/10 bg-white/5 px-4 py-3"
placeholder="Escrever mensagem"
value={text}
onChange={(event) => setText(event.target.value)}
disabled={!ready}
/>
<button className="btn-sinka px-5" disabled={!ready} type="submit">
Enviar
</button>
</form>
</div>
</section>
);
}

View File

@ -0,0 +1,29 @@
import Image from "next/image";
export function TenantMissing({ slug }: { slug: string }) {
return (
<main className="flex min-h-dvh flex-col bg-black text-[#E8F4E5]">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-6 py-16">
<a href="/" className="mb-10 block w-fit">
<Image
src="/brand/sinka-logo.webp"
alt="Sinka"
width={180}
height={58}
className="h-8 w-auto"
priority
/>
</a>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Cliente</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase leading-none">Este cliente não existe</h1>
<p className="mt-4 text-[#E8F4E5]/60">
Não empresa cadastrada em <span className="text-[#E8F4E5]">/{slug}</span>. Confira o endereço com quem
contratou a Sinka.
</p>
<a href="/" className="btn-sinka mt-8 w-fit">
Voltar
</a>
</div>
</main>
);
}

57
web/src/lib/api.ts Normal file
View File

@ -0,0 +1,57 @@
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const request = () =>
fetch(path, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
let response = await request();
if (response.status === 401 && path !== "/api/auth/refresh" && path !== "/api/auth/login") {
const refresh = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
if (refresh.ok) {
response = await request();
}
}
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
const message = Array.isArray(body.message) ? body.message[0] : body.message;
throw new Error(message ?? "Não foi possível concluir a operação.");
}
return response.json() as Promise<T>;
}
export async function apiForm<T>(path: string, body: FormData): Promise<T> {
const request = () => fetch(path, { method: "POST", credentials: "include", body });
let response = await request();
if (response.status === 401) {
const refresh = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
if (refresh.ok) {
response = await request();
}
}
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { message?: string | string[] };
const message = Array.isArray(payload.message) ? payload.message[0] : payload.message;
throw new Error(message ?? "Não foi possível enviar o arquivo.");
}
return response.json() as Promise<T>;
}
export type SessionUser = {
id: string;
name: string;
email: string;
role: "PLATFORM_ADMIN" | "ADMIN" | "MANAGER" | "OPERATOR";
roleLabel: string;
kind: "platform" | "tenant";
tenantId: string | null;
tenantSlug: string | null;
tenantName: string | null;
permissions: string[];
};

View File

@ -0,0 +1,106 @@
const DATABASE_URL = (
process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL ??
"https://sinka-8ec10-default-rtdb.firebaseio.com"
).replace(/\/$/, "");
export type ChatMessage = {
id: string;
authorId: string;
authorName: string;
authorKind: "platform" | "tenant";
text: string;
createdAt: number;
};
type MessageBody = Omit<ChatMessage, "id">;
function messagesUrl(channel: string) {
return `${DATABASE_URL}/chats/${channel}/messages.json`;
}
function toMessage(id: string, value: unknown): ChatMessage | null {
if (!value || typeof value !== "object") {
return null;
}
const row = value as Partial<MessageBody>;
if (!row.text || !row.authorName || !row.authorKind) {
return null;
}
return {
id,
authorId: row.authorId ?? "",
authorName: row.authorName,
authorKind: row.authorKind === "platform" ? "platform" : "tenant",
text: String(row.text),
createdAt: typeof row.createdAt === "number" ? row.createdAt : Date.now(),
};
}
function ingestPut(
path: string,
data: unknown,
onMessage: (message: ChatMessage) => void,
) {
if (data === null) {
return;
}
if (path === "/" || path === "") {
if (typeof data !== "object") {
return;
}
const rows = Object.entries(data as Record<string, unknown>)
.map(([id, value]) => toMessage(id, value))
.filter((row): row is ChatMessage => Boolean(row))
.sort((left, right) => left.createdAt - right.createdAt);
rows.forEach(onMessage);
return;
}
const message = toMessage(path.replace(/^\//, ""), data);
if (message) {
onMessage(message);
}
}
export function listenChat(channel: string, onMessage: (message: ChatMessage) => void) {
const source = new EventSource(messagesUrl(channel));
const onPut = (event: Event) => {
const payload = JSON.parse((event as MessageEvent).data) as { path: string; data: unknown };
ingestPut(payload.path, payload.data, onMessage);
};
const onPatch = (event: Event) => {
const payload = JSON.parse((event as MessageEvent).data) as { path: string; data: unknown };
if (payload.path === "/" || payload.path === "") {
if (payload.data && typeof payload.data === "object") {
Object.entries(payload.data as Record<string, unknown>).forEach(([id, value]) => {
const message = toMessage(id, value);
if (message) {
onMessage(message);
}
});
}
return;
}
ingestPut(payload.path, payload.data, onMessage);
};
source.addEventListener("put", onPut);
source.addEventListener("patch", onPatch);
return () => {
source.removeEventListener("put", onPut);
source.removeEventListener("patch", onPatch);
source.close();
};
}
export async function pushChat(channel: string, message: MessageBody) {
const response = await fetch(messagesUrl(channel), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
if (!response.ok) {
throw new Error("Não foi possível enviar a mensagem.");
}
}

44
web/src/lib/tenants.ts Normal file
View File

@ -0,0 +1,44 @@
export const RESERVED_SLUGS = [
"admin",
"login",
"app",
"platform",
"api",
"brand",
"film",
"topo",
"privacidade",
"termos",
"chat",
"arquivos",
] as const;
export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug.toLowerCase() as (typeof RESERVED_SLUGS)[number]);
}
export function isTenantSlug(slug: string): boolean {
return /^[a-z][a-z0-9-]{1,30}$/.test(slug) && !isReservedSlug(slug);
}
export type PublicTenant = {
name: string;
slug: string;
status: string;
};
export async function fetchPublicTenant(slug: string): Promise<PublicTenant | null> {
const origin = (
process.env.API_INTERNAL_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:3001/api"
).replace(/\/$/, "");
const response = await fetch(`${origin}/tenants/${encodeURIComponent(slug)}`, { cache: "no-store" });
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error("Não foi possível verificar o cliente.");
}
return response.json() as Promise<PublicTenant>;
}

47
web/src/middleware.ts Normal file
View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { isReservedSlug, isTenantSlug } from "@/lib/tenants";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === "/" || pathname.startsWith("/_next") || pathname.startsWith("/api")) {
return NextResponse.next();
}
const hasSession =
Boolean(request.cookies.get("sinka_access")?.value) ||
Boolean(request.cookies.get("sinka_refresh")?.value);
if (pathname === "/admin/login") {
return NextResponse.next();
}
if (pathname === "/admin" || pathname.startsWith("/admin/")) {
if (!hasSession) {
const login = new URL("/admin/login", request.url);
login.searchParams.set("next", pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
}
const tenant = pathname.split("/").filter(Boolean)[0];
if (!tenant || isReservedSlug(tenant) || !isTenantSlug(tenant)) {
return NextResponse.next();
}
if (pathname === `/${tenant}/login`) {
return NextResponse.next();
}
if (!hasSession) {
const login = new URL(`/${tenant}/login`, request.url);
login.searchParams.set("next", pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|brand|film).*)"],
};

34
web/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}