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

+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>