From 4c1e8bfeca4e323d8f253c14df7c84447a3941ad Mon Sep 17 00:00:00 2001 From: Tito Motter Date: Fri, 27 Feb 2026 16:16:54 -0300 Subject: [PATCH 01/10] feat(accounts): implement account balance transfer logic and BRL input mask --- src/app/(app)/accounts/page.tsx | 57 ++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/app/(app)/accounts/page.tsx b/src/app/(app)/accounts/page.tsx index e24ab5d..e48b4a0 100644 --- a/src/app/(app)/accounts/page.tsx +++ b/src/app/(app)/accounts/page.tsx @@ -142,11 +142,15 @@ export default function AccountsPage() { const [transferIntent, setTransferIntent] = useState<{ sourceId: string; targetId: string } | null>(null); const [transferValue, setTransferValue] = useState(""); - const totalGiro = MOCK_GIRO.reduce((acc, curr) => acc + curr.balance, 0); - const totalCreditBills = MOCK_CREDIT.reduce((acc, curr) => acc + (curr.creditUsed || 0), 0); + const [giroAccounts, setGiroAccounts] = useState(MOCK_GIRO); + const [creditAccounts, setCreditAccounts] = useState(MOCK_CREDIT); + const [vaultAccounts, setVaultAccounts] = useState(MOCK_VAULT); + + const totalGiro = giroAccounts.reduce((acc, curr) => acc + curr.balance, 0); + const totalCreditBills = creditAccounts.reduce((acc, curr) => acc + (curr.creditUsed || 0), 0); const realLiquidity = totalGiro - totalCreditBills; - const totalReserves = MOCK_VAULT.reduce((acc, curr) => acc + curr.balance, 0); + const totalReserves = vaultAccounts.reduce((acc, curr) => acc + curr.balance, 0); const formatCurrency = (val: number) => { return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(val); @@ -184,10 +188,37 @@ export default function AccountsPage() { }; // Combine all mock data to find specific accounts for the transfer modal - const ALL_ACCOUNTS = [...MOCK_GIRO, ...MOCK_CREDIT, ...MOCK_VAULT]; + const ALL_ACCOUNTS = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + + const handleTransferValueChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, ""); + if (!value) { + setTransferValue(""); + return; + } + const numericValue = parseInt(value, 10) / 100; + const formatted = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + setTransferValue(formatted); + }; const executeTransfer = () => { - console.log(`Transferring ${transferValue} from ${transferIntent?.sourceId} to ${transferIntent?.targetId}`); + const value = parseFloat(transferValue.replace(/\./g, "").replace(",", ".")); + if (isNaN(value) || value <= 0 || !transferIntent) return; + + const { sourceId, targetId } = transferIntent; + + const updateBalance = (id: string, amount: number) => { + setGiroAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + setCreditAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + setVaultAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + }; + + updateBalance(sourceId, -value); + updateBalance(targetId, value); + setTransferIntent(null); setTransferValue(""); }; @@ -232,7 +263,7 @@ export default function AccountsPage() { {/* Accordions */}
- {MOCK_GIRO.map((account) => ( + {giroAccounts.map((account) => (
- {MOCK_CREDIT.map((account) => ( + {creditAccounts.map((account) => (
- {MOCK_VAULT.map((account) => ( + {vaultAccounts.map((account) => (
setTransferValue(e.target.value)} + onChange={handleTransferValueChange} autoFocus className="w-full text-center text-5xl font-bold bg-transparent border-none outline-none text-zinc-100 placeholder:text-zinc-800" placeholder="0,00" @@ -366,7 +398,10 @@ export default function AccountsPage() { + + +
+ + + Se você não esperava por este convite, por favor, ignore este email de forma segura. O + link expirará em breve. + + + +
+ + © {new Date().getFullYear()} Finiza. Todos os direitos reservados. + + + Se precisar de ajuda, entre em contato através do nosso{" "} + + suporte + + . + +
+ + + + + ); +}; + +const main = { + backgroundColor: "#09090b", + backgroundImage: "radial-gradient(circle at 50% -20%, rgba(120, 119, 198, 0.15), transparent)", + color: "#fafafa", + margin: "0", + padding: "0", + fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', +}; + +const containerWrapper = { + padding: "40px 20px", +}; + +const container = { + maxWidth: "600px", + margin: "0 auto", + backgroundColor: "#18181b", + background: "rgba(24, 24, 27, 0.6)", + borderRadius: "16px", + border: "1px solid rgba(255, 255, 255, 0.1)", + boxShadow: "0 8px 32px 0 rgba(0, 0, 0, 0.3)", + overflow: "hidden", +}; + +const header = { + padding: "32px 24px", + textAlign: "center" as const, + borderBottom: "1px solid rgba(255, 255, 255, 0.05)", +}; + +const logo = { + color: "#fafafa", + fontSize: "28px", + fontWeight: "700", + letterSpacing: "-1px", + textDecoration: "none", + margin: "0", +}; + +const logoAccent = { + color: "#34d399", +}; + +const content = { + padding: "40px 32px", +}; + +const title = { + fontSize: "22px", + fontWeight: "600", + color: "#fafafa", + marginTop: "0", + marginBottom: "16px", + textAlign: "center" as const, +}; + +const text = { + color: "#a1a1aa", + fontSize: "16px", + textAlign: "center" as const, + lineHeight: "1.6", + margin: "0 0 24px 0", +}; + +const buttonContainer = { + textAlign: "center" as const, + marginBottom: "32px", + marginTop: "32px", +}; + +const button = { + backgroundColor: "#10b981", + backgroundImage: "linear-gradient(to right, #34d399, #10b981)", + color: "#09090b", + fontWeight: "600", + textDecoration: "none", + padding: "14px 28px", + borderRadius: "8px", + fontSize: "16px", + boxShadow: "0 4px 14px 0 rgba(16, 185, 129, 0.39)", + display: "inline-block", +}; + +const divider = { + height: "1px", + backgroundColor: "rgba(255, 255, 255, 0.1)", + margin: "32px 0 24px 0", + border: "none", + width: "100%", +}; + +const footer = { + padding: "0 32px 32px", + textAlign: "center" as const, +}; + +const footerText = { + margin: "0 0 8px 0", + fontSize: "13px", + color: "#71717a", +}; + +const link = { + color: "#34d399", + textDecoration: "none", +}; + +export default AccountInviteEmail; diff --git a/src/components/invite/InviteView.tsx b/src/components/invite/InviteView.tsx new file mode 100644 index 0000000..1656b13 --- /dev/null +++ b/src/components/invite/InviteView.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { GlassCard } from "@/components/ui/GlassCard"; +import { Loader2, UserPlus, CheckCircle2, ArrowRight } from "lucide-react"; + +export function InviteView() { + const searchParams = useSearchParams(); + const router = useRouter(); + const supabase = createClient(); + + const email = searchParams?.get("email") || ""; + const role = searchParams?.get("role") || "Leitor"; + const account = searchParams?.get("account") || "Indefinida"; + + const [loading, setLoading] = useState(true); + const [userEmail, setUserEmail] = useState(null); + const [accepted, setAccepted] = useState(false); + + useEffect(() => { + let isMounted = true; + + const checkAuth = async () => { + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!isMounted) return; + + if (!session) { + // Usuário não autenticado: enviamos para /auth com a query redirect_to apontando de volta pra cá + const currentFullParams = searchParams ? searchParams.toString() : ""; + const inviteUrl = `/invite?${currentFullParams}`; + router.push(`/auth?redirect_to=${encodeURIComponent(inviteUrl)}`); + return; + } + + // Autenticado: continua a renderizar o convite + setUserEmail(session.user.email ?? null); + setLoading(false); + }; + + checkAuth(); + + return () => { + isMounted = false; + }; + }, [router, supabase, searchParams]); + + const handleAcceptInvite = async () => { + setLoading(true); + // FIXME: Aqui entraria a chamada de API real do Supabase + // para inserir o registro na tabela de relacionamentos (account_users) + await new Promise((resolve) => setTimeout(resolve, 1500)); + setAccepted(true); + setLoading(false); + }; + + if (loading) { + return ( + + +

Verificando informações...

+
+ ); + } + + if (accepted) { + return ( + +
+ +
+
+

Convite Aceito!

+

+ Agora você tem acesso à conta {account} com permissão + de {role}. +

+
+ +
+ ); + } + + return ( + +
+ +
+ +
+

Convite para Conta

+

+ Você foi convidado(a) para acessar a Sincronia Doméstica desta conta no Finiza. +

+
+ +
+
+ Conta Base + + {account} + +
+
+ Sua Permissão + {role} +
+
+ + {email && email !== userEmail && ( +
+ Aviso: O convite visava {email}, mas você está conectando como {userEmail}. Ao + prosseguir, o vínculo será feito ao perfil atual. +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/components/ui/AccountSlideOver.tsx b/src/components/ui/AccountSlideOver.tsx index e50acc0..da213f5 100644 --- a/src/components/ui/AccountSlideOver.tsx +++ b/src/components/ui/AccountSlideOver.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect } from "react"; -import { X, Check, Trash2, Edit2, Zap } from "lucide-react"; +import { X, Check, Trash2, Edit2, Zap, Plus, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { sendAccountInvite } from "@/app/actions/sendAccountInvite"; interface AccountSlideOverProps { isOpen: boolean; onClose: () => void; - accountId: string; + accountId?: string; name: string; institution: string; balance: number; @@ -15,7 +16,7 @@ interface AccountSlideOverProps { export function AccountSlideOver({ isOpen, onClose, - accountId, + // accountId is currently unused but kept for interface consistency name, institution, balance, @@ -25,6 +26,14 @@ export function AccountSlideOver({ const [adjustedBalance, setAdjustedBalance] = useState(balance.toString()); const [swipeLeftId, setSwipeLeftId] = useState(null); + const [isEditingInstitution, setIsEditingInstitution] = useState(false); + const [tempInstitution, setTempInstitution] = useState(institution); + + const [isAddingPerson, setIsAddingPerson] = useState(false); + const [isInviting, setIsInviting] = useState(false); + const [newPersonEmail, setNewPersonEmail] = useState(""); + const [addedPersons, setAddedPersons] = useState<{ email: string; role: string }[]>([]); + useEffect(() => { if (isOpen) { document.body.style.overflow = "hidden"; @@ -50,6 +59,36 @@ export function AccountSlideOver({ onClose(); }; + const handleInvitePerson = async () => { + if (!newPersonEmail) return; + + setIsInviting(true); + try { + // Fake API call or connect to server action + const res = await sendAccountInvite({ + email: newPersonEmail, + inviterName: "Você", // Default mock as the current auth user + accountName: name, + role: "Leitor", + }); + + if (res.success) { + // If ok, add dynamically + setAddedPersons((prev) => [...prev, { email: newPersonEmail, role: "Leitor" }]); + setNewPersonEmail(""); + setIsAddingPerson(false); + } else { + console.error("Failed to send invite:", res.error); + alert("Falha ao enviar convite. " + res.error); + } + } catch (error) { + console.error("Unexpected error:", error); + alert("Erro inesperado ao enviar convite."); + } finally { + setIsInviting(false); + } + }; + return ( <> {/* Backdrop */} @@ -77,7 +116,7 @@ export function AccountSlideOver({

{name}

-

{institution}

+

{tempInstitution}

- + {isEditingInstitution ? ( +
+ setTempInstitution(e.target.value)} + className="flex-1 bg-transparent px-3 py-2 text-sm outline-none text-zinc-100" + placeholder="Nubank, Itaú..." + onKeyDown={(e) => { + if (e.key === "Enter") setIsEditingInstitution(false); + }} + /> + +
+ ) : ( +
+ {tempInstitution} + +
+ )}
@@ -235,7 +309,7 @@ export function AccountSlideOver({
-
+
EU
@@ -244,9 +318,77 @@ export function AccountSlideOver({
- + + {addedPersons.map((person, idx) => ( +
+
+
+ {person.email.charAt(0)} +
+
+ {person.email.split("@")[0]}{" "} + ({person.role}) +
+
+ +
+ ))} + + {isAddingPerson ? ( +
+
+ setNewPersonEmail(e.target.value)} + placeholder="E-mail do convidado..." + className="flex-1 bg-transparent px-3 py-2 text-sm outline-none text-zinc-100 placeholder:text-zinc-700" + disabled={isInviting} + onKeyDown={(e) => { + if (e.key === "Enter" && newPersonEmail && !isInviting) { + handleInvitePerson(); + } + }} + /> +
+ + +
+ ) : ( + + )}
From 716bc7bfc2aff99705495f1d57de36aaaec42f44 Mon Sep 17 00:00:00 2001 From: Tito Motter Date: Fri, 27 Feb 2026 17:33:10 -0300 Subject: [PATCH 03/10] chore: add .env.local.template --- .env.local.template | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .env.local.template diff --git a/.env.local.template b/.env.local.template new file mode 100644 index 0000000..910d4dd --- /dev/null +++ b/.env.local.template @@ -0,0 +1,6 @@ +# Supabase +NEXT_PUBLIC_SUPABASE_URL="YOUR_SUPABASE_PROJECT_URL" +NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY" + +# Resend (Email Service) +RESEND_API_KEY="YOUR_RESEND_API_KEY" From a678c5a621fe5b71176119c2ff5e8d079e2c5dd9 Mon Sep 17 00:00:00 2001 From: Tito Motter Date: Fri, 27 Feb 2026 17:40:10 -0300 Subject: [PATCH 04/10] feat: add currency input mask to the account balance slideover --- src/components/ui/AccountSlideOver.tsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/ui/AccountSlideOver.tsx b/src/components/ui/AccountSlideOver.tsx index da213f5..708a40a 100644 --- a/src/components/ui/AccountSlideOver.tsx +++ b/src/components/ui/AccountSlideOver.tsx @@ -23,7 +23,23 @@ export function AccountSlideOver({ colorHex, }: AccountSlideOverProps) { const [activeTab, setActiveTab] = useState<"ajuste" | "historico" | "config">("ajuste"); - const [adjustedBalance, setAdjustedBalance] = useState(balance.toString()); + const [adjustedBalance, setAdjustedBalance] = useState(() => + new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(balance), + ); + + const handleBalanceChange = (e: React.ChangeEvent) => { + let value = e.target.value.replace(/\D/g, ""); + if (value === "") value = "0"; + const numericValue = parseInt(value, 10) / 100; + const formatted = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + setAdjustedBalance(formatted); + }; const [swipeLeftId, setSwipeLeftId] = useState(null); const [isEditingInstitution, setIsEditingInstitution] = useState(false); @@ -158,10 +174,12 @@ export function AccountSlideOver({ R$ setAdjustedBalance(e.target.value)} - className="w-full text-center text-4xl sm:text-5xl font-bold bg-transparent border-none outline-none text-zinc-100 placeholder:text-zinc-700 w-[280px]" + onChange={handleBalanceChange} + className="w-full text-center text-4xl sm:text-5xl font-bold bg-transparent border-none outline-none text-zinc-100 placeholder:text-zinc-700" + style={{ width: "280px" }} />
From f513c16430923ef214f75e1e193af4cdb6ecb8eb Mon Sep 17 00:00:00 2001 From: Tito Motter Date: Fri, 27 Feb 2026 18:24:32 -0300 Subject: [PATCH 05/10] style: fix balance input width and currency prefix positioning --- src/components/ui/AccountSlideOver.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/ui/AccountSlideOver.tsx b/src/components/ui/AccountSlideOver.tsx index 708a40a..a4bf44a 100644 --- a/src/components/ui/AccountSlideOver.tsx +++ b/src/components/ui/AccountSlideOver.tsx @@ -169,17 +169,15 @@ export function AccountSlideOver({

Saldo real no app do banco

-
- - R$ - +
+ R$
From 85b2008f0a34e1f893df996bf8c3080c62684031 Mon Sep 17 00:00:00 2001 From: Tito Motter Date: Fri, 27 Feb 2026 18:44:04 -0300 Subject: [PATCH 06/10] feat: allow editing account category and block transfers to credit cards --- src/app/(app)/accounts/page.tsx | 12 +++++ src/components/ui/AccountSlideOver.tsx | 69 +++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/accounts/page.tsx b/src/app/(app)/accounts/page.tsx index e48b4a0..ed43f4a 100644 --- a/src/app/(app)/accounts/page.tsx +++ b/src/app/(app)/accounts/page.tsx @@ -182,6 +182,17 @@ export default function AccountsPage() { e.preventDefault(); setDragOverId(null); if (draggedAccountId && draggedAccountId !== targetId) { + const tempAll = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + const targetAccount = tempAll.find((a) => a.id === targetId); + + if (targetAccount?.category === "credit") { + alert( + "Não é possível transferir saldo para um Cartão de Crédito. Ele deve estar vinculado a uma conta corrente.", + ); + setDraggedAccountId(null); + return; + } + setTransferIntent({ sourceId: draggedAccountId, targetId }); } setDraggedAccountId(null); @@ -330,6 +341,7 @@ export default function AccountsPage() { institution={selectedAccount.institution} balance={selectedAccount.balance} colorHex={selectedAccount.colorHex} + category={selectedAccount.category} /> )} diff --git a/src/components/ui/AccountSlideOver.tsx b/src/components/ui/AccountSlideOver.tsx index a4bf44a..4ad32b0 100644 --- a/src/components/ui/AccountSlideOver.tsx +++ b/src/components/ui/AccountSlideOver.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { X, Check, Trash2, Edit2, Zap, Plus, Loader2 } from "lucide-react"; +import { X, Check, Trash2, Edit2, Zap, Plus, Loader2, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { sendAccountInvite } from "@/app/actions/sendAccountInvite"; @@ -11,6 +11,7 @@ interface AccountSlideOverProps { institution: string; balance: number; colorHex: string; + category?: string; } export function AccountSlideOver({ @@ -21,7 +22,14 @@ export function AccountSlideOver({ institution, balance, colorHex, + category = "checking", }: AccountSlideOverProps) { + const [tempCategory, setTempCategory] = useState(category); + + useEffect(() => { + setTempCategory(category); + }, [category]); + const [activeTab, setActiveTab] = useState<"ajuste" | "historico" | "config">("ajuste"); const [adjustedBalance, setAdjustedBalance] = useState(() => new Intl.NumberFormat("pt-BR", { @@ -257,6 +265,65 @@ export function AccountSlideOver({ {activeTab === "config" && (
+
+ +
+ +
+ +
+
+
+ + {tempCategory === "credit" && ( +
+ +
+ +
+ +
+
+

+ O pagamento das faturas deste cartão será debitado desta conta no seu dashboard. +

+
+ )} +