feat: topbar de navegação como experimento de UI (v1.7.0)

- Substitui header fixo por topbar com backdrop blur e navegação agrupada em 5 seções
- Adiciona FerramentasDropdown consolidando calculadora e modo privacidade
- NotificationBell expandida com orçamentos e pré-lançamentos
- Remove logout-button, header-dashboard e privacy-mode-toggle como componentes separados
- Logo refatorado com variante compact; topbar com links em lowercase
- Adiciona dependência radix-ui ^1.4.3
- Atualiza CHANGELOG para v1.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-24 15:43:14 +00:00
parent af7dd6f737
commit 1b90be6b54
54 changed files with 1492 additions and 787 deletions

View File

@@ -1,55 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth/client";
import { Spinner } from "../ui/spinner";
export default function LogoutButton() {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleLogOut() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
onRequest: (_ctx) => {
setLoading(true);
},
onResponse: (_ctx) => {
setLoading(false);
},
},
});
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
aria-busy={loading}
data-loading={loading}
onClick={handleLogOut}
disabled={loading}
className="text-destructive transition-all duration-200 border hover:text-destructive focus-visible:ring-destructive/30 data-[loading=true]:opacity-90"
>
{loading && <Spinner className="size-3.5 text-destructive" />}
<span aria-live="polite">{loading ? "Saindo" : "Sair"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Encerrar sessão
</TooltipContent>
</Tooltip>
);
}

View File

