From 2362a70b9deb59178c2b1ccbeac7b7ac8339aca9 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sun, 15 Feb 2026 21:35:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(v1.5.0):=20customiza=C3=A7=C3=A3o=20de=20f?= =?UTF-8?q?ontes=20e=20corre=C3=A7=C3=A3o=20de=20cores=20em=20tend=C3=AAnc?= =?UTF-8?q?ias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 19 + app/(dashboard)/ajustes/actions.ts | 64 +- app/(dashboard)/ajustes/data.ts | 4 + app/(dashboard)/ajustes/page.tsx | 2 + app/(dashboard)/layout.tsx | 62 +- app/globals.css | 10 +- app/layout.tsx | 9 +- components/ajustes/preferences-form.tsx | 139 +- components/font-provider.tsx | 80 + components/money-values.tsx | 3 +- components/relatorios/category-cell.tsx | 15 +- db/schema.ts | 2 + drizzle/0016_complete_randall.sql | 2 + drizzle/meta/0016_snapshot.json | 2191 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/preferences/fonts.ts | 33 + public/fonts/SF-Pro-Display-Bold.otf | Bin 0 -> 2298456 bytes public/fonts/SF-Pro-Display-Medium.otf | Bin 0 -> 2320104 bytes public/fonts/SF-Pro-Display-Regular.otf | Bin 0 -> 2230364 bytes public/fonts/SF-Pro-Display-Semibold.otf | Bin 0 -> 2325324 bytes public/fonts/SF-Pro-Rounded-Bold.otf | Bin 0 -> 2383476 bytes public/fonts/SF-Pro-Rounded-Medium.otf | Bin 0 -> 2406796 bytes public/fonts/SF-Pro-Rounded-Regular.otf | Bin 0 -> 2290984 bytes public/fonts/SF-Pro-Rounded-Semibold.otf | Bin 0 -> 2410352 bytes public/fonts/font_index.ts | 207 +- 25 files changed, 2779 insertions(+), 70 deletions(-) create mode 100644 components/font-provider.tsx create mode 100644 drizzle/0016_complete_randall.sql create mode 100644 drizzle/meta/0016_snapshot.json create mode 100644 lib/preferences/fonts.ts create mode 100644 public/fonts/SF-Pro-Display-Bold.otf create mode 100644 public/fonts/SF-Pro-Display-Medium.otf create mode 100644 public/fonts/SF-Pro-Display-Regular.otf create mode 100644 public/fonts/SF-Pro-Display-Semibold.otf create mode 100644 public/fonts/SF-Pro-Rounded-Bold.otf create mode 100644 public/fonts/SF-Pro-Rounded-Medium.otf create mode 100644 public/fonts/SF-Pro-Rounded-Regular.otf create mode 100644 public/fonts/SF-Pro-Rounded-Semibold.otf diff --git a/CHANGELOG.md b/CHANGELOG.md index d63bdea..2cf5b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [1.5.0] - 2026-02-15 + +### Adicionado + +- Customização de fontes nas preferências — fonte da interface e fonte de valores monetários configuráveis por usuário +- 13 fontes disponíveis: AI Sans, Anthropic Sans, SF Pro Display, SF Pro Rounded, Inter, Geist Sans, Roboto, Reddit Sans, Fira Sans, Ubuntu, JetBrains Mono, Fira Code, IBM Plex Mono +- FontProvider com preview ao vivo — troca de fonte aplica instantaneamente via CSS variables, sem necessidade de reload +- Fontes Apple SF Pro (Display e Rounded) carregadas localmente com 4 pesos (Regular, Medium, Semibold, Bold) +- Colunas `system_font` e `money_font` na tabela `preferencias_usuario` + +### Corrigido + +- Cores de variação invertidas na tabela de receitas em `/relatorios/tendencias` — aumento agora é verde (bom) e diminuição é vermelho (ruim), consistente com a semântica de receita + +### Alterado + +- Sistema de fontes migrado de className direto para CSS custom properties (`--font-app`, `--font-money`) via `@theme inline` +- MoneyValues usa `var(--font-money)` em vez de classe fixa, permitindo customização + ## [1.4.1] - 2026-02-15 ### Adicionado diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index fe40b1b..e45143e 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -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, }); } diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts index 1c72cb8..84ed107 100644 --- a/app/(dashboard)/ajustes/data.ts +++ b/app/(dashboard)/ajustes/data.ts @@ -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)) diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 21f2d0f..1152af3 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -61,6 +61,8 @@ export default async function Page() { disableMagnetlines={ userPreferences?.disableMagnetlines ?? false } + systemFont={userPreferences?.systemFont ?? "ai-sans"} + moneyFont={userPreferences?.moneyFont ?? "ai-sans"} /> diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index edca242..1773a8b 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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 ( - - - ({ - id: item.id, - name: item.name, - avatarUrl: item.avatarUrl, - canEdit: item.canEdit, - }))} - preLancamentosCount={preLancamentosCount} - variant="sidebar" - /> - - -
-
-
- {children} + + + + ({ + id: item.id, + name: item.name, + avatarUrl: item.avatarUrl, + canEdit: item.canEdit, + }))} + preLancamentosCount={preLancamentosCount} + variant="sidebar" + /> + + +
+
+
+ {children} +
-
- - - + + + + ); } diff --git a/app/globals.css b/app/globals.css index e5d75c1..099fe53 100644 --- a/app/globals.css +++ b/app/globals.css @@ -7,6 +7,10 @@ } :root { + /* Font customization */ + --font-app: var(--font-ai-sans); + --font-money: var(--font-ai-sans); + /* Base surfaces - warm cream with subtle orange undertone */ --background: oklch(96.563% 0.00504 67.275); --foreground: oklch(18% 0.02 45); @@ -71,7 +75,7 @@ --sidebar-ring: oklch(69.18% 0.18855 38.353); /* Layout */ - --radius: 0.8rem; + --radius: 1rem; /* Shadows - warm tinted for cohesion */ --shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04); @@ -160,7 +164,7 @@ --sidebar-ring: oklch(69.18% 0.18855 38.353); /* Layout */ - --radius: 0.8rem; + --radius: 1rem; /* Shadows - deeper for dark mode */ --shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3); @@ -186,6 +190,8 @@ } @theme inline { + --default-font-family: var(--font-app); + --default-mono-font-family: var(--font-money); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); diff --git a/app/layout.tsx b/app/layout.tsx index 434e6bf..5c82b09 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; -import { main_font } from "@/public/fonts/font_index"; +import { allFontVariables } from "@/public/fonts/font_index"; import "./globals.css"; export const metadata: Metadata = { @@ -17,14 +17,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - + {children} diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx index 839def6..454617c 100644 --- a/components/ajustes/preferences-form.tsx +++ b/components/ajustes/preferences-form.tsx @@ -1,22 +1,51 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions"; +import { useFont } from "@/components/font-provider"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index"; interface PreferencesFormProps { disableMagnetlines: boolean; + systemFont: string; + moneyFont: string; } -export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) { +export function PreferencesForm({ + disableMagnetlines, + systemFont: initialSystemFont, + moneyFont: initialMoneyFont, +}: PreferencesFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [magnetlinesDisabled, setMagnetlinesDisabled] = useState(disableMagnetlines); + const [selectedSystemFont, setSelectedSystemFont] = + useState(initialSystemFont); + const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont); + + const fontCtx = useFont(); + + // Live preview: update CSS vars when font selection changes + useEffect(() => { + fontCtx.setSystemFont(selectedSystemFont); + }, [selectedSystemFont, fontCtx.setSystemFont]); + + useEffect(() => { + fontCtx.setMoneyFont(selectedMoneyFont); + }, [selectedMoneyFont, fontCtx.setMoneyFont]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -24,16 +53,13 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) { startTransition(async () => { const result = await updatePreferencesAction({ disableMagnetlines: magnetlinesDisabled, + systemFont: selectedSystemFont, + moneyFont: selectedMoneyFont, }); if (result.success) { toast.success(result.message); - // Recarregar a página para aplicar as mudanças nos componentes router.refresh(); - // Forçar reload completo para garantir que os hooks re-executem - setTimeout(() => { - window.location.reload(); - }, 500); } else { toast.error(result.error); } @@ -41,16 +67,103 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) { }; return ( -
-
-
+ + {/* Seção 1: Tipografia */} +
+
+

Tipografia

+

+ Personalize as fontes usadas na interface e nos valores monetários. +

+
+ + {/* Fonte do sistema */} +
+ + +

+ Suas finanças em um só lugar +

+
+ + {/* Fonte de valores */} +
+ + +

+ R$ 1.234,56 +

+
+
+ +
+ + {/* Seção 3: Dashboard */} +
+
+

Dashboard

+

+ Opções que afetam a experiência no painel principal. +

+
+ +

- Remove o recurso de linhas magnéticas do sistema. Essa mudança - afeta a interface e interações visuais. + Remove o recurso de linhas magnéticas do sistema.

-
+