From 5ccc9120c40370608ae7cf1ef47d42b8355e2b66 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 26 Oct 2025 22:30:46 -0300 Subject: [PATCH] feat(donations): add custom donation tier with flexible amount selection - Updated support page grid layout to accommodate new custom donation card - Enhanced API route to validate and process custom donation amounts (min/max limits) - Modified donation card component to include custom amount input field - Ensures users can donate any amount within specified range, improving donation flexibility --- app/[locale]/apoiar/page.tsx | 8 +- app/api/donations/create/route.ts | 50 +++++++- components/donation-card.tsx | 206 +++++++++++++++++++++++++----- types/donation.ts | 8 ++ 4 files changed, 232 insertions(+), 40 deletions(-) diff --git a/app/[locale]/apoiar/page.tsx b/app/[locale]/apoiar/page.tsx index a3dbc8a..13ceddd 100644 --- a/app/[locale]/apoiar/page.tsx +++ b/app/[locale]/apoiar/page.tsx @@ -170,7 +170,7 @@ export default async function SupportPage({

Escolha como apoiar

-
+
+
diff --git a/app/api/donations/create/route.ts b/app/api/donations/create/route.ts index f7bf29d..7037ad0 100644 --- a/app/api/donations/create/route.ts +++ b/app/api/donations/create/route.ts @@ -11,6 +11,7 @@ export async function POST(request: NextRequest) { "coffee", "snack", "lunch", + "custom", ]; if (!body.tier || !validTiers.includes(body.tier)) { return NextResponse.json( @@ -22,14 +23,49 @@ export async function POST(request: NextRequest) { const product = DONATION_PRODUCTS[body.tier as keyof typeof DONATION_PRODUCTS]; + // Se for custom, validar valor enviado + let finalAmount: number; + let finalDescription: string; + + if (body.tier === "custom") { + if (!body.customAmount || typeof body.customAmount !== "number") { + return NextResponse.json( + { success: false, error: "Valor customizado inválido" }, + { status: 400 } + ); + } + + const minAmount = DONATION_PRODUCTS.custom.minAmount; + const maxAmount = DONATION_PRODUCTS.custom.maxAmount; + + if (body.customAmount < minAmount || body.customAmount > maxAmount) { + return NextResponse.json( + { + success: false, + error: `Valor deve estar entre R$ ${minAmount} e R$ ${maxAmount}`, + }, + { status: 400 } + ); + } + + // Converter para centavos + finalAmount = Math.round(body.customAmount * 100); + finalDescription = `Doação Personalizada - R$ ${body.customAmount.toFixed( + 2 + )}`; + } else { + finalAmount = product.price; + finalDescription = product.name; + } + let pixData; // Fluxo: Anônimo ou Identificado if (body.anonymous === true) { // Doação anônima - sem customer pixData = await abacatePay.createPixQrCode({ - amount: product.price, - description: product.name, + amount: finalAmount, + description: finalDescription, expiresIn: 600, }); @@ -38,8 +74,9 @@ export async function POST(request: NextRequest) { console.log("📝 Registro de doação anônima:", { type: "donation_created", pixId: pixData.id, - amount: product.price, + amount: finalAmount, tier: body.tier, + customAmount: body.tier === "custom" ? body.customAmount : undefined, anonymous: true, createdAt: new Date().toISOString(), }); @@ -87,8 +124,8 @@ export async function POST(request: NextRequest) { } pixData = await abacatePay.createPixQrCode({ - amount: product.price, - description: product.name, + amount: finalAmount, + description: finalDescription, expiresIn: 600, customer: { name: body.donorName.trim(), @@ -103,8 +140,9 @@ export async function POST(request: NextRequest) { console.log("📝 Registro de doação identificada:", { type: "donation_created", pixId: pixData.id, - amount: product.price, + amount: finalAmount, tier: body.tier, + customAmount: body.tier === "custom" ? body.customAmount : undefined, anonymous: false, donor: { name: body.donorName, diff --git a/components/donation-card.tsx b/components/donation-card.tsx index 0a2b28c..600df42 100644 --- a/components/donation-card.tsx +++ b/components/donation-card.tsx @@ -17,6 +17,7 @@ import { User, CheckCircle, CheckCircle2, + DollarSign, } from "lucide-react"; import { Dialog, @@ -45,6 +46,7 @@ export function DonationCard({ const [donorPhone, setDonorPhone] = useState(""); const [donorTaxId, setDonorTaxId] = useState(""); const [donorMessage, setDonorMessage] = useState(""); + const [customAmount, setCustomAmount] = useState(""); // Estados para PIX const [loading, setLoading] = useState(false); @@ -199,13 +201,20 @@ export function DonationCard({ setShowChoiceModal(false); try { + const requestBody: any = { + tier: id, + anonymous: true, + }; + + // Adicionar valor customizado se for custom + if (id === "custom" && customAmount) { + requestBody.customAmount = parseFloat(customAmount); + } + const response = await fetch("/api/donations/create", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tier: id, - anonymous: true, - }), + body: JSON.stringify(requestBody), }); const data: DonationResponse = await response.json(); @@ -270,18 +279,25 @@ export function DonationCard({ setError(null); try { + const requestBody: any = { + tier: id, + anonymous: false, + donorName: donorName.trim(), + donorEmail: donorEmail.trim(), + donorPhone: donorPhone.replace(/\D/g, ""), // Apenas números + donorTaxId: donorTaxId.replace(/\D/g, ""), // Apenas números + donorMessage: donorMessage.trim() || undefined, + }; + + // Adicionar valor customizado se for custom + if (id === "custom" && customAmount) { + requestBody.customAmount = parseFloat(customAmount); + } + const response = await fetch("/api/donations/create", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tier: id, - anonymous: false, - donorName: donorName.trim(), - donorEmail: donorEmail.trim(), - donorPhone: donorPhone.replace(/\D/g, ""), // Apenas números - donorTaxId: donorTaxId.replace(/\D/g, ""), // Apenas números - donorMessage: donorMessage.trim() || undefined, - }), + body: JSON.stringify(requestBody), }); const data: DonationResponse = await response.json(); @@ -320,6 +336,75 @@ export function DonationCard({ } }; + // Funções para doação customizada + const { DONATION_PRODUCTS } = require("@/types/donation"); + const customProduct = DONATION_PRODUCTS.custom; + + const isValidCustomAmount = () => { + const amount = parseFloat(customAmount); + return ( + !isNaN(amount) && + amount >= customProduct.minAmount && + amount <= customProduct.maxAmount + ); + }; + + const formatCurrency = (value: string): string => { + // Permite vírgula durante digitação, mas converte para ponto + // Remove tudo exceto números, vírgulas e pontos + const cleaned = value.replace(/[^\d.,]/g, ""); + + // Se tem vírgula, troca por ponto + const normalized = cleaned.replace(/,/g, "."); + + // Garante apenas um separador decimal + const parts = normalized.split("."); + if (parts.length > 2) { + return parts[0] + "." + parts.slice(1).join(""); + } + + // Limita 2 casas decimais + if (parts[1]?.length > 2) { + return parts[0] + "." + parts[1].substring(0, 2); + } + + return normalized; + }; + + const handleCustomAmountChange = (e: React.ChangeEvent) => { + const formatted = formatCurrency(e.target.value); + + // Se o usuário digitou vírgula e ainda não há ponto, adiciona vírgula temporariamente + const rawValue = e.target.value; + if ( + rawValue.includes(",") && + !rawValue.includes(".") && + !rawValue.replace(/[^\d,]/g, "").includes(".") + ) { + // Permite a vírgula momentaneamente, mas converte ao processar + setCustomAmount(formatted); + } else { + setCustomAmount(formatted); + } + }; + + const handleCustomDonationClick = () => { + if (!customAmount) { + setError("Digite um valor"); + return; + } + const amount = parseFloat(customAmount); + if (!isValidCustomAmount()) { + setError( + `Valor deve estar entre R$ ${customProduct.minAmount.toFixed( + 2 + )} e R$ ${customProduct.maxAmount.toFixed(2)}` + ); + return; + } + handleDonateClick(); + }; + return ( <>
{title}

{description}

- {amount && ( -
- R$ {amount.toFixed(2)} -
- )} + {id === "custom" ? ( + <> +
+
+ + +

+ Mín: R$ {customProduct.minAmount.toFixed(2)} | Máx: R${" "} + {customProduct.maxAmount.toFixed(2)} +

+
+ {customAmount && !isValidCustomAmount() && ( +

+ {parseFloat(customAmount) < customProduct.minAmount + ? `Mínimo é R$ ${customProduct.minAmount.toFixed(2)}` + : `Máximo é R$ ${customProduct.maxAmount.toFixed(2)}`} +

+ )} +
- + + + ) : ( + <> + {amount && ( +
+ R$ {amount.toFixed(2)} +
+ )} + + + + )}
diff --git a/types/donation.ts b/types/donation.ts index 642de12..fcd4aa4 100644 --- a/types/donation.ts +++ b/types/donation.ts @@ -34,6 +34,14 @@ export const DONATION_PRODUCTS = { amount: 25, price: 2500, }, + custom: { + id: "custom", + name: "Valor Personalizado", + amount: 0, + price: 0, + minAmount: 1, // R$ 1,00 + maxAmount: 10000, // R$ 10.000,00 + }, } as const; export type DonationTierId = keyof typeof DONATION_PRODUCTS;