Compare commits
No commits in common. "0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0" and "cf16bf1fb634400686e5f5212e08a8f709767d1f" have entirely different histories.
0b19e8ac5c
...
cf16bf1fb6
@ -17,7 +17,11 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
|
||||
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
||||
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_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ msg.txt
|
||||
*.tsbuildinfo
|
||||
docker-compose.vps.yml
|
||||
deploy/
|
||||
msg.txt
|
||||
|
||||
17
api/src/platform/integrations.controller.ts
Normal file
17
api/src/platform/integrations.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
80
api/src/platform/integrations.service.ts
Normal file
80
api/src/platform/integrations.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
api/src/platform/integrations.spec.ts
Normal file
19
api/src/platform/integrations.spec.ts
Normal 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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
42
api/src/platform/integrations.ts
Normal file
42
api/src/platform/integrations.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -1,11 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsController } from './integrations.controller';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformOverviewController } from './platform-overview.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { PublicTenantsController } from './public-tenants.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
|
||||
providers: [PlatformService],
|
||||
controllers: [
|
||||
PlatformController,
|
||||
PlatformOverviewController,
|
||||
IntegrationsController,
|
||||
PublicTenantsController,
|
||||
],
|
||||
providers: [PlatformService, IntegrationsService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
1
api/tsconfig.build.tsbuildinfo
Normal file
1
api/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
BIN
desafio_tecnico_logistica (1).pdf
Normal file
BIN
desafio_tecnico_logistica (1).pdf
Normal file
Binary file not shown.
22
msg.txt
Normal file
22
msg.txt
Normal file
@ -0,0 +1,22 @@
|
||||
Olá,
|
||||
|
||||
Sinka: SaaS de frete. NestJS + Next.js + MySQL + Redis.
|
||||
|
||||
Isolamento: um banco MySQL por empresa. Admin em /admin, cliente em /<empresa>.
|
||||
|
||||
Auth: e-mail/senha (JWT + cookie) e Google. Papéis Administrador, Manager, Operador.
|
||||
|
||||
Frete: ViaCEP + mapa, cotação por transportadora, dashboard de custo.
|
||||
|
||||
Comunicação em tempo real: Firebase Realtime com um chat interno entre a Sinka e o cliente. Cada empresa só vê a conversa dela.
|
||||
|
||||
Arquivos: tela para anexar CSV/XLSX (e PDF/TXT), tabela e botão de baixar. Fica no disco, separado por empresa.
|
||||
|
||||
Testes: Jest na API. `npm test`.
|
||||
|
||||
Landing: http://localhost:3000
|
||||
Admin: /admin/login — tina.r@example.net / SinkaPlatform!1
|
||||
Demo: /demo/login — xena.w@example.org / SinkaAdmin!1
|
||||
|
||||
Obrigado,
|
||||
[seu nome]
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
# 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 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.
|
||||
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 -->
|
||||
|
||||
300
web/src/app/admin/(panel)/clientes/page.tsx
Normal file
300
web/src/app/admin/(panel)/clientes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
web/src/app/admin/(panel)/integracoes/page.tsx
Normal file
53
web/src/app/admin/(panel)/integracoes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { 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 Overview = {
|
||||
clients: number;
|
||||
@ -18,146 +9,37 @@ type Overview = {
|
||||
users: number;
|
||||
};
|
||||
|
||||
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 PlatformPage() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
|
||||
const [messages, setMessages] = useState(0);
|
||||
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, stats] = await Promise.all([
|
||||
api<Tenant[]>("/api/platform/tenants"),
|
||||
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
|
||||
api<Overview>("/api/platform/overview").catch(() => null),
|
||||
]);
|
||||
setTenants(list);
|
||||
setChatCounts(counts);
|
||||
setOverview(stats);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
|
||||
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"));
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Admin</p>
|
||||
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Visão geral</h1>
|
||||
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
|
||||
{error ? <p className="mb-4 text-sm text-red-300">{error}</p> : null}
|
||||
|
||||
<ul className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
label: "Clientes",
|
||||
value: String(overview?.clients ?? tenants.length),
|
||||
value: String(overview?.clients ?? "-"),
|
||||
hint: "empresas na plataforma",
|
||||
},
|
||||
{
|
||||
label: "Ativos",
|
||||
value: String(overview?.active ?? tenants.filter((tenant) => tenant.status === "ACTIVE").length),
|
||||
value: String(overview?.active ?? "-"),
|
||||
hint: "prontos para operar",
|
||||
},
|
||||
{
|
||||
@ -167,7 +49,7 @@ export default function PlatformPage() {
|
||||
},
|
||||
{
|
||||
label: "Mensagens",
|
||||
value: String(Object.values(chatCounts).reduce((sum, count) => sum + count, 0)),
|
||||
value: String(messages),
|
||||
hint: "no chat de suporte",
|
||||
},
|
||||
].map((card) => (
|
||||
@ -178,180 +60,6 @@ export default function PlatformPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||
{tenants.slice(0, 4).map((tenant) => (
|
||||
<article key={tenant.id} className="rounded-2xl border border-white/10 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">/{tenant.slug}</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">{tenant.name}</h2>
|
||||
<p className="mt-2 text-sm text-[#E8F4E5]/60">
|
||||
{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}
|
||||
{chatCounts[tenant.slug] ? ` · ${chatCounts[tenant.slug]} msg no chat` : " · sem chat pendente"}
|
||||
</p>
|
||||
<a href={`/${tenant.slug}/login`} className="mt-3 inline-flex text-sm text-[#82C85C]" target="_blank" rel="noopener noreferrer">
|
||||
Abrir operação
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="clientes" className="scroll-mt-24">
|
||||
<RegistryTable
|
||||
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 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-left 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 text-right">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -291,22 +291,6 @@ h3,
|
||||
color: rgba(232, 244, 229, 0.5);
|
||||
}
|
||||
|
||||
.live {
|
||||
color: #82c85c;
|
||||
letter-spacing: 0.18em;
|
||||
animation: live-blink 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@ -141,9 +141,7 @@ export function SinkaLanding() {
|
||||
</RevealSection>
|
||||
|
||||
<RevealSection className="chapter-live">
|
||||
<p className="kicker">
|
||||
<span className="live">LIVE</span> Tempo real
|
||||
</p>
|
||||
<p className="kicker">Tempo real</p>
|
||||
<h2>Sua operação em movimento.</h2>
|
||||
<ul className="live-feed">
|
||||
{liveEvents.map((item) => (
|
||||
|
||||
@ -99,7 +99,6 @@ export function VisionBoard() {
|
||||
<div ref={rootRef} className="dash" data-ready={active}>
|
||||
<div className="dash-top">
|
||||
<span>Sinka · operação</span>
|
||||
<span className="live">LIVE</span>
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
|
||||
@ -7,6 +7,7 @@ export function RegistryTable({
|
||||
empty,
|
||||
isEmpty,
|
||||
children,
|
||||
align = "left",
|
||||
}: {
|
||||
countLabel: string;
|
||||
action?: ReactNode;
|
||||
@ -14,6 +15,7 @@ export function RegistryTable({
|
||||
empty?: string;
|
||||
isEmpty: boolean;
|
||||
children: ReactNode;
|
||||
align?: "left" | "center";
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-white/10">
|
||||
@ -22,7 +24,7 @@ export function RegistryTable({
|
||||
{action ?? <span />}
|
||||
</div>
|
||||
<div className="overflow-x-auto border-t border-white/10">
|
||||
<table className="w-full min-w-[36rem] text-left text-sm">
|
||||
<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) => (
|
||||
|
||||
@ -20,7 +20,6 @@ export function RestrictedShell({
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [hash, setHash] = useState("");
|
||||
const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`;
|
||||
|
||||
useEffect(() => {
|
||||
@ -50,16 +49,8 @@ export function RestrictedShell({
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
setHash(typeof window === "undefined" ? "" : window.location.hash);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setHash(window.location.hash);
|
||||
sync();
|
||||
window.addEventListener("hashchange", sync);
|
||||
return () => window.removeEventListener("hashchange", sync);
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
await api("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
router.replace(loginPath);
|
||||
@ -77,7 +68,8 @@ export function RestrictedShell({
|
||||
kind === "platform"
|
||||
? [
|
||||
{ href: "/admin", label: "Dashboard" },
|
||||
{ href: "/admin#clientes", label: "Clientes" },
|
||||
{ href: "/admin/clientes", label: "Clientes" },
|
||||
{ href: "/admin/integracoes", label: "Integrações" },
|
||||
]
|
||||
: [
|
||||
{ href: basePath, label: "Dashboard" },
|
||||
@ -97,12 +89,9 @@ export function RestrictedShell({
|
||||
function isActive(href: string) {
|
||||
if (kind === "platform") {
|
||||
if (href === "/admin") {
|
||||
return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes";
|
||||
return pathname === "/admin" || pathname === "/admin/";
|
||||
}
|
||||
if (href.includes("#clientes")) {
|
||||
return (pathname === "/admin" || pathname === "/admin/") && hash === "#clientes";
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
return href === basePath ? pathname === basePath : pathname.startsWith(href);
|
||||
}
|
||||
@ -122,11 +111,6 @@ export function RestrictedShell({
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => {
|
||||
if (kind === "platform") {
|
||||
setHash(link.href.includes("#") ? "#clientes" : "");
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg px-3 py-3 text-base ${
|
||||
isActive(link.href)
|
||||
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
|
||||
|
||||
@ -95,7 +95,7 @@ export function SupportChat({
|
||||
<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" className="mt-4 inline-flex text-sm text-[#82C85C] hover:underline">
|
||||
<a href="/admin/clientes" className="mt-4 inline-flex text-sm text-[#82C85C] hover:underline">
|
||||
Voltar para clientes
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user