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,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<HTMLFormElement>) => {
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 (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção 1: Tipografia */}
<section className="space-y-5">
<div>
<h3 className="text-base font-semibold">Tipografia</h3>
<p className="text-sm text-muted-foreground">
Personalize as fontes usadas na interface e nos valores monetários.
</p>
</div>
{/* Fonte do sistema */}
<div className="space-y-2 max-w-md">
<Label htmlFor="system-font">Fonte do sistema</Label>
<Select
value={selectedSystemFont}
onValueChange={setSelectedSystemFont}
>
<SelectTrigger id="system-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key}>
<span
style={{
fontFamily: opt.variable,
}}
>
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p
className="text-sm text-muted-foreground pt-1"
style={{
fontFamily: getFontVariable(selectedSystemFont),
}}
>
Suas finanças em um lugar
</p>
</div>
{/* Fonte de valores */}
<div className="space-y-2 max-w-md">
<Label htmlFor="money-font">Fonte de valores</Label>
<Select
value={selectedMoneyFont}
onValueChange={setSelectedMoneyFont}
>
<SelectTrigger id="money-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key}>
<span
style={{
fontFamily: opt.variable,
}}
>
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p
className="text-sm text-muted-foreground pt-1 tabular-nums"
style={{
fontFamily: getFontVariable(selectedMoneyFont),
}}
>
R$ 1.234,56
</p>
</div>
</section>
<div className="border-b" />
{/* Seção 3: Dashboard */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Dashboard</h3>
<p className="text-sm text-muted-foreground">
Opções que afetam a experiência no painel principal.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<Switch
@@ -60,7 +173,7 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
disabled={isPending}
/>
</div>
</div>
</section>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">

View File

@@ -0,0 +1,80 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { getFontVariable } from "@/public/fonts/font_index";
type FontContextValue = {
systemFont: string;
moneyFont: string;
setSystemFont: (key: string) => void;
setMoneyFont: (key: string) => void;
};
const FontContext = createContext<FontContextValue | null>(null);
export function FontProvider({
systemFont: initialSystemFont,
moneyFont: initialMoneyFont,
children,
}: {
systemFont: string;
moneyFont: string;
children: React.ReactNode;
}) {
const [systemFont, setSystemFontState] = useState(initialSystemFont);
const [moneyFont, setMoneyFontState] = useState(initialMoneyFont);
const applyFontVars = useCallback((sys: string, money: string) => {
document.documentElement.style.setProperty(
"--font-app",
getFontVariable(sys),
);
document.documentElement.style.setProperty(
"--font-money",
getFontVariable(money),
);
}, []);
useEffect(() => {
applyFontVars(systemFont, moneyFont);
}, [systemFont, moneyFont, applyFontVars]);
const setSystemFont = useCallback((key: string) => {
setSystemFontState(key);
}, []);
const setMoneyFont = useCallback((key: string) => {
setMoneyFontState(key);
}, []);
const value = useMemo(
() => ({ systemFont, moneyFont, setSystemFont, setMoneyFont }),
[systemFont, moneyFont, setSystemFont, setMoneyFont],
);
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
}}
/>
<FontContext value={value}>{children}</FontContext>
</>
);
}
export function useFont() {
const ctx = useContext(FontContext);
if (!ctx) {
throw new Error("useFont must be used within FontProvider");
}
return ctx;
}

View File

@@ -1,7 +1,6 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { money_font } from "@/public/fonts/font_index";
import { usePrivacyMode } from "./privacy-provider";
type Props = {
@@ -24,8 +23,8 @@ function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
return (
<span
style={{ fontFamily: "var(--font-money)" }}
className={cn(
money_font.className,
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
privacyMode &&
"blur-[6px] select-none hover:blur-none focus-within:blur-none",

View File

@@ -32,6 +32,13 @@ export function CategoryCell({
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
// Receita: aumento é bom (verde), diminuição é ruim (vermelho)
const isPositive =
categoryType === "receita" ? isIncrease : isDecrease;
const isNegative =
categoryType === "receita" ? isDecrease : isIncrease;
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -41,8 +48,8 @@ export function CategoryCell({
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isIncrease && "text-destructive",
isDecrease && "text-success",
isNegative && "text-destructive",
isPositive && "text-success",
)}
>
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
@@ -63,8 +70,8 @@ export function CategoryCell({
<div
className={cn(
"font-medium",
isIncrease && "text-destructive",
isDecrease && "text-success",
isNegative && "text-destructive",
isPositive && "text-success",
)}
>
Diferença:{" "}