@@ -31,7 +31,7 @@ type CalculatorDialogButtonProps = {
onSelectValue?: (value: string) => void;
};
function CalculatorDialogContent({
export function CalculatorDialogContent({
open,
onSelectValue,
}: {

View File

@@ -158,7 +158,7 @@ export function CardItem({
);
return (
<Card className="flex p-6 h-[300px] w-[440px]">
<Card className="flex flex-col p-6 w-full">
<CardHeader className="space-y-2 px-0 pb-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
@@ -209,14 +209,14 @@ export function CardItem({
</div>
{brandAsset ? (
<div className="flex items-center justify-center rounded-lg py-1">
<div className="flex items-center justify-center py-1">
<Image
src={brandAsset}
alt={`Bandeira ${brand}`}
width={42}
height={42}
width={36}
height={36}
className={cn(
"h-6 w-auto rounded-full",
"h-5 w-auto rounded",
isInactive && "grayscale opacity-40",
)}
/>

View File

@@ -125,7 +125,7 @@ export function CardsPage({
}
return (
<div className="flex flex-wrap gap-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{list.map((card) => (
<CardItem
key={card.id}

View File

@@ -1,6 +1,5 @@
"use client";
import { main_font } from "@/public/fonts/font_index";
import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
@@ -54,9 +53,7 @@ export function DashboardWelcome({
const greeting = getGreeting();
return (
<Card
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none overflow-hidden`}
>
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={8}

View File

@@ -270,7 +270,7 @@ export function InvoiceSummaryCard({
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded-full"
className="h-5 w-auto rounded"
/>
<span className="truncate">{cardBrand}</span>
</div>

View File

@@ -64,14 +64,74 @@ const feedbackCategories = [
},
];
export function FeedbackDialog() {
const [open, setOpen] = useState(false);
export function FeedbackDialogBody({ onClose }: { onClose?: () => void }) {
const handleCategoryClick = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
onClose?.();
};
return (
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiMessageLine className="h-5 w-5" />
Enviar Feedback
</DialogTitle>
<DialogDescription>
Sua opinião é muito importante! Escolha o tipo de feedback que
deseja compartilhar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-4">
{feedbackCategories.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => handleCategoryClick(item.url)}
className={cn(
"flex items-start gap-3 p-4 rounded-lg border transition-all",
"hover:border-primary hover:bg-accent/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
)}
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"bg-muted",
)}
>
<Icon className={cn("h-5 w-5", item.color)} />
</div>
<div className="flex-1 text-left space-y-1">
<h3 className="font-semibold text-sm flex items-center gap-2">
{item.title}
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</button>
);
})}
</div>
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Você será redirecionado para o GitHub Discussions onde poderá
escrever seu feedback. É necessário ter uma conta no GitHub.
</p>
</div>
</DialogContent>
);
}
export function FeedbackDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
@@ -93,62 +153,7 @@ export function FeedbackDialog() {
</TooltipTrigger>
<TooltipContent>Enviar Feedback</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiMessageLine className="h-5 w-5" />
Enviar Feedback
</DialogTitle>
<DialogDescription>
Sua opinião é muito importante! Escolha o tipo de feedback que
deseja compartilhar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-4">
{feedbackCategories.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => handleCategoryClick(item.url)}
className={cn(
"flex items-start gap-3 p-4 rounded-lg border transition-all",
"hover:border-primary hover:bg-accent/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
)}
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"bg-muted",
)}
>
<Icon className={cn("h-5 w-5", item.color)} />
</div>
<div className="flex-1 text-left space-y-1">
<h3 className="font-semibold text-sm flex items-center gap-2">
{item.title}
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</button>
);
})}
</div>
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Você será redirecionado para o GitHub Discussions onde poderá
escrever seu feedback. É necessário ter uma conta no GitHub.
</p>
</div>
</DialogContent>
<FeedbackDialogBody onClose={() => setOpen(false)} />
</Dialog>
);
}

View File

@@ -1,39 +0,0 @@
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getUser } from "@/lib/auth/server";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { AnimatedThemeToggler } from "./animated-theme-toggler";
import LogoutButton from "./auth/logout-button";
import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
import { PrivacyModeToggle } from "./privacy-mode-toggle";
import { RefreshPageButton } from "./refresh-page-button";
type SiteHeaderProps = {
notificationsSnapshot: DashboardNotificationsSnapshot;
};
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
const _user = await getUser();
return (
<header className="fixed top-0 left-0 right-0 z-50 border-b bg-background md:static md:z-auto flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
/>
<CalculatorDialogButton withTooltip />
<RefreshPageButton />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>
<FeedbackDialog />
<LogoutButton />
</div>
</div>
</header>
);
}

View File

@@ -46,7 +46,7 @@ export function PaymentMethodSection({
return (
<>
{!isUpdateMode ? (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="flex w-full flex-col gap-2 md:flex-row mt-3">
<div
className={cn(
"space-y-1 w-full",

View File

@@ -41,9 +41,9 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-72">
<SheetHeader>
<SheetTitle>
<Logo />
<SheetHeader className="border-b pb-4">
<SheetTitle asChild>
<Logo variant="compact" />
</SheetTitle>
</SheetHeader>

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
interface LogoProps {
variant?: "full" | "small";
variant?: "full" | "small" | "compact";
className?: string;
showVersion?: boolean;
}
@@ -13,6 +13,29 @@ export function Logo({
className,
showVersion = false,
}: LogoProps) {
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1", className)}>
<Image
src="/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className="object-contain"
priority
/>
<Image
src="/logo_text.png"
alt="OpenMonetis"
width={110}
height={32}
className="object-contain dark:invert hidden sm:block"
priority
/>
</div>
);
}
if (variant === "small") {
return (
<Image
@@ -45,8 +68,8 @@ export function Logo({
priority
/>
{showVersion && (
<span className="text-[10px] font-medium text-muted-foreground">
v{version}
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div>

View File

@@ -79,7 +79,7 @@ export default function MonthNavigation() {
};
return (
<Card className="w-full flex-row bg-card text-card-foreground p-4">
<Card className="w-full flex-row bg-card text-card-foreground p-4 sticky top-14 z-10">
<div className="flex items-center gap-1">
<NavigationButton
direction="left"

View File

@@ -2,17 +2,23 @@
import {
RiAlertFill,
RiArrowRightLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiFileListLine,
RiInboxLine,
RiNotification3Line,
RiTimeLine,
} from "@remixicon/react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -27,26 +33,26 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { DashboardNotification } from "@/lib/dashboard/notifications";
import type {
BudgetNotification,
DashboardNotification,
} from "@/lib/dashboard/notifications";
import { cn } from "@/lib/utils/ui";
type NotificationBellProps = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
};
function formatDate(dateString: string): string {
// Parse manual para evitar problemas de timezone
// Formato esperado: "YYYY-MM-DD"
const [year, month, day] = dateString.split("-").map(Number);
// Criar data em UTC usando os valores diretos
const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
timeZone: "UTC", // Força uso de UTC para evitar conversão de timezone
timeZone: "UTC",
});
}
@@ -57,13 +63,41 @@ function formatCurrency(amount: number): string {
}).format(amount);
}
function SectionLabel({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
<span className="text-muted-foreground/60">{icon}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{title}
</span>
</div>
);
}
export function NotificationBell({
notifications,
totalCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps) {
const [open, setOpen] = useState(false);
const displayCount = totalCount > 99 ? "99+" : totalCount.toString();
const hasNotifications = totalCount > 0;
const effectiveTotalCount =
totalCount + preLancamentosCount + budgetNotifications.length;
const displayCount =
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
const hasNotifications = effectiveTotalCount > 0;
const invoiceNotifications = notifications.filter(
(n) => n.type === "invoice",
);
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -74,7 +108,6 @@ export function NotificationBell({
type="button"
aria-label="Notificações"
aria-expanded={open}
data-has-notifications={hasNotifications}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
@@ -103,23 +136,27 @@ export function NotificationBell({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Pagamentos para os próximos 5 dias.
Notificações
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={12}
className="w-80 max-h-[500px] overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
>
<DropdownMenuLabel className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/60 bg-linear-to-b from-background/95 to-background/80 px-4 py-3 text-sm font-semibold">
<span>Notificações | Próximos 5 dias.</span>
{/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
<span>Notificações</span>
{hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold">
{totalCount} {totalCount === 1 ? "item" : "itens"}
{effectiveTotalCount}{" "}
{effectiveTotalCount === 1 ? "item" : "itens"}
</Badge>
)}
</DropdownMenuLabel>
{notifications.length === 0 ? (
{!hasNotifications ? (
<div className="px-4 py-8">
<Empty>
<EmptyMedia>
@@ -132,71 +169,169 @@ export function NotificationBell({
</Empty>
</div>
) : (
<div className="max-h-[400px] overflow-y-auto py-2">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className={cn(
"group relative flex flex-col gap-2 rounded-none border-b border-dashed last:border-0 p-2.5",
"cursor-default focus:bg-transparent data-highlighted:bg-accent/60",
)}
>
<div className="flex items-start justify-between w-full gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div
className={cn(
"flex items-center justify-center text-sm transition-all duration-200",
)}
>
{notification.status === "overdue" ? (
<RiAlertFill color="red" className="size-4" />
) : (
<RiTimeLine className="size-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{notification.name}
</span>
<Badge
variant="outline"
className="px-1.5 py-0 tracking-wide text-muted-foreground"
>
{notification.type === "invoice"
? "Cartão"
: "Boleto"}
</Badge>
</div>
<div className="mt-1 flex flex-col items-start gap-2 text-xs text-muted-foreground">
<span className="font-no">
{notification.status === "overdue"
? "Venceu em "
: "Vence em "}
{formatDate(notification.dueDate)}
</span>
<div className="max-h-[460px] overflow-y-auto pb-2">
{/* Pré-lançamentos */}
{preLancamentosCount > 0 && (
<div>
<SectionLabel
icon={<RiInboxLine className="size-3" />}
title="Pré-lançamentos"
/>
<Link
href="/pre-lancamentos"
onClick={() => setOpen(false)}
className="group mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-accent/60"
>
<p className="flex-1 text-xs leading-snug text-foreground">
{preLancamentosCount === 1
? "1 pré-lançamento aguardando revisão"
: `${preLancamentosCount} pré-lançamentos aguardando revisão`}
</p>
<RiArrowRightLine className="size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
)}
{notification.showAmount && notification.amount > 0 && (
<span className="font-medium">
{formatCurrency(notification.amount)}
</span>
{/* Orçamentos */}
{budgetNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBarChart2Line className="size-3" />}
title="Orçamentos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{budgetNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "exceeded" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "exceeded" ? (
<>
Orçamento de <strong>{n.categoryName}</strong>{" "}
excedido {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} (
{Math.round(n.usedPercentage)}%)
</>
) : (
<>
<strong>{n.categoryName}</strong> atingiu{" "}
<strong>{Math.round(n.usedPercentage)}%</strong> do
orçamento {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)}
</>
)}
</div>
</p>
</div>
</div>
<Badge
variant={
notification.status === "overdue" ? "destructive" : "info"
}
className={cn("shrink-0 px-2 py-0.5 tracking-wide")}
>
{notification.status === "overdue"
? "Atrasado"
: "Em breve"}
</Badge>
))}
</div>
</DropdownMenuItem>
))}
</div>
)}
{/* Cartão de Crédito */}
{invoiceNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBankCardLine className="size-3" />}
title="Cartão de Crédito"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{invoiceNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "overdue" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiTimeLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
A fatura de <strong>{n.name}</strong> venceu em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
) : (
<>
A fatura de <strong>{n.name}</strong> vence em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
{/* Boletos */}
{boletoNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiFileListLine className="size-3" />}
title="Boletos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{boletoNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "overdue" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiTimeLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
venceu em {formatDate(n.dueDate)}
</>
) : (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
vence em {formatDate(n.dueDate)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</DropdownMenuContent>

View File

@@ -1,78 +0,0 @@
"use client";
import { RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import { usePrivacyMode } from "./privacy-provider";
type PrivacyModeToggleProps = React.ComponentPropsWithoutRef<"button">;
export const PrivacyModeToggle = ({
className,
...props
}: PrivacyModeToggleProps) => {
const { privacyMode, toggle } = usePrivacyMode();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={undefined}
type="button"
onClick={toggle}
aria-pressed={privacyMode}
aria-label={
privacyMode
? "Desativar modo privacidade"
: "Ativar modo privacidade"
}
title={
privacyMode
? "Desativar modo privacidade"
: "Ativar modo privacidade"
}
data-state={privacyMode ? "active" : "inactive"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=active]:bg-accent/60 data-[state=active]:text-foreground border",
className,
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=active]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-blue-500/5 via-transparent to-blue-500/15 dark:from-blue-500/10 dark:to-blue-500/30" />
</span>
{privacyMode ? (
<RiEyeOffLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiEyeLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{privacyMode
? "Modo privacidade ativo"
: "Modo privacidade inativo"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{privacyMode ? "Desativar privacidade" : "Ativar privacidade"}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -45,7 +45,10 @@ export function RefreshPageButton({
{...props}
>
<RiRefreshLine
className={cn("size-4 transition-transform duration-200", isPending && "animate-spin")}
className={cn(
"size-4 transition-transform duration-200",
isPending && "animate-spin",
)}
aria-hidden
/>
</button>

View File

@@ -1,34 +1,135 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import { useMemo } from "react";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
type TooltipProps,
XAxis,
YAxis,
} from "recharts";
import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
function AreaTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null;
const items = payload
.filter((entry) => Number(entry.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
if (items.length === 0) return null;
return (
<div className="min-w-[210px] rounded-lg border border-border/50 bg-background px-3 py-2.5 shadow-xl">
<p className="mb-2.5 border-b border-border/50 pb-1.5 text-xs font-semibold text-foreground">
{label}
</p>
<div className="space-y-1.5">
{items.map((entry) => (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-6"
>
<div className="flex min-w-0 items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
{currencyFormatter.format(Number(entry.value))}
</span>
</div>
))}
</div>
</div>
);
}
interface CategoryReportChartProps {
data: CategoryChartData;
}
const CHART_COLORS = CATEGORY_COLORS;
const LIMIT_OPTIONS = [
{ value: "5", label: "Top 5" },
{ value: "10", label: "Top 10" },
{ value: "15", label: "Top 15" },
] as const;
const MAX_CATEGORIES_IN_CHART = 15;
const MAX_CATEGORIES = 15;
export function CategoryReportChart({ data }: CategoryReportChartProps) {
const { chartData, categories } = data;
const [limit, setLimit] = React.useState("10");
const { topCategories, filteredChartData } = React.useMemo(() => {
const limitNum = Math.min(Number(limit), MAX_CATEGORIES);
const categoriesWithTotal = categories.map((category) => ({
...category,
total: chartData.reduce((sum, point) => {
const v = point[category.name];
return sum + (typeof v === "number" ? v : 0);
}, 0),
}));
const sorted = categoriesWithTotal
.sort((a, b) => b.total - a.total)
.slice(0, limitNum);
const filtered = chartData.map((point) => {
const result: { month: string; [key: string]: number | string } = {
month: point.month,
};
for (const cat of sorted) {
result[cat.name] = (point[cat.name] as number) ?? 0;
}
return result;
});
return { topCategories: sorted, filteredChartData: filtered };
}, [categories, chartData, limit]);
const chartConfig = React.useMemo<ChartConfig>(() => {
const config: ChartConfig = {};
for (let i = 0; i < topCategories.length; i++) {
const cat = topCategories[i];
config[cat.name] = {
label: cat.name,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
};
}
return config;
}, [topCategories]);
// Check if there's no data
if (categories.length === 0 || chartData.length === 0) {
return (
<EmptyState
@@ -40,165 +141,91 @@ export function CategoryReportChart({ data }: CategoryReportChartProps) {
);
}
// Get top 10 categories by total spending
const { topCategories, filteredChartData } = useMemo(() => {
// Calculate total for each category across all periods
const categoriesWithTotal = categories.map((category) => {
const total = chartData.reduce((sum, dataPoint) => {
const value = dataPoint[category.name];
return sum + (typeof value === "number" ? value : 0);
}, 0);
return { ...category, total };
});
// Sort by total (descending) and take top 10
const sorted = categoriesWithTotal
.sort((a, b) => b.total - a.total)
.slice(0, MAX_CATEGORIES_IN_CHART);
// Filter chartData to include only top categories
const _topCategoryNames = new Set(sorted.map((cat) => cat.name));
const filtered = chartData.map((dataPoint) => {
const filteredPoint: { month: string; [key: string]: number | string } = {
month: dataPoint.month,
};
// Only include data for top categories
for (const cat of sorted) {
if (dataPoint[cat.name] !== undefined) {
filteredPoint[cat.name] = dataPoint[cat.name];
}
}
return filteredPoint;
});
return { topCategories: sorted, filteredChartData: filtered };
}, [categories, chartData]);
const firstMonth = chartData[0]?.month ?? "";
const lastMonth = chartData[chartData.length - 1]?.month ?? "";
const periodLabel =
firstMonth === lastMonth ? firstMonth : `${firstMonth} ${lastMonth}`;
return (
<Card>
<CardHeader>
<CardTitle>
Evolução por Categoria - Top {topCategories.length}
</CardTitle>
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Evolução por Categoria</CardTitle>
<CardDescription>{periodLabel}</CardDescription>
</div>
<Select value={limit} onValueChange={setLimit}>
<SelectTrigger
className="hidden w-[130px] rounded-lg sm:ml-auto sm:flex"
aria-label="Número de categorias"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
{LIMIT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={filteredChartData}>
<defs>
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<linearGradient
key={category.id}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="month"
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => {
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value.toString();
}}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<div className="mb-2 font-semibold">
{payload[0]?.payload?.month}
</div>
<div className="space-y-1">
{payload.map((entry, index) => {
if (entry.dataKey === "month") return null;
return (
<div
key={index}
className="flex items-center justify-between gap-4 text-sm"
>
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">
{entry.name}
</span>
</div>
<span className="font-medium">
{currencyFormatter.format(
Number(entry.value) || 0,
)}
</span>
</div>
);
})}
</div>
</div>
);
}}
/>
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[300px] w-full"
>
<AreaChart data={filteredChartData}>
<defs>
{topCategories.map((cat, index) => {
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
return (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={color}
strokeWidth={2}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
/>
<linearGradient
key={cat.id}
id={`fill-${cat.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
</defs>
{/* Legend */}
<div className="mt-4 flex flex-wrap gap-4">
{topCategories.map((category, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<div key={category.id} className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-muted-foreground">
{category.name}
</span>
</div>
);
})}
</div>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip cursor={false} content={<AreaTooltip />} />
{topCategories.map((cat, index) => (
<Area
key={cat.id}
dataKey={cat.name}
type="natural"
fill={`url(#fill-${cat.id})`}
stroke={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
strokeWidth={1.5}
stackId="a"
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);

View File

@@ -1,12 +1,9 @@
import Image from "next/image";
import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { PrivacyModeToggle } from "@/components/privacy-mode-toggle";
import { RefreshPageButton } from "@/components/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { Logo } from "../logo";
import { TopNavMenu } from "./top-nav-menu";
import { TopbarUser } from "./topbar-user";
@@ -29,49 +26,26 @@ export function AppTopbar({
notificationsSnapshot,
}: AppTopbarProps) {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-card h-14 shrink-0 flex items-center shadow-xs">
<header className="fixed top-0 left-0 right-0 z-50 h-15 shrink-0 flex items-center bg-card/80 backdrop-blur-md supports-backdrop-filter:bg-card/70">
<div className="w-full max-w-8xl mx-auto px-4 flex items-center gap-4 h-full">
{/* Logo */}
<Link
href="/dashboard"
className="flex items-center gap-2 shrink-0 mr-1"
>
<Image
src="/logo_small.png"
alt="OpenMonetis"
width={28}
height={28}
className="object-contain"
priority
/>
<Image
src="/logo_text.png"
alt="OpenMonetis"
width={90}
height={28}
className="object-contain dark:invert hidden sm:block"
priority
/>
<Link href="/dashboard" className="shrink-0 mr-1">
<Logo variant="compact" />
</Link>
{/* Navigation */}
<TopNavMenu preLancamentosCount={preLancamentosCount} />
<TopNavMenu />
{/* Right-side actions */}
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<CalculatorDialogButton withTooltip />
<RefreshPageButton />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span
aria-hidden
className="h-5 w-px bg-foreground/20 mx-1 hidden sm:block"
/>
<FeedbackDialog />
</div>
{/* User avatar */}

View File

@@ -0,0 +1,105 @@
"use client";
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { useState } from "react";
import { CalculatorDialogContent } from "@/components/calculadora/calculator-dialog";
import { usePrivacyMode } from "@/components/privacy-provider";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { cn } from "@/lib/utils/ui";
const itemClass =
"flex w-full items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer";
export function FerramentasDropdownContent() {
const { privacyMode, toggle } = usePrivacyMode();
const [calcOpen, setCalcOpen] = useState(false);
return (
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
<ul className="grid w-52 gap-0.5 p-2">
<li>
<DialogTrigger asChild>
<button type="button" className={cn(itemClass)}>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
</DialogTrigger>
</li>
<li>
<button type="button" onClick={toggle} className={cn(itemClass)}>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4"
>
Ativo
</Badge>
)}
</button>
</li>
</ul>
<CalculatorDialogContent open={calcOpen} />
</Dialog>
);
}
type MobileFerramentasItemsProps = {
onClose: () => void;
};
export function MobileFerramentasItems({
onClose,
}: MobileFerramentasItemsProps) {
const { privacyMode, toggle } = usePrivacyMode();
const [calcOpen, setCalcOpen] = useState(false);
return (
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
<DialogTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
</DialogTrigger>
<button
type="button"
onClick={() => {
toggle();
onClose();
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
Ativo
</Badge>
)}
</button>
<CalculatorDialogContent open={calcOpen} />
</Dialog>
);
}

View File

@@ -33,33 +33,32 @@ import {
} from "@/components/ui/sheet";
import type { DropdownLinkItem } from "./dropdown-link-list";
import { DropdownLinkList } from "./dropdown-link-list";
import {
FerramentasDropdownContent,
MobileFerramentasItems,
} from "./ferramentas-dropdown";
import { MobileNavLink, MobileSectionLabel } from "./mobile-nav-link";
import { triggerClass } from "./nav-styles";
import { SimpleNavLink } from "./simple-nav-link";
type TopNavMenuProps = {
preLancamentosCount?: number;
};
export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
export function TopNavMenu() {
const [sheetOpen, setSheetOpen] = useState(false);
const close = () => setSheetOpen(false);
const lancamentosItems: DropdownLinkItem[] = [
{
href: "/lancamentos",
label: "Lançamentos",
label: "lançamentos",
icon: <RiArrowLeftRightLine className="size-4" />,
},
{
href: "/pre-lancamentos",
label: "Pré-Lançamentos",
label: "pré-lançamentos",
icon: <RiInboxLine className="size-4" />,
badge: preLancamentosCount,
},
{
href: "/calendario",
label: "Calendário",
label: "calendário",
icon: <RiCalendarEventLine className="size-4" />,
},
];
@@ -67,17 +66,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const financasItems: DropdownLinkItem[] = [
{
href: "/cartoes",
label: "Cartões",
label: "cartões",
icon: <RiBankCard2Line className="size-4" />,
},
{
href: "/contas",
label: "Contas",
label: "contas",
icon: <RiBankLine className="size-4" />,
},
{
href: "/orcamentos",
label: "Orçamentos",
label: "orçamentos",
icon: <RiFundsLine className="size-4" />,
},
];
@@ -85,17 +84,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const organizacaoItems: DropdownLinkItem[] = [
{
href: "/pagadores",
label: "Pagadores",
label: "pagadores",
icon: <RiGroupLine className="size-4" />,
},
{
href: "/categorias",
label: "Categorias",
label: "categorias",
icon: <RiPriceTag3Line className="size-4" />,
},
{
href: "/anotacoes",
label: "Anotações",
label: "anotações",
icon: <RiTodoLine className="size-4" />,
},
];
@@ -103,17 +102,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const analiseItems: DropdownLinkItem[] = [
{
href: "/insights",
label: "Insights",
label: "insights",
icon: <RiSparklingLine className="size-4" />,
},
{
href: "/relatorios/tendencias",
label: "Tendências",
label: "tendências",
icon: <RiFileChartLine className="size-4" />,
},
{
href: "/relatorios/uso-cartoes",
label: "Uso de Cartões",
label: "uso de cartões",
icon: <RiBankCard2Line className="size-4" />,
},
];
@@ -157,12 +156,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
<NavigationMenuItem>
<NavigationMenuTrigger className={triggerClass}>
Análise
Relatórios
</NavigationMenuTrigger>
<NavigationMenuContent>
<DropdownLinkList items={analiseItems} />
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className={triggerClass}>
Ferramentas
</NavigationMenuTrigger>
<NavigationMenuContent>
<FerramentasDropdownContent />
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</nav>
@@ -198,22 +206,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiArrowLeftRightLine className="size-4" />}
onClick={close}
>
Lançamentos
lançamentos
</MobileNavLink>
<MobileNavLink
href="/pre-lancamentos"
icon={<RiInboxLine className="size-4" />}
onClick={close}
badge={preLancamentosCount}
>
P-Lançamentos
p-lançamentos
</MobileNavLink>
<MobileNavLink
href="/calendario"
icon={<RiCalendarEventLine className="size-4" />}
onClick={close}
>
Calendário
calendário
</MobileNavLink>
<MobileSectionLabel label="Finanças" />
@@ -222,21 +229,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiBankCard2Line className="size-4" />}
onClick={close}
>
Cartões
cartões
</MobileNavLink>
<MobileNavLink
href="/contas"
icon={<RiBankLine className="size-4" />}
onClick={close}
>
Contas
contas
</MobileNavLink>
<MobileNavLink
href="/orcamentos"
icon={<RiFundsLine className="size-4" />}
onClick={close}
>
Orçamentos
orçamentos
</MobileNavLink>
<MobileSectionLabel label="Organização" />
@@ -245,21 +252,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiGroupLine className="size-4" />}
onClick={close}
>
Pagadores
pagadores
</MobileNavLink>
<MobileNavLink
href="/categorias"
icon={<RiPriceTag3Line className="size-4" />}
onClick={close}
>
Categorias
categorias
</MobileNavLink>
<MobileNavLink
href="/anotacoes"
icon={<RiTodoLine className="size-4" />}
onClick={close}
>
Anotações
anotações
</MobileNavLink>
<MobileSectionLabel label="Análise" />
@@ -268,22 +275,25 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiSparklingLine className="size-4" />}
onClick={close}
>
Insights
insights
</MobileNavLink>
<MobileNavLink
href="/relatorios/tendencias"
icon={<RiFileChartLine className="size-4" />}
onClick={close}
>
Tendências
tendências
</MobileNavLink>
<MobileNavLink
href="/relatorios/uso-cartoes"
icon={<RiBankCard2Line className="size-4" />}
onClick={close}
>
Uso de Cartões
uso de cartões
</MobileNavLink>
<MobileSectionLabel label="Ferramentas" />
<MobileFerramentasItems onClose={close} />
</nav>
</SheetContent>
</Sheet>

View File

@@ -1,10 +1,16 @@
"use client";
import { RiSettings2Line } from "@remixicon/react";
import {
RiLogoutCircleLine,
RiMessageLine,
RiSettings2Line,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import LogoutButton from "@/components/auth/logout-button";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,7 +18,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { authClient } from "@/lib/auth/client";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
const itemClass =
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent";
type TopbarUserProps = {
user: {
@@ -25,58 +38,109 @@ type TopbarUserProps = {
};
export function TopbarUser({ user, pagadorAvatarUrl }: TopbarUserProps) {
const router = useRouter();
const [logoutLoading, setLogoutLoading] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const avatarSrc = useMemo(() => {
if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl);
if (user.image) return user.image;
return getAvatarSrc(null);
}, [user.image, pagadorAvatarUrl]);
async function handleLogout() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => router.push("/login"),
onRequest: () => setLogoutLoading(true),
onResponse: () => setLogoutLoading(false),
},
});
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center rounded-full ring-2 ring-foreground/30 hover:ring-foreground/60 transition-all focus-visible:outline-none focus-visible:ring-foreground"
>
<Image
src={avatarSrc}
alt={user.name}
width={32}
height={32}
className="size-8 rounded-full object-cover"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
<Image
src={avatarSrc}
alt={user.name}
width={36}
height={36}
className="size-9 rounded-full object-cover shrink-0"
/>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="text-xs text-muted-foreground truncate">
{user.email}
<Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg"
aria-label="Menu do usuário"
>
<Image
src={avatarSrc}
alt={`Avatar de ${user.name}`}
width={40}
height={40}
className="size-10 rounded-full object-cover"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
<Image
src={avatarSrc}
alt={user.name}
width={36}
height={36}
className="size-9 rounded-full object-cover shrink-0"
/>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="text-xs text-muted-foreground truncate">
{user.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-0.5 py-1">
<Link href="/ajustes" className={cn(itemClass, "text-foreground")}>
<RiSettings2Line className="size-4 text-muted-foreground shrink-0" />
Ajustes
</Link>
<DialogTrigger asChild>
<button
type="button"
className={cn(itemClass, "text-foreground")}
>
<RiMessageLine className="size-4 text-muted-foreground shrink-0" />
Enviar Feedback
</button>
</DialogTrigger>
</div>
<DropdownMenuSeparator />
<div className="py-1">
<button
type="button"
onClick={handleLogout}
disabled={logoutLoading}
aria-busy={logoutLoading}
className={cn(
itemClass,
"text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-60",
)}
>
{logoutLoading ? (
<Spinner className="size-4 shrink-0" />
) : (
<RiLogoutCircleLine className="size-4 shrink-0" />
)}
{logoutLoading ? "Saindo..." : "Sair"}
</button>
</div>
<DropdownMenuSeparator />
<div className="px-3 py-1.5">
<span className="text-[10px] font-mono text-muted-foreground/40 select-none">
Versão {version}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 pt-1">
<Link
href="/ajustes"
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<RiSettings2Line className="size-4 text-muted-foreground" />
Ajustes
</Link>
<div className="px-1 py-0.5">
<LogoutButton />
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
<FeedbackDialogBody onClose={() => setFeedbackOpen(false)} />
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { RiArrowDownBoxFill } from "@remixicon/react";
import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority";
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react";
@@ -73,8 +73,8 @@ function NavigationMenuTrigger({
{...props}
>
{children}{" "}
<RiArrowDownBoxFill
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
<RiArrowDropDownLine
className="relative top-px ml-1 size-4 text-primary transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>