video so no scroll e menu vertical no admin
This commit is contained in:
parent
6cc1a2bc00
commit
0b19e8ac5c
17
api/src/platform/platform-overview.controller.ts
Normal file
17
api/src/platform/platform-overview.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 { 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
api/src/platform/platform-overview.spec.ts
Normal file
20
api/src/platform/platform-overview.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
16
api/src/platform/platform-overview.ts
Normal file
16
api/src/platform/platform-overview.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
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';
|
import { PublicTenantsController } from './public-tenants.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PlatformController, PublicTenantsController],
|
controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
|
||||||
providers: [PlatformService],
|
providers: [PlatformService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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 {
|
||||||
@ -22,6 +23,16 @@ 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) {
|
async publicBySlug(raw: string) {
|
||||||
let slug: string;
|
let slug: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -9,6 +9,13 @@ type Tenant = {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Overview = {
|
||||||
|
clients: number;
|
||||||
|
active: number;
|
||||||
|
users: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Created = Tenant & {
|
type Created = Tenant & {
|
||||||
@ -41,6 +48,7 @@ function slugFromName(value: string) {
|
|||||||
|
|
||||||
export default function PlatformPage() {
|
export default function PlatformPage() {
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
|
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -54,12 +62,14 @@ export default function PlatformPage() {
|
|||||||
const [issuing, setIssuing] = useState<string | null>(null);
|
const [issuing, setIssuing] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [list, counts] = await Promise.all([
|
const [list, counts, stats] = await Promise.all([
|
||||||
api<Tenant[]>("/api/platform/tenants"),
|
api<Tenant[]>("/api/platform/tenants"),
|
||||||
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
|
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
|
||||||
|
api<Overview>("/api/platform/overview").catch(() => null),
|
||||||
]);
|
]);
|
||||||
setTenants(list);
|
setTenants(list);
|
||||||
setChatCounts(counts);
|
setChatCounts(counts);
|
||||||
|
setOverview(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -135,12 +145,57 @@ export default function PlatformPage() {
|
|||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Admin</p>
|
<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">Clientes</h1>
|
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Visão geral</h1>
|
||||||
<p className="mt-4 max-w-xl text-[#E8F4E5]/65">
|
|
||||||
Quem contrata a Sinka. Cada cliente tem um endereço próprio para o time entrar.
|
|
||||||
</p>
|
|
||||||
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
|
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
|
||||||
|
|
||||||
|
<ul className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Clientes",
|
||||||
|
value: String(overview?.clients ?? tenants.length),
|
||||||
|
hint: "empresas na plataforma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Ativos",
|
||||||
|
value: String(overview?.active ?? tenants.filter((tenant) => tenant.status === "ACTIVE").length),
|
||||||
|
hint: "prontos para operar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Usuários",
|
||||||
|
value: String(overview?.users ?? "-"),
|
||||||
|
hint: "contas em todos os clientes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mensagens",
|
||||||
|
value: String(Object.values(chatCounts).reduce((sum, count) => sum + count, 0)),
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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
|
<RegistryTable
|
||||||
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
|
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
|
||||||
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
|
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
|
||||||
@ -190,6 +245,7 @@ export default function PlatformPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</RegistryTable>
|
</RegistryTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<Modal onClose={closeModal}>
|
<Modal onClose={closeModal}>
|
||||||
|
|||||||
@ -8,10 +8,6 @@ type ScrollFilmProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function easeInOutCubic(value: number) {
|
|
||||||
return value < 0.5 ? 4 * value * value * value : 1 - (-2 * value + 2) ** 3 / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
|
export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
|
||||||
const orbitRef = useRef<HTMLDivElement>(null);
|
const orbitRef = useRef<HTMLDivElement>(null);
|
||||||
const filmRef = useRef<HTMLVideoElement>(null);
|
const filmRef = useRef<HTMLVideoElement>(null);
|
||||||
@ -34,9 +30,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
|
|||||||
let current = 0;
|
let current = 0;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let blobUrl: string | null = null;
|
let blobUrl: string | null = null;
|
||||||
let introStartedAt = 0;
|
|
||||||
let introDone = false;
|
|
||||||
const introMs = 2400;
|
|
||||||
|
|
||||||
const markReady = () => {
|
const markReady = () => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@ -46,9 +39,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
|
|||||||
film.pause();
|
film.pause();
|
||||||
film.classList.add("is-ready");
|
film.classList.add("is-ready");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
introDone = reduce;
|
|
||||||
introStartedAt = reduce ? 0 : performance.now();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch(src)
|
fetch(src)
|
||||||
@ -69,33 +59,13 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
|
|||||||
return y / total;
|
return y / total;
|
||||||
};
|
};
|
||||||
|
|
||||||
const introSeconds = (now: number, duration: number) => {
|
const tick = () => {
|
||||||
if (introDone || !introStartedAt) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (progressFromScroll() > 0.02) {
|
|
||||||
introDone = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const elapsed = now - introStartedAt;
|
|
||||||
const t = Math.min(elapsed / introMs, 1);
|
|
||||||
const peak = Math.min(2, Math.max(duration * 0.22, 0.6));
|
|
||||||
const trip = t < 0.5 ? easeInOutCubic(t * 2) : 1 - easeInOutCubic((t - 0.5) * 2);
|
|
||||||
if (t >= 1) {
|
|
||||||
introDone = true;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return trip * peak;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const target = progressFromScroll();
|
const target = progressFromScroll();
|
||||||
current += (target - current) * 0.42;
|
current += (target - current) * 0.42;
|
||||||
orbit.style.setProperty("--scrub", current.toFixed(4));
|
orbit.style.setProperty("--scrub", current.toFixed(4));
|
||||||
|
|
||||||
if (ready && film.duration && !film.seeking) {
|
if (ready && film.duration && !film.seeking) {
|
||||||
const intro = introSeconds(now, film.duration);
|
const time = current * (film.duration - 0.04);
|
||||||
const time = intro ?? current * (film.duration - 0.04);
|
|
||||||
if (Math.abs(film.currentTime - time) >= 0.02) {
|
if (Math.abs(film.currentTime - time) >= 0.02) {
|
||||||
film.currentTime = time;
|
film.currentTime = time;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export function RestrictedShell({
|
|||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [hash, setHash] = useState("");
|
||||||
const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`;
|
const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -49,8 +50,16 @@ export function RestrictedShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
|
setHash(typeof window === "undefined" ? "" : window.location.hash);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setHash(window.location.hash);
|
||||||
|
sync();
|
||||||
|
window.addEventListener("hashchange", sync);
|
||||||
|
return () => window.removeEventListener("hashchange", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await api("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
await api("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||||
router.replace(loginPath);
|
router.replace(loginPath);
|
||||||
@ -66,7 +75,10 @@ export function RestrictedShell({
|
|||||||
|
|
||||||
const links =
|
const links =
|
||||||
kind === "platform"
|
kind === "platform"
|
||||||
? [{ href: "/admin", label: "Clientes" }]
|
? [
|
||||||
|
{ href: "/admin", label: "Dashboard" },
|
||||||
|
{ href: "/admin#clientes", label: "Clientes" },
|
||||||
|
]
|
||||||
: [
|
: [
|
||||||
{ href: basePath, label: "Dashboard" },
|
{ href: basePath, label: "Dashboard" },
|
||||||
{ href: `${basePath}/simular`, label: "Simular" },
|
{ href: `${basePath}/simular`, label: "Simular" },
|
||||||
@ -82,48 +94,17 @@ export function RestrictedShell({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
if (kind === "platform") {
|
if (kind === "platform") {
|
||||||
return (
|
if (href === "/admin") {
|
||||||
<div className="min-h-dvh bg-black text-[#E8F4E5]">
|
return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes";
|
||||||
<header className="border-b border-white/10">
|
|
||||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-5 py-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-8">
|
|
||||||
<Link href={basePath}>
|
|
||||||
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={160} height={52} className="h-7 w-auto" />
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-6 text-base">
|
|
||||||
{links.map((link) => {
|
|
||||||
const active = pathname === link.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "font-medium text-[#82C85C]"
|
|
||||||
: "text-[#E8F4E5]/70 transition hover:text-[#E8F4E5]"
|
|
||||||
}
|
}
|
||||||
>
|
if (href.includes("#clientes")) {
|
||||||
{link.label}
|
return (pathname === "/admin" || pathname === "/admin/") && hash === "#clientes";
|
||||||
</Link>
|
}
|
||||||
);
|
return pathname.startsWith(href);
|
||||||
})}
|
}
|
||||||
</nav>
|
return href === basePath ? pathname === basePath : pathname.startsWith(href);
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm">
|
|
||||||
<div className="hidden text-right sm:block">
|
|
||||||
<p className="font-medium">{user.name}</p>
|
|
||||||
<p className="text-xs text-[#82C85C]">{user.roleLabel}</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn-ghost px-3 py-2 text-xs" type="button" onClick={() => void logout()}>
|
|
||||||
Sair
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto max-w-6xl px-5 py-10">{children}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
@ -133,26 +114,28 @@ export function RestrictedShell({
|
|||||||
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" />
|
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-[#82C85C]">
|
<p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-[#82C85C]">
|
||||||
{user.tenantName || "Operação"}
|
{kind === "platform" ? "Admin" : user.tenantName || "Operação"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-1 flex-col gap-0.5 px-3">
|
<nav className="flex flex-1 flex-col gap-0.5 px-3">
|
||||||
{links.map((link) => {
|
{links.map((link) => (
|
||||||
const active = link.href === basePath ? pathname === basePath : pathname.startsWith(link.href);
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
|
onClick={() => {
|
||||||
|
if (kind === "platform") {
|
||||||
|
setHash(link.href.includes("#") ? "#clientes" : "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`rounded-lg px-3 py-3 text-base ${
|
className={`rounded-lg px-3 py-3 text-base ${
|
||||||
active
|
isActive(link.href)
|
||||||
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
|
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
|
||||||
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
|
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mt-auto border-t border-white/10 px-5 py-5">
|
<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="truncate text-sm font-medium">{user.name}</p>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user