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
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-26 22:30:46 -03:00
1 parent 8271d1fa31
commit 5ccc9120c4
4 files changed
+232 -40

No files matched your search

+7 -1
View File
@@ -170,7 +170,7 @@ export default async function SupportPage({
<h2 className="text-3xl font-bold text-center mb-8">
Escolha como apoiar
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
<DonationCard
id="coffee"
amount={5}
@@ -196,6 +196,12 @@ export default async function SupportPage({
icon="pizza"
badge={t("tiers.lunch.badge")}
/>
<DonationCard
id="custom"
title="Personalizado"
description="Escolha quanto quer doar"
icon="heart"
/>
</div>
</div>
</BlurFade>
+44 -6
View File
@@ -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,
+173 -33
View File
@@ -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<HTMLInputElement>) => {
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 (
<>
<div
@@ -347,27 +432,82 @@ export function DonationCard({
<h3 className="text-2xl font-bold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
{amount && (
<div className="text-4xl font-bold text-primary">
R$ {amount.toFixed(2)}
</div>
)}
{id === "custom" ? (
<>
<div className="w-full space-y-4">
<div className="space-y-2">
<label
htmlFor="customAmount"
className="text-sm text-muted-foreground"
>
Valor (R$)
</label>
<Input
id="customAmount"
type="text"
inputMode="decimal"
placeholder="Ex: 15,50 ou 15.50"
value={customAmount}
onChange={handleCustomAmountChange}
className="text-lg text-center"
/>
<p className="text-xs text-muted-foreground">
Mín: R$ {customProduct.minAmount.toFixed(2)} | Máx: R${" "}
{customProduct.maxAmount.toFixed(2)}
</p>
</div>
{customAmount && !isValidCustomAmount() && (
<p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount
? `Mínimo é R$ ${customProduct.minAmount.toFixed(2)}`
: `Máximo é R$ ${customProduct.maxAmount.toFixed(2)}`}
</p>
)}
</div>
<Button
size="lg"
className="w-full"
onClick={handleDonateClick}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processando...
</>
) : (
"Apoiar com PIX"
)}
</Button>
<Button
size="lg"
className="w-full"
onClick={handleCustomDonationClick}
disabled={loading || !isValidCustomAmount()}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processando...
</>
) : customAmount ? (
`Doar R$ ${parseFloat(customAmount || "0").toFixed(2)}`
) : (
"Escolha o valor"
)}
</Button>
</>
) : (
<>
{amount && (
<div className="text-4xl font-bold text-primary">
R$ {amount.toFixed(2)}
</div>
)}
<Button
size="lg"
className="w-full"
onClick={handleDonateClick}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processando...
</>
) : (
"Apoiar com PIX"
)}
</Button>
</>
)}
</div>
</div>
+8
View File
@@ -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;