feat(v1.5.0): customização de fontes e correção de cores em tendências

Adiciona sistema de customização de fontes por usuário via CSS custom
properties, com preview ao vivo e persistência no banco. Corrige lógica
de cores invertida na tabela de receitas em tendências.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-15 21:35:00 +00:00
parent 4b442a907a
commit 2362a70b9d
25 changed files with 2779 additions and 70 deletions

View File

@@ -1,11 +1,12 @@
"use server";
import { createHash, randomBytes } from "node:crypto";
import { verifyPassword } from "better-auth/crypto";
import { and, eq, isNull, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { pagadores, tokensApi } from "@/db/schema";
import { account, pagadores, tokensApi } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
@@ -51,8 +52,26 @@ const deleteAccountSchema = z.object({
}),
});
const VALID_FONTS = [
"ai-sans",
"anthropic-sans",
"fira-code",
"fira-sans",
"geist",
"ibm-plex-mono",
"inter",
"jetbrains-mono",
"reddit-sans",
"roboto",
"sf-pro-display",
"sf-pro-rounded",
"ubuntu",
] as const;
const updatePreferencesSchema = z.object({
disableMagnetlines: z.boolean(),
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
});
// Actions
@@ -235,19 +254,32 @@ export async function updateEmailAction(
};
}
// Validar senha tentando fazer changePassword para a mesma senha
// Se falhar, a senha atual está incorreta
try {
await auth.api.changePassword({
body: {
newPassword: validated.password,
currentPassword: validated.password,
},
headers: await headers(),
});
} catch (authError: any) {
// Se der erro é porque a senha está incorreta
console.error("Erro ao validar senha:", authError);
// Buscar hash da senha no registro de credencial
const credentialAccount = await db
.select({ password: account.password })
.from(account)
.where(
and(
eq(account.userId, session.user.id),
eq(account.providerId, "credential"),
),
)
.limit(1);
const storedHash = credentialAccount[0]?.password;
if (!storedHash) {
return {
success: false,
error: "Conta de credencial não encontrada.",
};
}
const isValid = await verifyPassword({
password: validated.password,
hash: storedHash,
});
if (!isValid) {
return {
success: false,
error: "Senha incorreta",
@@ -385,6 +417,8 @@ export async function updatePreferencesAction(
.update(schema.preferenciasUsuario)
.set({
disableMagnetlines: validated.disableMagnetlines,
systemFont: validated.systemFont,
moneyFont: validated.moneyFont,
updatedAt: new Date(),
})
.where(eq(schema.preferenciasUsuario.userId, session.user.id));
@@ -393,6 +427,8 @@ export async function updatePreferencesAction(
await db.insert(schema.preferenciasUsuario).values({
userId: session.user.id,
disableMagnetlines: validated.disableMagnetlines,
systemFont: validated.systemFont,
moneyFont: validated.moneyFont,
});
}

View File

@@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db";
export interface UserPreferences {
disableMagnetlines: boolean;
systemFont: string;
moneyFont: string;
}
export interface ApiToken {
@@ -30,6 +32,8 @@ export async function fetchUserPreferences(
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
systemFont: schema.preferenciasUsuario.systemFont,
moneyFont: schema.preferenciasUsuario.moneyFont,
})
.from(schema.preferenciasUsuario)
.where(eq(schema.preferenciasUsuario.userId, userId))

View File

@@ -61,6 +61,8 @@ export default async function Page() {
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
systemFont={userPreferences?.systemFont ?? "ai-sans"}
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
/>
</div>
</Card>

View File

@@ -1,3 +1,4 @@
import { FontProvider } from "@/components/font-provider";
import { SiteHeader } from "@/components/header-dashboard";
import { PrivacyProvider } from "@/components/privacy-provider";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
@@ -6,6 +7,7 @@ import { getUserSession } from "@/lib/auth/server";
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { fetchUserFontPreferences } from "@/lib/preferences/fonts";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
@@ -41,35 +43,43 @@ export default async function DashboardLayout({
currentPeriod,
);
// Buscar contagem de pré-lançamentos pendentes
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
// Buscar contagem de pré-lançamentos pendentes e preferências de fonte
const [preLancamentosCount, fontPrefs] = await Promise.all([
fetchPendingInboxCount(session.user.id),
fetchUserFontPreferences(session.user.id),
]);
return (
<PrivacyProvider>
<SidebarProvider>
<AppSidebar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
pagadores={pagadoresList.map((item) => ({
id: item.id,
name: item.name,
avatarUrl: item.avatarUrl,
canEdit: item.canEdit,
}))}
preLancamentosCount={preLancamentosCount}
variant="sidebar"
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}
<FontProvider
systemFont={fontPrefs.systemFont}
moneyFont={fontPrefs.moneyFont}
>
<PrivacyProvider>
<SidebarProvider>
<AppSidebar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
pagadores={pagadoresList.map((item) => ({
id: item.id,
name: item.name,
avatarUrl: item.avatarUrl,
canEdit: item.canEdit,
}))}
preLancamentosCount={preLancamentosCount}
variant="sidebar"
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}
</div>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</PrivacyProvider>
</SidebarInset>
</SidebarProvider>
</PrivacyProvider>
</FontProvider>
);
}