O Planej.ai é uma aplicação web de planejamento financeiro pessoal. O usuário preenche um formulário com informações sobre sua renda, gastos e uma meta financeira (como uma viagem ou a compra de um bem), e a aplicação usa inteligência artificial para gerar um diagnóstico personalizado com sugestões práticas, ideias de renda extra e um plano de ação.
Tudo funciona diretamente no navegador: sem backend, sem banco de dados remoto. Os dados são salvos no localStorage e as análises são geradas em tempo real pela API do Google Gemini.
| Pacote | Versão | Finalidade |
|---|---|---|
react |
^19.2.4 | Biblioteca principal de UI |
react-dom |
^19.2.4 | Renderização React no DOM |
react-router-dom |
^7.13.2 | Roteamento client-side (SPA) |
tailwindcss |
^4.2.2 | Framework de CSS utilitário |
@tailwindcss/vite |
^4.2.2 | Plugin Tailwind para Vite |
@fontsource/inter |
^5.2.8 | Fonte Inter auto-hospedada |
lucide-react |
^1.5.0 | Biblioteca de ícones SVG |
react-loading-skeleton |
^3.5.0 | Skeletons de carregamento |
| Pacote | Versão | Finalidade |
|---|---|---|
vite |
^8.0.1 | Build tool e dev server |
typescript |
~5.9.3 | Tipagem estática |
@vitejs/plugin-react |
^6.0.1 | Suporte a React no Vite (Fast Refresh) |
eslint |
^9.39.4 | Linter de código |
prettier |
^3.8.1 | Formatação de código |
eslint-plugin-simple-import-sort |
^12.1.1 | Ordenação automática de imports |
eslint-plugin-unused-imports |
^4.4.1 | Remove imports não utilizados |
prettier-plugin-tailwindcss |
^0.7.2 | Ordenação automática de classes Tailwind |
planejai/
├── public/
│ ├── favicon.svg # Ícone da aba do navegador
│ └── icons.svg # Sprite de ícones SVG
├── src/
│ ├── assets/
│ │ └── images/
│ │ └── piggy-bank.png # Imagem ilustrativa (hero)
│ ├── components/
│ │ ├── features/
│ │ │ ├── Insights/ # Componentes de exibição dos insights da IA
│ │ │ │ ├── Content.tsx
│ │ │ │ └── Error.tsx
│ │ │ ├── Simulation/ # Componentes do formulário multi-step
│ │ │ │ ├── Form.tsx
│ │ │ │ ├── FormStep.tsx
│ │ │ │ ├── Hero.tsx
│ │ │ │ └── Progress.tsx
│ │ │ └── SimulationResults/ # Componentes da página de resultados
│ │ │ ├── AIInsightCardProps.tsx
│ │ │ └── Card.tsx
│ │ ├── layout/
│ │ │ └── RootLayout.tsx # Layout raiz com Header
│ │ └── shared/ # Componentes reutilizáveis
│ │ ├── Button.tsx
│ │ ├── Divider.tsx
│ │ ├── Header.tsx
│ │ ├── Input.tsx
│ │ └── PageHero.tsx
│ ├── context/
│ │ └── theme/
│ │ ├── ThemeContext.tsx # Contexto de tema (claro/escuro)
│ │ └── ThemeProvider.tsx # Provider do contexto de tema
│ ├── data/
│ │ ├── aiPrompt.ts # Montagem do prompt para o Gemini
│ │ └── simulation.ts # Dados e configuração do formulário
│ ├── hooks/
│ │ ├── useInsight.tsx # Hook de chamada à API do Gemini
│ │ ├── useSimulationStorage.tsx # Hook de leitura/escrita no localStorage
│ │ └── useTheme.tsx # Hook de acesso ao contexto de tema
│ ├── pages/
│ │ ├── SimulationFormPage.tsx # Página do formulário
│ │ └── SimulationResultsPage.tsx # Página de resultados
│ ├── services/
│ │ └── aiService.ts # Chamada HTTP à API do Google Gemini
│ ├── styles/
│ │ └── theme.css # Variáveis CSS de tema (claro/escuro)
│ ├── utils/
│ │ ├── currency.ts # Máscara e formatação de moeda
│ │ └── simulation.ts # Utilitários de simulação
│ ├── App.tsx # Componente raiz
│ ├── index.css # Estilos globais e imports
│ ├── main.tsx # Entry point da aplicação
│ └── router.tsx # Definição das rotas
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts
Cole este conteúdo no arquivo src/index.css após instalar as dependências:
@import 'tailwindcss';
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/inter/700.css';
@import '@fontsource/inter/800.css';
@import './styles/theme.css';
@layer base {
body {
@apply bg-background text-foreground;
width: 100%;
height: 100%;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
}
.lucide {
stroke-width: 1.5;
}Crie o arquivo src/styles/theme.css e cole o conteúdo abaixo. Ele define as variáveis CSS de cor para os temas claro e escuro, e as registra no sistema de tokens do Tailwind v4:
@layer base {
:root,
[data-theme='light'] {
--background: #f8fafc;
--foreground: #0f1729;
--primary: #925cf0;
--primary-foreground: #f8fafc;
--card: #fcfcfe;
--border: rgba(9, 9, 11, 0.08);
--muted-primary: rgba(146, 92, 240, 0.5);
--muted-foreground: #64749a;
--secondary-button: #f1f5f9;
--input: #fcfcfe;
--skeleton-base-color: #e1e1f1;
--skeleton-highlight-color: #c5c5df;
}
[data-theme='dark'] {
--background: #0f0d16;
--foreground: #fcfcfe;
--primary: #925cf0;
--primary-foreground: #fcfcfc;
--card: #181622;
--border: rgba(248, 250, 252, 0.4);
--muted-primary: rgba(146, 92, 240, 0.2);
--muted-foreground: #ad9fc8;
--secondary-button: #27272a;
--input: #232131;
--skeleton-base-color: #3e3e42;
--skeleton-highlight-color: #434052;
}
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-border: var(--border);
--color-muted-primary: var(--muted-primary);
--color-muted-foreground: var(--muted-foreground);
--color-secondary-button: var(--secondary-button);
--color-input: var(--input);
--color-red-500: #ef4444;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--color-skeleton-base: var(--skeleton-base-color);
--color-skeleton-highlight: var(--skeleton-highlight-color);
}O layout do projeto está disponível no Figma:
Faça o download dos arquivos abaixo e coloque-os nos caminhos indicados dentro do projeto:
| Arquivo | Caminho no projeto | Descrição |
|---|---|---|
| piggy-bank.png | src/assets/images/piggy-bank.png |
Ilustração da hero section |
- Bloco 1 — Configuração e Layout Base do Projeto
- Aula 01: Apresentação do Curso e do Projeto
- Aula 02: Criando o Projeto com Vite e Configurações Iniciais
- Aula 03: Adicionando e Configurando o Tailwind CSS com Vite
- Aula 04: Configurando Variáveis de Tema e Estilos Globais
- Aula 05: Configurando Rotas com React Router
- Aula 06: Componente
Button - Aula 07: Cabeçalho e Menu de Navegação
- Bloco 2 — Temas e Formulário Multi-Step de Simulação
- Aula 08: Implementando o Sistema de Temas
- Aula 09: Estrutura Base da Página de Simulação
- Aula 10: Componente de Progresso do Formulário
- Aula 11: Interface do Formulário de Simulação
- Aula 12: Configurando os Dados do Formulário Multi-Step
- Aula 13: Lógica de Avançar, Voltar e Exibir Progresso
- Aula 14: Aplicando Máscara de Moeda no Input
- Aula 15: Salvando as Respostas no localStorage
- Bloco 3 — Resultado da Simulação e Insights com IA
- Aula 16: Componentes para a Página de Resultados
- Aula 17: Implementando ID Único para Cada Simulação
- Aula 18: Criando o Prompt para a IA Generativa
- Aula 19: Obtendo a Chave de API do Google Gemini
- Aula 20: Chamada para a API do Gemini
- Aula 21: Evitando Chamadas Duplicadas à API
- Aula 22: Estados de Carregamento e de Erro
- Aula 23: Exibindo o Diagnóstico Financeiro da IA
- Desafios
O Planej.ai é uma SPA (Single Page Application) desenvolvida com React + TypeScript. Nesta série, você vai aprender a construir do zero uma aplicação com formulário multi-step, sistema de temas (claro/escuro), persistência de dados com localStorage e integração com IA generativa.
-
Crie o projeto utilizando o Vite com o template de React + TypeScript:
pnpm create vite planejai --template react-ts
-
Faça uma limpeza nos arquivos gerados automaticamente pelo Vite:
- Remova o arquivo
App.css - Limpe o conteúdo de
App.tsx - Deixe o arquivo
index.cssem branco - Remova as imagens da pasta
assets
- Remova o arquivo
-
Configure o repositório Git e vincule ao repositório remoto:
git init git commit -m "feat: create project react-ts" git branch -M main git remote add origin https://github.com/seu-usuario/seu-repositorio.git -
Configure o ESLint, Prettier e o VSCode com a ajuda da IA. Use o seguinte prompt como base para gerar as configurações:
- Faça a configuração do Prettier no projeto e ESLint. - Configure o VSCode para o projeto - Sempre que o desenvolvedor salvar um arquivo, ele deve ser formatado automaticamente - Espaçamento para novos arquivos de 2 tabs por padrão - Remova automaticamente os imports que não são utilizados - Faça a ordenação dos imports automaticamente - Adicione o plugin do Prettier para organizar as classes do tailwind - Configure o alias no projeto '@/' para a pasta src
O Tailwind CSS é um framework de CSS "utility-first": em vez de criar classes personalizadas no seu próprio CSS (como .botao-primario), você monta o visual dos elementos usando classes pré-prontas, onde cada uma é responsável por uma única propriedade (espaçamento, cor, tamanho, sombra etc.).
Isso torna o desenvolvimento mais rápido, o CSS final mais leve (só as classes usadas são incluídas no build) e facilita a criação de layouts responsivos e consistentes.
-
Instale o Tailwind CSS e o plugin de integração com o Vite:
pnpm add tailwindcss @tailwindcss/vite
-
Configure o plugin no arquivo
vite.config.ts:import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [tailwindcss()], })
-
Adicione a fonte
Interao projeto:pnpm add @fontsource/inter
Importe os pesos da fonte no arquivo
index.css:@import '@fontsource/inter/400.css'; @import '@fontsource/inter/600.css'; @import '@fontsource/inter/700.css'; @import '@fontsource/inter/800.css';
Neste projeto, utilizaremos dois arquivos de estilos:
index.css→ ponto de entrada e estilos globaisstyles/theme.css→ design tokens (variáveis de cor por tema)
-
Importe o Tailwind dentro dos estilos globais (
index.css):@import 'tailwindcss'; @import '@fontsource/inter/400.css'; @import '@fontsource/inter/600.css'; @import '@fontsource/inter/700.css'; @import '@fontsource/inter/800.css';
-
Defina os tokens de design no arquivo
/styles/theme.css.@theme: No Tailwind v4, o@themeé onde você registra os nomes das classes personalizadas. Ao declarar--color-primary: ...dentro dele, o Tailwind cria automaticamente as classesbg-primary,text-primary,border-primary, etc. Sem isso, essas classes não funcionariam.@layer base: Usado para adicionar estilos globais e resetar regras padrão do CSS para elementos HTML (comoh1,a,body). Esses estilos são carregados antes das classes utilitárias, que podem sobrescrevê-los quando necessário.@layer base { :root, [data-theme='light'] { --background: #f8fafc; --foreground: #0f1729; --primary: #925cf0; --primary-foreground: #f8fafc; --card: #fcfcfe; --border: rgba(9, 9, 11, 0.08); --muted-primary: rgba(146, 92, 240, 0.5); --muted-foreground: #64749a; --secondary-button: #f1f5f9; --input: #fcfcfe; } [data-theme='dark'] { --background: #0f0d16; --foreground: #fcfcfe; --primary: #925cf0; --primary-foreground: #fcfcfc; --card: #181622; --border: rgba(248, 250, 252, 0.4); --muted-primary: rgba(146, 92, 240, 0.2); --muted-foreground: #ad9fc8; --secondary-button: #27272a; --input: #232131; } } @theme { --color-background: var(--background); --color-foreground: var(--foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-card: var(--card); --color-border: var(--border); --color-muted-primary: var(--muted-primary); --color-muted-foreground: var(--muted-foreground); --color-secondary-button: var(--secondary-button); --color-input: var(--input); --color-red-500: #ef4444; --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; }
-
Adicione os estilos globais ao
index.css:@import 'tailwindcss'; @import '@fontsource/inter/400.css'; @import '@fontsource/inter/600.css'; @import '@fontsource/inter/700.css'; @import '@fontsource/inter/800.css'; @import './styles/theme.css'; @layer base { body { @apply bg-background text-foreground; width: 100%; height: 100%; transition: background-color 0.3s ease, color 0.3s ease; } }
O React, por padrão, é uma SPA (Single Page Application). O React Router permite simular a navegação entre páginas sem recarregar o navegador.
-
Instale a biblioteca:
pnpm add react-router-dom
-
Crie o arquivo
src/router.tsxcom as rotas da aplicação. Por enquanto, vamos usar elementos temporários que serão substituídos pelos componentes reais:import { createBrowserRouter } from 'react-router-dom' export const router = createBrowserRouter([ { children: [ { path: '/', element: <h1>Formulário de Simulação</h1>, }, { path: '/resultado', element: <h1>Resultado da Simulação</h1>, }, { path: '/historico', element: <h1>Histórico de Simulações</h1>, }, ], }, ])
-
No
App.tsx, configure oRouterProviderpara disponibilizar as rotas e os hooks do React Router em toda a aplicação:import { RouterProvider } from 'react-router-dom' import { router } from './router' function App() { return <RouterProvider router={router} /> } export default App
Nesta aula, criaremos o componente Button reutilizável. Ele recebe uma prop variant que define qual estilo deve ser aplicado, permitindo usar o mesmo componente em diferentes contextos sem precisar estilizar do zero a cada uso.
Exemplo de uso:
<Button variant="primary" icon={PiggyBank}>
Clique aqui
</Button>-
Crie o arquivo em
src/components/shared/Button/index.tsxcom a estrutura inicial:export function Button() { return <button>Clique aqui</button> }
-
Adicione a biblioteca de ícones:
pnpm add lucide-react
-
Declare as propriedades que o
Buttonpode receber.O
extends ButtonHTMLAttributesgarante que o componente aceite props nativas comotype="submit",onClick,disabled, etc., sem precisar declará-las manualmente uma por uma.import type { LucideIcon } from 'lucide-react' import type { ButtonHTMLAttributes } from 'react' interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant: 'primary' | 'secondary' | 'ghost' icon?: LucideIcon } export function Button({ variant, icon: Icon, children, ...props }: ButtonProps) { return ( <button {...props}> {Icon && <Icon size={16} />} {children} </button> ) }
-
Adicione as classes base e as classes por variante:
const baseClasses = 'flex cursor-pointer items-center justify-center font-medium text-sm gap-2 px-4 py-3 transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-80' const variantClasses = { primary: 'bg-primary text-primary-foreground font-semibold rounded-xl', secondary: 'bg-secondary-button border border-border rounded-3xl', ghost: 'rounded-lg text-foreground', }
-
Aplique as classes e permita customização via
className:export function Button({ variant, icon: Icon, children, className, ...props }: ButtonProps) { return ( <button className={[baseClasses, variantClasses[variant], className].join(' ')} {...props} > {Icon && <Icon />} {children} </button> ) }
-
Deixe os ícones do Lucide com espessura de traço mais fina globalmente no CSS:
.lucide { stroke-width: 1.5; }
-
Crie o componente
Headeremsrc/components/shared/Header/index.tsx:export function Header() { return <header>Header</header> }
-
Crie o
RootLayoutpara exibir oHeaderem todas as páginas da aplicação:src/components/layout/RootLayout.tsximport { Outlet } from 'react-router-dom' import { Header } from '../shared/Header' export function RootLayout() { return ( <> <Header /> <Outlet /> </> ) }
Atualize o
router.tsxpara usar oRootLayout:import { createBrowserRouter } from 'react-router-dom' import { RootLayout } from './components/layout/RootLayout' export const router = createBrowserRouter([ { element: <RootLayout />, children: [ { path: '/', element: <h1>Formulário de Simulação</h1>, }, { path: '/resultado', element: <h1>Resultado da Simulação</h1>, }, { path: '/historico', element: <h1>Histórico de Simulações</h1>, }, ], }, ])
-
Desenvolva o logotipo dentro do
Header:import { Wallet } from 'lucide-react' export function Header() { return ( <header className="border-b border-(--border) px-6 py-3"> <nav className="flex items-center justify-between"> <div className="flex items-center gap-2"> <div className="bg-primary flex h-9 w-9 items-center justify-center rounded-full"> <Wallet size={20} className="text-primary-foreground" /> </div> <span className="text-lg"> <span className="text-muted-foreground font-medium">Planej</span> <span className="font-extrabold">.ai</span> </span> </div> </nav> </header> ) }
-
Adicione os botões de navegação:
import { Clock, TrendingUp, Wallet } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { Button } from './Button' export function Header() { const navigate = useNavigate() return ( <header className="border-b border-(--border) px-6 py-3"> <nav className="flex items-center justify-between"> <div className="flex items-center gap-2"> <div className="bg-primary flex h-9 w-9 items-center justify-center rounded-full"> <Wallet size={20} className="text-primary-foreground" /> </div> <span className="text-lg"> <span className="text-muted-foreground font-medium">Planej</span> <span className="font-extrabold">.ai</span> </span> </div> <div className="flex items-center gap-1"> <Button variant="secondary" icon={TrendingUp} onClick={() => void navigate('/')} > <span className="hidden sm:inline">Nova Simulação</span> </Button> <Button variant="ghost" icon={Clock} onClick={() => void navigate('/historico')} > <span className="hidden sm:inline">Histórico</span> </Button> </div> </nav> </header> ) }
Nesta aula, criaremos a lógica para que o usuário possa alternar entre os temas claro e escuro usando a Context API do React.
Requisitos:
- A preferência de tema deve ser salva no
localStorage. - Se não houver valor salvo, deve-se usar a preferência do sistema operacional.
-
Crie o arquivo
src/context/theme/ThemeContext.tsxcom a definição do contexto:import { createContext } from 'react' export type Theme = 'light' | 'dark' interface ThemeContextValue { theme: Theme toggleTheme: () => void } export const ThemeContext = createContext<ThemeContextValue | undefined>( undefined, )
-
Crie o
ThemeProvideremsrc/context/theme/ThemeProvider.tsx. Começamos com a estrutura básica:import { type PropsWithChildren, useState } from 'react' import { type Theme, ThemeContext } from './ThemeContext' export function ThemeProvider({ children }: PropsWithChildren) { const [theme, setTheme] = useState<Theme>('light') const toggleTheme = () => { setTheme((currentTheme) => (currentTheme === 'light' ? 'dark' : 'light')) } return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ) }
-
Adicione o
useEffectpara atualizar o atributodata-themeno HTML e salvar a preferência nolocalStorage. Sempre que othememuda, atualizamos o atributo no HTML (para o CSS aplicar as cores corretas) e salvamos nolocalStorage:useEffect(() => { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('theme', theme) }, [theme])
-
Inicialize o estado de tema com as preferências salvas (localStorage ou sistema operacional):
const [theme, setTheme] = useState<Theme>(() => { const localStorageTheme = localStorage.getItem('theme') as Theme | null if (localStorageTheme) { return localStorageTheme } const systemPrefersDark = window.matchMedia( '(prefers-color-scheme: dark)', ).matches return systemPrefersDark ? 'dark' : 'light' })
-
Crie o hook personalizado
useThemeemsrc/hooks/useTheme.tsx:import { useContext } from 'react' import { ThemeContext } from '../context/theme/ThemeContext' export function useTheme() { const context = useContext(ThemeContext) if (context === undefined) { throw new Error('useTheme deve ser usado dentro de um ThemeProvider') } return context }
-
Envolva a aplicação com o
ThemeProvidernoApp.tsx:import { RouterProvider } from 'react-router-dom' import { ThemeProvider } from './context/theme/ThemeProvider' import { router } from './router' function App() { return ( <ThemeProvider> <RouterProvider router={router} /> </ThemeProvider> ) } export default App
-
Adicione o botão de alternar tema no
Header:<Button aria-label={`Mudar para tema ${theme === 'light' ? 'escuro' : 'claro'}`} variant="ghost" icon={theme === 'light' ? Moon : Sun} onClick={toggleTheme} />
-
Crie o componente
Divideremsrc/components/shared/Divider.tsx:interface DividerProps { orientation?: 'horizontal' | 'vertical' spacing?: number className?: string } export function Divider({ orientation = 'horizontal', spacing = 16, className, }: DividerProps) { const style = orientation === 'horizontal' ? { marginTop: spacing, marginBottom: spacing } : { marginLeft: spacing, marginRight: spacing } const classNamesByOrientation = { horizontal: 'w-full h-px', vertical: 'self-stretch w-px', } return ( <div role="separator" aria-orientation={orientation} style={style} className={[ 'bg-border', classNamesByOrientation[orientation], className, ] .filter(Boolean) .join(' ')} /> ) }
-
Versão final do
Headercom todos os elementos:import { Clock, Moon, Sun, TrendingUp, Wallet } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useTheme } from '../../hooks/useTheme' import { Button } from './Button' import { Divider } from './Divider' export function Header() { const navigate = useNavigate() const { theme, toggleTheme } = useTheme() return ( <header className="border-b border-(--border) px-6 py-3"> <nav className="flex items-center justify-between"> <div className="flex items-center gap-2"> <div className="bg-primary flex h-9 w-9 items-center justify-center rounded-full"> <Wallet size={20} className="text-primary-foreground" /> </div> <span className="text-lg"> <span className="text-muted-foreground font-medium">Planej</span> <span className="font-extrabold">.ai</span> </span> </div> <div className="flex items-center gap-1"> <Button variant="secondary" icon={TrendingUp} onClick={() => void navigate('/')} > <span className="hidden sm:inline">Nova Simulação</span> </Button> <Button variant="ghost" icon={Clock} onClick={() => void navigate('/historico')} > <span className="hidden sm:inline">Histórico</span> </Button> <Divider orientation="vertical" /> <Button aria-label={`Mudar para tema ${theme === 'light' ? 'escuro' : 'claro'}`} variant="ghost" icon={theme === 'light' ? Moon : Sun} onClick={toggleTheme} /> </div> </nav> </header> ) }
-
Crie a página
SimulationFormPagee registre-a na rota/:src/pages/SimulationFormPage.tsxexport function SimulationFormPage() { return ( <div> <h1>Simulation Form Page</h1> </div> ) }
src/router.tsximport { createBrowserRouter } from 'react-router-dom' import { RootLayout } from './components/layout/RootLayout' import { SimulationFormPage } from './pages/SimulationFormPage' export const router = createBrowserRouter([ { element: <RootLayout />, children: [ { path: '/', element: <SimulationFormPage />, }, { path: '/resultado', element: <h1>Resultado da Simulação</h1>, }, { path: '/historico', element: <h1>Histórico de Simulações</h1>, }, ], }, ])
-
Crie o componente
SimulationHeropara o título e subtítulo da página.Adicione a imagem do cofrinho 3D em
src/assets/images/piggy-bank.png.src/components/features/Simulation/Hero/index.tsximport PiggyBankImage from '@/assets/images/piggy-bank.png' export function SimulationHero() { return ( <div className="mb-8 text-center"> <div className="flex flex-col items-center sm:flex-row"> <h1 className="text-foreground text-3xl font-semibold sm:text-4xl"> Vamos planejar seu futuro </h1> <img src={PiggyBankImage} alt="" aria-hidden="true" className="h-16 w-16 sm:-mt-2 sm:-ml-3" /> </div> <p className="text-muted-foreground text-sm"> Responda algumas questões para ter insights financeiros personalizados. </p> </div> ) }
-
Atualize a página de simulação para incluir o hero e um componente de formulário:
import { SimulationHero } from '@/components/features/Simulation/Hero' import { SimulationForm } from '../components/features/Simulation/Form' export function SimulationFormPage() { return ( <main className="mx-auto max-w-xl px-4 py-10 sm:py-14"> <SimulationHero /> <SimulationForm /> </main> ) }
Crie o componente de barra de progresso para o formulário multi-step:
src/components/features/Simulation/Progress/index.tsx
interface StepProgressProps {
currentStep: number
totalSteps: number
}
export function StepProgress({ currentStep, totalSteps }: StepProgressProps) {
const progress = (currentStep / totalSteps) * 100
return (
<div className="mb-4">
<p className="text-muted-foreground mb-2 text-sm">
Passo {currentStep} de {totalSteps}
</p>
<div className="bg-border h-1 w-full overflow-hidden rounded-full">
<div
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Passo ${currentStep} de ${totalSteps}`}
className="bg-primary h-full rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
}-
Crie o componente
FormStep:src/components/features/Simulation/FormStep/index.tsximport type { LucideIcon } from 'lucide-react' interface FormStepProps { icon: LucideIcon title: string question: string } export function FormStep({ icon: Icon, title, question }: FormStepProps) { return ( <div className="bg-card rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] sm:p-8"> <div className="bg-primary mb-4 flex h-15 w-15 items-center justify-center rounded-xl"> <Icon size={32} className="text-primary-foreground" /> </div> <h2 className="text-primary mb-1 text-xs font-semibold tracking-widest uppercase"> {title} </h2> <h3 className="text-foreground mb-6 text-xl leading-snug font-semibold sm:text-2xl"> {question} </h3> </div> ) }
-
Crie o componente
Inputemsrc/components/shared/Input.tsx:import type { InputHTMLAttributes } from 'react' import { Divider } from './Divider' export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { prefix?: string suffix?: string } export function Input({ prefix, suffix, ...rest }: InputProps) { return ( <div className="bg-input flex items-center rounded-2xl p-4 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)]"> {prefix && ( <> <span className="text-muted-foreground text-sm font-medium"> {prefix} </span> <Divider orientation="vertical" /> </> )} <input className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none" autoFocus {...rest} /> {suffix && ( <> <Divider orientation="vertical" /> <span className="text-muted-foreground ml-3 text-sm font-medium"> {suffix} </span> </> )} </div> ) }
-
Atualize o
FormSteppara incluir o input e os botões de navegação:import { ArrowLeft, ArrowRight, type LucideIcon } from 'lucide-react' import { Button } from '@/components/shared/Button' import { Input, type InputProps } from '@/components/shared/Input' interface FormStepProps { icon: LucideIcon title: string question: string inputProps: InputProps submitButtonProps?: { label: string emojiIcon?: string } } export function FormStep({ icon: Icon, title, question, inputProps, submitButtonProps, }: FormStepProps) { return ( <div className="bg-card rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] sm:p-8"> <div className="bg-primary mb-4 flex h-15 w-15 items-center justify-center rounded-xl"> <Icon size={32} className="text-primary-foreground" /> </div> <h2 className="text-primary mb-1 text-xs font-semibold tracking-widest uppercase"> {title} </h2> <h3 className="text-foreground mb-6 text-xl leading-snug font-semibold sm:text-2xl"> {question} </h3> <form className="flex flex-col gap-4"> <Input {...inputProps} /> <div className="flex flex-col gap-3 sm:flex-row sm:gap-6"> <Button type="button" variant="ghost" className="order-2 flex-1 justify-center rounded-xl py-3 sm:order-1" > <ArrowLeft size={16} /> Voltar </Button> <Button type="submit" variant="primary" className="order-1 flex-1 sm:order-2" > {submitButtonProps?.label ?? 'Próximo'} {submitButtonProps?.emojiIcon ?? <ArrowRight size={16} />} </Button> </div> </form> </div> ) }
-
Exporte a interface
FormStepPropspara ser usada em outros arquivos:export interface FormStepProps { id: string // ... demais propriedades }
-
Crie o arquivo
src/data/simulation.tscom a configuração de cada step do formulário:import { CalendarClock, CreditCard, Goal, Landmark, PiggyBank, Wallet, } from 'lucide-react' import type { FormStepProps } from '../components/features/Simulation/FormStep' export const simulationFormSteps: FormStepProps[] = [ { id: 'income', icon: PiggyBank, title: 'Renda mensal bruta', question: 'Quanto é depositado na sua conta todo mês (somando todas as fontes)?', inputProps: { placeholder: 'ex: 5.000,00', prefix: 'R$', maxLength: 12, }, }, { id: 'expenses', icon: CreditCard, title: 'Custos fixos de vida', question: 'Quanto você gasta mensalmente com custos fixos (aluguel, contas, etc)?', inputProps: { placeholder: 'ex: 2.000,00', prefix: 'R$', maxLength: 12, }, }, { id: 'debts', icon: Landmark, title: 'Dívidas / parcelas', question: 'Você tem algum valor comprometido com parcelas ou empréstimos mensalmente?', inputProps: { placeholder: 'ex: 500,00', prefix: 'R$', maxLength: 12, }, }, { id: 'goalName', icon: Goal, title: 'Nome da meta', question: 'Qual o objetivo que você deseja alcançar?', inputProps: { placeholder: 'ex: Viagem para o Japão', maxLength: 50, }, }, { id: 'goalAmount', icon: Wallet, title: 'Custo da meta', question: 'Quanto custa realizar esse sonho?', inputProps: { placeholder: 'ex: 15.000,00', prefix: 'R$', maxLength: 12, }, }, { id: 'goalDeadline', icon: CalendarClock, title: 'Prazo desejado', question: 'Em quantos meses você planeja atingir esse objetivo?', inputProps: { type: 'number', placeholder: 'ex: 12', suffix: 'meses', min: 1, max: 120, }, submitButtonProps: { label: 'Gerar simulação', emojiIcon: '✨', }, }, ]
-
Use os dados no componente
SimulationForm:import { simulationFormSteps } from '@/data/simulation' import { FormStep } from './FormStep' import { FormProgress } from './Progress' export const SimulationForm = () => { const currentStep = simulationFormSteps[0] return ( <> <FormProgress currentStep={1} totalSteps={6} /> <FormStep key={currentStep.id} {...currentStep} /> </> ) }
-
Adicione um estado para controlar o step atual:
export const SimulationForm = () => { const [currentStepIndex, setCurrentStepIndex] = useState(0) const totalSteps = simulationFormSteps.length const currentStep = simulationFormSteps[currentStepIndex] return ( <> <FormProgress currentStep={currentStepIndex + 1} totalSteps={totalSteps} /> <FormStep key={currentStep.id} {...currentStep} /> </> ) }
-
Implemente as funções de navegação:
const handleNextStep = () => { if (currentStepIndex + 1 > totalSteps - 1) { return } setCurrentStepIndex((prev) => prev + 1) } const handlePreviousStep = () => { if (currentStepIndex === 0) { return } setCurrentStepIndex((prev) => prev - 1) }
-
Atualize o
FormSteppara receber e chamar as funções de navegação:interface ActionsButtonsProps { onBack: () => void onNext: () => void hideBackButton?: boolean } export function FormStep({ icon: Icon, title, question, inputProps, submitButtonProps, hideBackButton, onBack, onNext, }: FormStepProps & ActionsButtonsProps) { const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { e.preventDefault() onNext() } return ( <div className="bg-card rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] sm:p-8"> {/* ... */} <form onSubmit={handleSubmit} className="flex flex-col gap-4"> <Input {...inputProps} /> <div className="flex flex-col gap-3 sm:flex-row sm:gap-6"> {!hideBackButton && ( <Button type="button" variant="ghost" className="order-2 flex-1 justify-center rounded-xl py-3 sm:order-1" onClick={onBack} > <ArrowLeft size={16} /> Voltar </Button> )} <Button type="submit" variant="primary" className="order-1 flex-1 sm:order-2" > {submitButtonProps?.label ?? 'Próximo'} {submitButtonProps?.emojiIcon ?? <ArrowRight size={16} />} </Button> </div> </form> </div> ) }
-
Adicione validação para não permitir avançar com o campo vazio:
export function FormStep({ ... }: FormStepProps & ActionsButtonsProps) { const [inputValue, setInputValue] = useState('') const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { e.preventDefault() if (!inputValue) { return } onNext() } return ( // ... <Input {...inputProps} value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <Button type="submit" variant="primary" className="order-1 flex-1 sm:order-2" disabled={!inputValue} > {/* ... */} </Button> ) }
-
Crie a função de máscara em
src/utils/currency.ts:export function formatCurrencyMask(value: string): string { const digits = value.replace(/\D/g, '') if (!digits) { return '' } const number = parseInt(digits, 10) / 100 return number.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2, }) }
-
Aplique a máscara nos inputs monetários dentro do
FormStep:<Input {...inputProps} value={inputValue} onChange={(e) => { const isCurrency = inputProps.prefix === 'R$' const value = e.target.value setInputValue(isCurrency ? formatCurrencyMask(value) : value) }} />
-
Corrija o bug em que o input da próxima pergunta vinha preenchido com o valor anterior. A solução é adicionar a prop
keyno componente — quando okeymuda, o React desmonta e remonta o componente do zero, resetando automaticamente todo o estado interno:<FormStep {...currentStep} key={currentStep.id} hideBackButton={currentStepIndex === 0} onBack={handlePreviousStep} onNext={handleNextStep} />
-
Atualize o
FormSteppara enviar o valor preenchido ao componente pai quando avançar:interface ActionsButtonsProps { onBack: () => void onNext: (value: string) => void hideBackButton?: boolean } const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { e.preventDefault() if (!inputValue) { return } onNext(inputValue) }
-
Adicione o tipo
SimulationFormDataao arquivo de dados. O operadorsatisfiesvalida que o array respeita o formato deFormStepProps[], mas sem apagar os tipos literais dosids. Assim, o TypeScript sabe exatamente quais valores existem, e o tipoSimulationFormDataé gerado automaticamente a partir dos dados — sem precisar declarar manualmente cada chave:export const simulationFormSteps: FormStepProps[] = [ // ... ] satisfies FormStepProps[] export type SimulationFormData = Record< (typeof simulationFormSteps)[number]['id'], string >
-
Salve os dados de cada step no estado do
SimulationForm:import { type SimulationFormData, simulationFormSteps } from '@/data/simulation' export const SimulationForm = () => { const [currentStepIndex, setCurrentStepIndex] = useState(0) const [formData, setFormData] = useState<SimulationFormData>({} as SimulationFormData) const totalSteps = simulationFormSteps.length const currentStep = simulationFormSteps[currentStepIndex] const handleNextStep = (value: string) => { const updatedFormData = { ...formData, [currentStep.id]: value } setFormData(updatedFormData) if (currentStepIndex + 1 > totalSteps - 1) { return } setCurrentStepIndex((prev) => prev + 1) }
-
Crie o hook
useSimulationStoragepara centralizar a lógica de persistência:src/hooks/useSimulationStorage.tsximport { type SimulationFormData } from '@/data/simulation' const LOCAL_STORAGE_KEY = 'simulation-data' export const useSimulationStorage = () => { const saveFormData = (formData: SimulationFormData) => { const storage = localStorage.getItem(LOCAL_STORAGE_KEY) const savedData = storage ? (JSON.parse(storage) as SimulationFormData[]) : [] localStorage.setItem( LOCAL_STORAGE_KEY, JSON.stringify([...savedData, formData]), ) } return { saveFormData } }
-
Ao finalizar o formulário, salve os dados e navegue para a página de resultados:
const navigate = useNavigate() const handleNextStep = (value: string) => { const updatedFormData = { ...formData, [currentStep.id]: value } setFormData(updatedFormData) if (currentStepIndex + 1 > totalSteps - 1) { saveFormData(updatedFormData) void navigate('/resultado') return } setCurrentStepIndex((prev) => prev + 1) }
-
Crie a
SimulationResultsPagee configure a rota:export function SimulationResultsPage() { return <h1>Página de Resultados</h1> }
{ path: '/resultado', element: <SimulationResultsPage />, }
-
Crie o componente
PageHeroemsrc/components/shared/PageHero.tsx:interface PageHeroProps { title: string subtitle: string } export function PageHero({ title, subtitle }: PageHeroProps) { return ( <> <h1 className="text-foreground mb-1 text-2xl font-semibold sm:text-3xl"> {title} </h1> <p className="text-muted-foreground mb-8 text-sm">{subtitle}</p> </> ) }
-
Crie o componente
Cardpara exibir os dados da simulação:src/components/features/SimulationResults/Card.tsximport type { LucideIcon } from 'lucide-react' interface CardProps { icon: LucideIcon label: string value: string subtitle: string variant?: 'default' | 'primary' } const variantClasses = { default: { card: 'bg-card', accent: 'text-primary', value: 'text-foreground', subtitle: 'text-muted-foreground', }, primary: { card: 'bg-primary', accent: 'text-primary-foreground', value: 'text-primary-foreground', subtitle: 'text-primary-foreground/70', }, } export function Card({ icon: Icon, label, value, subtitle, variant = 'default', }: CardProps) { const styles = variantClasses[variant] return ( <div className={[ 'rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)]', styles.card, ].join(' ')} > <div className="mb-3 flex items-center gap-2"> <Icon size={16} className={styles.accent} /> <span className={[ 'text-xs font-semibold tracking-widest uppercase', styles.accent, ].join(' ')} > {label} </span> </div> <p className={['text-3xl font-semibold', styles.value].join(' ')}> {value} </p> <p className={['mt-1 text-sm', styles.subtitle].join(' ')}> {subtitle} </p> </div> ) }
-
Crie a função utilitária
parseCurrencyemsrc/utils/currency.ts:export function parseCurrency(value: string): number { return ( parseFloat( value.replace(/\./g, '').replace(',', '.').replace('R$', ''), ) || 0 ) }
-
Crie a função
calcMonthlySavingsemsrc/utils/simulation.ts:import type { SimulationFormData } from '../data/simulation' import { parseCurrency } from './currency' export function calcMonthlySavings(data: SimulationFormData) { return ( parseCurrency(data.income) - parseCurrency(data.expenses) - parseCurrency(data.debts) ) }
-
Monte a página de resultados com todos os cards:
const mock: SimulationFormData = { income: 'R$ 5.000,00', expenses: 'R$ 2.000,00', debts: 'R$ 500,00', goalName: 'Viagem para o Japão', goalAmount: 'R$ 15.000,00', goalDeadline: '12', } export function SimulationResultsPage() { const data = mock as SimulationFormData const monthlySavings = calcMonthlySavings(data) return ( <main className="mx-auto max-w-6xl px-4 py-10 sm:py-14"> <PageHero title="Resultado da sua simulação" subtitle="Com base no seu perfil financeiro e objetivos." /> <div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-3"> <Card icon={Goal} label="Custo da Meta" value={data.goalAmount} subtitle={data.goalName} /> <Card icon={CalendarClock} label="Prazo" value={`${data.goalDeadline} meses`} subtitle="Prazo para atingir a meta" /> <Card variant="primary" icon={PiggyBank} label="Economia mensal" value={`R$ ${monthlySavings.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} subtitle="Economia mensal necessária" /> </div> <div className="grid gap-6 lg:grid-cols-3"> <div className="bg-card order-2 rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] lg:order-1 lg:col-span-2"> Painel de Insights </div> <div className="order-1 flex flex-col gap-6 lg:order-2"> <Card icon={Wallet} label="Renda mensal" value={data.income} subtitle="Renda total bruta por mês" /> <Card icon={CreditCardIcon} label="Custos Fixos de Vida" value={data.expenses} subtitle="Gastos essenciais por mês" /> <Card icon={Landmark} label="Dívidas / Parcelas" value={data.debts} subtitle="Valor comprometido em parcelas/depósito" /> </div> </div> </main> ) }
Atualmente salvamos simulações sem um identificador único, o que impede recuperar um registro específico na página de resultados.
-
Crie o tipo
SimulationRecordno hook de armazenamento. A boa prática é não poluir o tipo de formulário com responsabilidades de armazenamento:export type SimulationRecord = SimulationFormData & { id: string }
-
Atualize
saveFormDatapara gerar um ID único usando a Web Crypto API:export const useSimulationStorage = () => { const saveFormData = (formData: SimulationFormData) => { const id = crypto.randomUUID() const record: SimulationRecord = { ...formData, id } const storage = localStorage.getItem(LOCAL_STORAGE_KEY) const savedData = storage ? (JSON.parse(storage) as SimulationRecord[]) : [] localStorage.setItem( LOCAL_STORAGE_KEY, JSON.stringify([...savedData, record]), ) return id } }
-
Adicione a função
getFormDatapara buscar uma simulação pelo ID:const getFormData = (id: string): SimulationRecord | null => { const storage = localStorage.getItem(LOCAL_STORAGE_KEY) if (!storage) { return null } const savedData = JSON.parse(storage) as SimulationRecord[] return savedData.find((record) => record.id === id) || null }
-
Use o ID retornado para navegar com o parâmetro de rota:
const handleNextStep = (value: string) => { const updatedFormData = { ...formData, [currentStep.id]: value } setFormData(updatedFormData) if (currentStepIndex + 1 > totalSteps - 1) { const id = saveFormData(updatedFormData) void navigate(`/resultado/${id}`) return } setCurrentStepIndex((prev) => prev + 1) }
-
Atualize a rota e a página de resultados para ler o ID via
useParams:// router.tsx { path: '/resultado/:id', element: <SimulationResultsPage />, }
// SimulationResultsPage.tsx export function SimulationResultsPage() { const { id } = useParams<{ id: string }>() const { getFormData } = useSimulationStorage() const data = id ? getFormData(id) : null if (!data) { return <p>Simulação não encontrada.</p> } // ... }
Ao integrar IA em uma aplicação, a qualidade do prompt é determinante para a qualidade da resposta. Veja os elementos essenciais:
| Elemento | Por que é importante |
|---|---|
| Papel | Define tom e consistência |
| Contexto de exibição | Evita respostas inadequadas para a UI |
| Dados estruturados | Garante que a IA usa os valores corretos |
| Formato de saída | Permite processar a resposta no código |
| Schema | Contrato entre prompt e código |
| Critérios objetivos | Elimina ambiguidade em campos enumerados |
| Regras de restrição | Previne comportamentos indesejados |
- Papel (Role): Diga quem a IA é. Quanto mais específico, mais consistente e previsível será o comportamento.
- Contexto de exibição: A IA não sabe onde a resposta vai aparecer. Se não informar, ela pode usar markdown, escrever em terceira pessoa ou textos longos demais.
- Dados estruturados: Passe os dados de forma clara e rotulada. Inclua também valores calculados para garantir consistência com os que o sistema exibe.
- Formato de saída: Em integrações, você precisa processar a resposta no código — instrua a IA a retornar um formato específico (JSON).
- Schema como contrato: Mostra exatamente o que o código espera receber da IA.
- Critérios objetivos: Para campos com valores fixos, defina os critérios explicitamente. Nunca deixe a IA inferir.
- Regras de restrição: Limitam comportamentos indesejados que a IA poderia ter por padrão.
src/data/aiPrompt.ts
import type { SimulationRecord } from '@/hooks/useSimulationStorage'
import { parseCurrency } from '@/utils/currency'
import { calcMonthlySavings } from '@/utils/simulation'
const RESPONSE_SCHEMA = `{
"feasibility": {
"status": "viable" | "needs_adjustment" | "unfeasible",
"content": "<Análise objetiva sobre se a meta é atingível no prazo com o valor disponível. Mencione os números relevantes.>"
},
"diagnosis": {
"content": "<Diagnóstico focado no comprometimento do orçamento: quanto % da renda está comprometida com gastos e dívidas, e o que isso representa para a saúde financeira.>"
},
"suggestions": {
"items": ["<Sugestão prática e concreta para reduzir gastos ou reorganizar o orçamento>"]
},
"extraIncome": {
"items": ["<Ideia prática para gerar renda extra compatível com a realidade brasileira>"]
},
"investment": {
"items": ["<Sugestão de investimento acessível para o perfil apresentado, com foco em atingir a meta>"]
},
"motivation": {
"content": "<Mensagem final motivacional e personalizada, citando a meta pelo nome.>"
}
}`
export function buildAIPrompt(simulation: SimulationRecord) {
const { income, expenses, debts, goalName, goalAmount, goalDeadline } =
simulation
const monthlySavings = calcMonthlySavings(simulation)
const monthlySavingsNeeded =
parseCurrency(goalAmount) / parseInt(goalDeadline)
return `Você é um educador financeiro especializado em finanças pessoais. Analise os dados abaixo e gere um diagnóstico financeiro personalizado com linguagem clara, didática e encorajadora, voltado para pessoas sem conhecimento financeiro. O diagnóstico será exibido diretamente ao usuário no app, fale sempre em segunda pessoa ("você tem...", "sua meta...").
Dados da simulação:
- Renda mensal bruta: ${income}
- Custos fixos essenciais: ${expenses}
- Dívidas e parcelas mensais: ${debts}
- Valor disponível por mês: ${monthlySavings} reais
- Meta: ${goalName}
- Custo da meta: ${goalAmount}
- Prazo desejado: ${goalDeadline} meses
- Economia mensal necessária para atingir a meta no prazo: ${monthlySavingsNeeded} reais
- Saldo após reserva para a meta: ${monthlySavings - monthlySavingsNeeded} reais
Retorne APENAS um JSON válido, sem texto adicional, sem blocos de código, neste formato exato:
${RESPONSE_SCHEMA}
Regras:
- Todos os textos em português do Brasil
- Máximo de 4 itens por lista
- Seja específico ao citar valores calculados
- Não repita informações entre seções
- Nunca use markdown dentro dos valores do JSON
- Para o campo "feasibility.status", use os seguintes critérios:
- "viable": saldo após reserva para a meta é maior ou igual a 0
- "needs_adjustment": saldo negativo de até 20% do valor da economia mensal necessária
- "unfeasible": saldo negativo superior a 20% do valor da economia mensal necessária`
}-
Acesse o Google AI Studio com sua conta Google.
-
No menu lateral, clique em Get API Key.
-
Clique em Criar chave de API e dê um nome para ela.
-
Copie a chave gerada.
-
Crie o arquivo
.env.localna raiz do projeto e adicione a chave:VITE_GEMINI_API_KEY=sua_chave_aqui
-
Crie o serviço em
src/services/aiService.ts:interface GeminiResponse { candidates: { content: { parts: { text: string }[] } }[] } const API_KEY = String(import.meta.env.VITE_GEMINI_API_KEY) const MODEL_NAME = 'gemini-flash-latest' const GEMINI_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL_NAME}:generateContent?key=${API_KEY}` const callGeminiAPI = async (prompt: string) => { const response = await fetch(GEMINI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], }), }) if (!response.ok) { throw new Error(`Erro na requisição: ${response.status}`) } return (await response.json()) as GeminiResponse } export interface InsightData { feasibility: { status: 'viable' | 'needs_adjustment' | 'unfeasible' content: string } diagnosis: { content: string } suggestions: { items: string[] } extraIncome: { items: string[] } investment: { items: string[] } motivation: { content: string } } export const getInsight = async (prompt: string) => { const response = await callGeminiAPI(prompt) const json = response.candidates[0].content.parts[0].text return JSON.parse(json) as InsightData }
-
Crie o hook
useInsightemsrc/hooks/useInsight.ts:import { useCallback, useEffect, useState } from 'react' import { buildAIPrompt } from '@/data/aiPrompt' import { useSimulationStorage } from '@/hooks/useSimulationStorage' import { getInsight, type InsightData } from '@/services/aiService' export const useInsight = (id: string) => { const [insight, setInsight] = useState<InsightData | null>(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState<string | null>(null) const { getFormData } = useSimulationStorage() // useCallback é necessário pois essa função entra no array de dependências do useEffect const fetchInsight = useCallback( async (simulationId: string) => { const simulation = getFormData(simulationId) if (!simulation) { setError('Simulação não encontrada.') return } setIsLoading(true) setError(null) try { const prompt = buildAIPrompt(simulation) const data = await getInsight(prompt) setInsight(data) return data } catch { setError('Erro ao gerar o diagnóstico. Tente novamente.') } finally { setIsLoading(false) } }, [getFormData], ) useEffect(() => { if (insight || isLoading) { return } fetchInsight(id).then((data) => { if (!data) return setInsight(data) }) }, [id, insight, isLoading, fetchInsight]) return { insight, isLoading, error, fetchInsight } }
-
Crie o componente
AIInsightsCard:import { useInsight } from '@/hooks/useInsight' interface AIInsightCardProps { simulationId: string } export function AIInsightsCard({ simulationId }: AIInsightCardProps) { const { insight } = useInsight(simulationId) return ( <div className="bg-card order-2 rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] lg:order-1 lg:col-span-2"> Painel de Insights </div> ) }
Em desenvolvimento, o React Strict Mode renderiza componentes duas vezes, o que faz a API do Gemini ser chamada duas vezes. Use um useRef como flag para evitar isso:
export const useInsight = (id: string) => {
const isRequestPending = useRef(false)
const [insight, setInsight] = useState<InsightData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { getFormData } = useSimulationStorage()
const fetchInsight = useCallback(
async (simulationId: string) => {
const simulation = getFormData(simulationId)
if (!simulation) {
setError('Simulação não encontrada.')
return
}
isRequestPending.current = true
setIsLoading(true)
setError(null)
try {
const prompt = buildAIPrompt(simulation)
const data = await getInsight(prompt)
setInsight(data)
return data
} catch {
setError('Erro ao gerar o diagnóstico. Tente novamente.')
} finally {
isRequestPending.current = false
setIsLoading(false)
}
},
[getFormData],
)
useEffect(() => {
// Evita loop infinito de requisições para a API do Gemini
if (insight || isLoading || isRequestPending.current || error) {
return
}
fetchInsight(id).then((data) => {
isRequestPending.current = false
if (!data) {
return
}
setInsight(data)
})
}, [id, insight, isLoading, fetchInsight])
return { insight, isLoading, error, fetchInsight }
}Para evitar chamadas desnecessárias quando o usuário revisita uma simulação, salve o insight no localStorage junto com os dados da simulação:
// data/simulation.ts
export type SimulationRecord = SimulationFormData & {
id: string
insight?: InsightData
}
const updateSimulation = (id: string, data: SimulationRecord) => {
const storage = localStorage.getItem(LOCAL_STORAGE_KEY)
const savedData = storage ? (JSON.parse(storage) as SimulationRecord[]) : []
const updated = savedData.map((record) =>
record.id === id ? { ...data } : record,
)
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated))
}Inicialize o estado do insight com o valor salvo, se existir:
const [insight, setInsight] = useState<InsightData | null>(() => {
const simulation = getFormData(id)
if (simulation?.insight) {
return simulation.insight
}
return null
})-
Crie os componentes de estado:
src/components/features/Insights/Error.tsximport { RefreshCw } from 'lucide-react' import { Button } from '../../shared/Button' interface ErrorProps { simulationId: string message: string onRetry: () => void } export function Error({ simulationId, message, onRetry }: ErrorProps) { if (!simulationId || !message) { return null } return ( <div className="flex h-full flex-col items-center justify-center gap-3 p-6"> <p className="text-sm text-red-500">⚠️ {message}</p> <Button variant="primary" className="px-6" icon={RefreshCw} onClick={onRetry} > Tentar novamente </Button> </div> ) }
-
Instale a biblioteca de skeleton para o estado de carregamento:
pnpm add react-loading-skeleton
-
Atualize o
AIInsightsCardpara tratar os diferentes estados:import 'react-loading-skeleton/dist/skeleton.css' import Skeleton from 'react-loading-skeleton' export function AIInsightsCard({ simulationId }: AIInsightCardProps) { const { insight, isLoading, error, fetchInsight } = useInsight(simulationId) return ( <div className="bg-card order-2 rounded-2xl p-6 shadow-[4px_4px_18px_0px_rgba(0,0,0,0.2)] lg:order-1 lg:col-span-2"> <div className="mb-3 flex items-center gap-1.5"> <span>✨</span> <span className="text-primary text-xs font-semibold tracking-widest uppercase"> Insight Financeiro Personalizado </span> </div> {isLoading && ( <div className="flex"> <Skeleton count={10.5} baseColor="var(--color-skeleton-base)" highlightColor="var(--color-skeleton-highlight)" className="mb-3 flex rounded-lg" containerClassName="flex-1" inline /> </div> )} {!isLoading && error && ( <Error simulationId={simulationId} message={error} onRetry={() => fetchInsight(simulationId)} /> )} {!isLoading && insight && <Content insight={insight} />} </div> ) }
-
Adicione as cores do skeleton ao
theme.css:@layer base { :root, [data-theme='light'] { /* ... */ --skeleton-base-color: #e1e1f1; --skeleton-highlight-color: #c5c5df; } [data-theme='dark'] { /* ... */ --skeleton-base-color: #3e3e42; --skeleton-highlight-color: #434052; } } @theme { /* ... */ --color-skeleton-base: var(--skeleton-base-color); --color-skeleton-highlight: var(--skeleton-highlight-color); }
Crie o componente Content para renderizar os insights retornados:
import type { PropsWithChildren } from 'react'
import type { InsightData } from '@/services/aiService'
interface ContentProps {
insight: InsightData
}
function Paragraph({ children }: PropsWithChildren) {
return (
<p className="text-muted-foreground text-sm leading-relaxed">{children}</p>
)
}
function SectionTitle({ children }: PropsWithChildren) {
return (
<h3 className="text-foreground mt-5 mb-1.5 text-sm leading-relaxed font-semibold">
{children}
</h3>
)
}
function OrderedList({ items }: { items: string[] }) {
return (
<ol className="text-muted-foreground ml-6 list-decimal text-sm leading-relaxed">
{items.map((item, index) => (
<li key={index} className="pl-1">
{item}
</li>
))}
</ol>
)
}
const statusStyles = {
viable: {
label: 'Meta viável no prazo',
className:
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
needs_adjustment: {
label: 'Ajuste necessário',
className:
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
},
unfeasible: {
label: 'Meta inviável no prazo',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
}
export function Content({ insight }: ContentProps) {
const status = statusStyles[insight.feasibility.status] ?? null
return (
<div className="lg:scrollbar-thin lg:max-h-93 lg:overflow-y-auto lg:pr-2 lg:[scrollbar-color:var(--border)_transparent]">
<section className="flex flex-col gap-2">
<div className="flex flex-col items-start gap-2 sm:flex-row">
<span className="text-foreground text-sm font-semibold">
🎯 Viabilidade da Meta
</span>
{status && (
<span
className={`w-fit rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.className}`}
>
{status.label}
</span>
)}
</div>
<Paragraph>{insight.feasibility.content}</Paragraph>
</section>
<section>
<SectionTitle>💰 Diagnóstico Financeiro</SectionTitle>
<Paragraph>{insight.diagnosis.content}</Paragraph>
</section>
<section>
<SectionTitle>📋 Sugestões Práticas</SectionTitle>
<OrderedList items={insight.suggestions.items} />
</section>
<section>
<SectionTitle>💡 Como Aumentar sua Renda</SectionTitle>
<OrderedList items={insight.extraIncome.items} />
</section>
<section>
<SectionTitle>🏦 Sugestões de Investimento</SectionTitle>
<OrderedList items={insight.investment.items} />
</section>
<section>
<SectionTitle>🚀 Mensagem Final</SectionTitle>
<Paragraph>{insight.motivation.content}</Paragraph>
</section>
</div>
)
}Envie os insights via props na chamada do componente:
<Content insight={insight} />- Exiba um resumo de cada simulação salva
- Crie um layout responsivo seguindo o protótipo
- Permita excluir uma simulação do histórico
- Ao clicar em "Ver detalhes", navegue para a página de resultados com os insights já gerados
- Adicione um campo de texto dentro do componente
AIInsightCard - Permita que o usuário faça perguntas sobre a simulação realizada
- A IA deve retornar respostas claras e exibi-las seguindo o protótipo
- O scroll deve rolar automaticamente quando a IA retornar uma resposta
- Mostre feedback de carregamento e erro para o usuário
- O usuário pode fazer quantas perguntas quiser por simulação
- Todo o histórico de perguntas e respostas deve ser exibido na tela
- As conversas devem ser salvas no
localStoragepara consulta posterior