diff --git a/package.json b/package.json
index 5fee50c..2c5046f 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.124",
"better-auth": "1.5.4",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
@@ -85,6 +86,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.6",
"@tailwindcss/postcss": "4.2.1",
+ "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.4.0",
"@types/pg": "^8.18.0",
"@types/react": "19.2.14",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 91ae335..b0a26e1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -107,6 +107,9 @@ importers:
better-auth:
specifier: 1.5.4
version: 1.5.4(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.18.0)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.20.0)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(mongodb@7.1.0)(mysql2@3.15.3)(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ canvas-confetti:
+ specifier: ^1.9.4
+ version: 1.9.4
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -177,6 +180,9 @@ importers:
'@tailwindcss/postcss':
specifier: 4.2.1
version: 4.2.1
+ '@types/canvas-confetti':
+ specifier: ^1.9.0
+ version: 1.9.0
'@types/node':
specifier: 25.4.0
version: 25.4.0
@@ -2178,6 +2184,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
+ '@types/canvas-confetti@1.9.0':
+ resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
+
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -2412,6 +2421,9 @@ packages:
caniuse-lite@1.0.30001777:
resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==}
+ canvas-confetti@1.9.4:
+ resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
+
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
@@ -5217,6 +5229,8 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
+ '@types/canvas-confetti@1.9.0': {}
+
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -5382,6 +5396,8 @@ snapshots:
caniuse-lite@1.0.30001777: {}
+ canvas-confetti@1.9.4: {}
+
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.28.6
diff --git a/src/features/dashboard/components/bills/bill-list-item.tsx b/src/features/dashboard/components/bills/bill-list-item.tsx
index 7aace27..98e77ba 100644
--- a/src/features/dashboard/components/bills/bill-list-item.tsx
+++ b/src/features/dashboard/components/bills/bill-list-item.tsx
@@ -19,8 +19,8 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
const overdue = isBillOverdue(bill);
return (
-
-
+
+
@@ -54,7 +54,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
>
{bill.isSettled ? (
- Pago
+ Pago
) : overdue ? (
diff --git a/src/features/dashboard/components/bills/bill-payment-dialog.tsx b/src/features/dashboard/components/bills/bill-payment-dialog.tsx
index 2d5ef99..644514d 100644
--- a/src/features/dashboard/components/bills/bill-payment-dialog.tsx
+++ b/src/features/dashboard/components/bills/bill-payment-dialog.tsx
@@ -1,6 +1,6 @@
import {
RiBarcodeFill,
- RiCheckboxCircleLine,
+ RiCalendarLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
@@ -11,6 +11,7 @@ import {
} from "@/features/dashboard/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries";
import MoneyValues from "@/shared/components/money-values";
+import { PaymentSuccess } from "@/shared/components/payment-success";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
@@ -68,68 +69,46 @@ export function BillPaymentDialog({
}}
>
{modalState === "success" ? (
-
-
-
-
-
-
- Pagamento registrado!
-
-
- Atualizamos o status do boleto para pago. Em instantes ele
- aparecerá como baixado no histórico.
-
-
-
-
- Fechar
-
-
-
+
) : (
<>
- Confirmar pagamento do boleto
-
- Confirme os dados para registrar o pagamento. Você poderá editar
- o lançamento depois, se necessário.
-
+
+
+
+
+
+ Confirmar pagamento
+
+ Boleto
+
+
+
{bill ? (
-
-
-
-
-
-
-
-
-
- Boleto
-
-
- {bill.name}
-
-
-
- {dueLabel ? (
-
- ) : null}
-
+
+ {/* Card principal */}
+
+
+ Boleto
+
+
+ {bill.name}
+
-
-
-
-
-
- Valor do Boleto
+ {/* Métricas */}
+
+
-
-
-
-
- Status
+
+
+
+
+
+ Vencimento
-
- {bill.isSettled ? "Pago" : "Pendente"}
-
+
+ {dueLabel?.replace("Vencimento: ", "") ?? "—"}
+
+
+ {/* Status */}
+
+
+ Status atual
+
+
+ {bill.isSettled ? "Pago" : "Pendente"}
+
+
+
+ {/* Aviso */}
+
+ Você poderá editar o lançamento depois, se necessário.
+
) : null}
@@ -169,7 +164,6 @@ export function BillPaymentDialog({
type="button"
onClick={onConfirm}
disabled={isProcessing || !bill || bill.isSettled}
- className="relative"
>
{isProcessing ? (
<>
diff --git a/src/features/dashboard/components/invoices/invoice-list-item.tsx b/src/features/dashboard/components/invoices/invoice-list-item.tsx
index 40b83c7..acd72c5 100644
--- a/src/features/dashboard/components/invoices/invoice-list-item.tsx
+++ b/src/features/dashboard/components/invoices/invoice-list-item.tsx
@@ -55,8 +55,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
);
return (
-
-
+
+
{isPaid ? (
- Pago
+ Pago
) : isOverdue ? (
@@ -147,6 +147,6 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)}
-
+
);
}
diff --git a/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx b/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx
index 731ca17..7384555 100644
--- a/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx
+++ b/src/features/dashboard/components/invoices/invoice-payment-dialog.tsx
@@ -1,5 +1,6 @@
import {
- RiCheckboxCircleLine,
+ RiBankCardLine,
+ RiCalendarLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
@@ -11,6 +12,7 @@ import {
} from "@/features/dashboard/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
import MoneyValues from "@/shared/components/money-values";
+import { PaymentSuccess } from "@/shared/components/payment-success";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
@@ -46,6 +48,9 @@ export function InvoicePaymentDialog({
}: InvoicePaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending;
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
+ const dueInfo = invoice
+ ? parseInvoiceDueDate(invoice.period, invoice.dueDay)
+ : null;
return (
{modalState === "success" ? (
-
-
-
-
-
-
- Pagamento confirmado!
-
-
- Atualizamos o status da fatura. O lançamento do pagamento
- aparecerá no extrato em instantes.
-
-
-
-
- Fechar
-
-
-
+
) : (
<>
- Confirmar pagamento
-
- Revise os dados antes de confirmar. Vamos registrar a fatura
- como paga.
-
+
+
+
+
+
+ Confirmar pagamento
+
+ Fatura do cartão
+
+
+
{invoice ? (
-
-
-
-
-
-
-
- Cartão
-
-
- {invoice.cardName}
-
-
-
-
- {invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
-
- {
- parseInvoiceDueDate(invoice.period, invoice.dueDay)
- .label
- }
-
- ) : null}
- {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
- paymentInfo ? (
-
- {paymentInfo.label}
-
- ) : null}
-
+
+ {/* Card principal */}
+
+
+
+
+ Cartão
+
+
+ {invoice.cardName}
+
-
-
-
-
-
- Valor da Invoice
+ {/* Métricas */}
+
+
-
-
-
-
- Status
+
+
+
+
+
+ {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
+ ? "Pago em"
+ : "Vencimento"}
-
- {INVOICE_STATUS_LABEL[invoice.paymentStatus]}
-
+
+ {invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
+ ? (paymentInfo?.label ?? "—")
+ : (dueInfo?.label ?? "—")}
+
+
+ {/* Status */}
+
+
+ Status atual
+
+
+ {INVOICE_STATUS_LABEL[invoice.paymentStatus]}
+
+
+
+ {/* Aviso */}
+
+ Vamos registrar a fatura como paga. Você poderá editar depois
+ se necessário.
+
) : null}
@@ -186,7 +186,6 @@ export function InvoicePaymentDialog({
type="button"
onClick={onConfirm}
disabled={isProcessing || !invoice}
- className="relative"
>
{isProcessing ? (
<>
diff --git a/src/features/invoices/actions.ts b/src/features/invoices/actions.ts
index c3007d3..d159e13 100644
--- a/src/features/invoices/actions.ts
+++ b/src/features/invoices/actions.ts
@@ -47,7 +47,7 @@ type ActionResult =
| { success: false; error: string };
const successMessageByStatus: Record
= {
- [INVOICE_PAYMENT_STATUS.PAID]: "Invoice marcada como paga.",
+ [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
diff --git a/src/shared/components/payment-success.tsx b/src/shared/components/payment-success.tsx
new file mode 100644
index 0000000..750ef92
--- /dev/null
+++ b/src/shared/components/payment-success.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { RiCheckboxCircleFill } from "@remixicon/react";
+import confetti from "canvas-confetti";
+import { useEffect } from "react";
+import { Button } from "@/shared/components/ui/button";
+import {
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from "@/shared/components/ui/dialog";
+
+// Tons baseados na primary: oklch(72% 0.163 50) ≈ laranja quente
+const PRIMARY_CONFETTI_COLORS = [
+ "#e07a3a", // primary base
+ "#f5a870", // primary claro
+ "#ffd4a8", // primary muito claro
+ "#b85520", // primary escuro
+ "#8a3a10", // primary muito escuro
+ "#f5c896", // tom pastel
+];
+
+type PaymentSuccessProps = {
+ title: string;
+ description: string;
+ onClose: () => void;
+};
+
+export function PaymentSuccess({
+ title,
+ description,
+ onClose,
+}: PaymentSuccessProps) {
+ useEffect(() => {
+ const origin = { x: 0.5, y: 0.4 };
+
+ confetti({
+ particleCount: 80,
+ spread: 70,
+ origin,
+ colors: PRIMARY_CONFETTI_COLORS,
+ startVelocity: 28,
+ gravity: 1.2,
+ scalar: 0.9,
+ ticks: 200,
+ });
+
+ const t1 = setTimeout(() => {
+ confetti({
+ particleCount: 40,
+ spread: 50,
+ origin: { x: 0.3, y: 0.45 },
+ colors: PRIMARY_CONFETTI_COLORS,
+ startVelocity: 22,
+ gravity: 1.1,
+ scalar: 0.8,
+ ticks: 180,
+ });
+ }, 150);
+
+ const t2 = setTimeout(() => {
+ confetti({
+ particleCount: 40,
+ spread: 50,
+ origin: { x: 0.7, y: 0.45 },
+ colors: PRIMARY_CONFETTI_COLORS,
+ startVelocity: 22,
+ gravity: 1.1,
+ scalar: 0.8,
+ ticks: 180,
+ });
+ }, 250);
+
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }, []);
+
+ return (
+
+
+ {/* Anel de pulso */}
+
+
+
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+ Fechar
+
+
+
+ );
+}