Files
pagodera/components/donation-card.tsx
2025-11-11 03:08:36 -03:00

865 lines
27 KiB
TypeScript

"use client";
import confetti from "canvas-confetti";
import {
Check,
CheckCircle,
CheckCircle2,
Coffee,
Copy,
Heart,
Loader2,
Lock,
Pizza,
User,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {}
export function DonationCard({
id,
amount,
title,
description,
icon,
badge,
highlighted,
}: DonationCardProps) {
// Estados para escolha inicial
const [showChoiceModal, setShowChoiceModal] = useState(false);
const [showDonorForm, setShowDonorForm] = useState(false);
const [donorName, setDonorName] = useState("");
const [donorEmail, setDonorEmail] = useState("");
const [donorPhone, setDonorPhone] = useState("");
const [donorTaxId, setDonorTaxId] = useState("");
const [donorMessage, setDonorMessage] = useState("");
const [customAmount, setCustomAmount] = useState("");
// Estados para PIX
const [loading, setLoading] = useState(false);
const [showPixModal, setShowPixModal] = useState(false);
const [pixData, setPixData] = useState<{
pixId: string;
brCode: string;
brCodeBase64: string;
expiresAt?: string;
} | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
const [showThankYouModal, setShowThankYouModal] = useState(false);
const t = useTranslations("donation");
const icons = {
coffee: Coffee,
pizza: Pizza,
heart: Heart,
};
const Icon = icons[icon];
// Confetti ao abrir modal de agradecimento
useEffect(() => {
if (showThankYouModal) {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
}
}, [showThankYouModal]);
// Atualizar timer e verificar pagamento automaticamente
useEffect(() => {
if (!pixData?.expiresAt || !pixData?.pixId) {
setTimeRemaining(null);
return;
}
const updateTimer = () => {
try {
const expires = new Date(pixData?.expiresAt!);
const now = new Date();
const diff = expires.getTime() - now.getTime();
if (diff <= 0) {
setTimeRemaining("Expirado");
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, "0")}`);
} catch {
setTimeRemaining(null);
}
};
// Verificar pagamento via API
const checkPaymentConfirmation = async () => {
if (!pixData?.pixId) return;
try {
const response = await fetch(
`/api/donations/status?pixId=${pixData.pixId}`,
);
const data = await response.json();
if (data.paid) {
console.log("✅ Pagamento confirmado!");
setPaymentConfirmed(true);
// Confetti celebration!
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
setTimeout(() => {
setShowPixModal(false);
setShowThankYouModal(true);
// Limpar localStorage
localStorage.removeItem("currentPixId");
}, 1000);
clearInterval(timerInterval);
}
} catch (error) {
console.error("Erro ao verificar pagamento:", error);
}
};
// Atualizar timer imediatamente
updateTimer();
// Atualizar timer e verificar pagamento a cada 2 segundos
const timerInterval = setInterval(() => {
updateTimer();
checkPaymentConfirmation();
}, 2000);
return () => clearInterval(timerInterval);
}, [pixData?.expiresAt, pixData?.pixId]);
// Funções de formatação
const formatCPF = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 11) {
return numbers
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d{1,2})$/, "$1-$2");
}
return value; // Não formatar se for CNPJ (14 dígitos)
};
const formatCNPJ = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length > 11) {
return numbers
.replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d)/, "$1-$2");
}
return value;
};
const formatPhone = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 10) {
// Telefone fixo (10 dígitos)
return numbers.replace(/^(\d{2})(\d{4})(\d{4})$/, "($1) $2-$3");
} else {
// Celular (11 dígitos)
return numbers.replace(/^(\d{2})(\d{5})(\d{4})$/, "($1) $2-$3");
}
};
const handleDonateClick = () => {
setShowChoiceModal(true);
setError(null);
};
const handleAnonymousDonation = async () => {
setLoading(true);
setError(null);
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(requestBody),
});
const data: DonationResponse = await response.json();
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
setPixData({
pixId: data.pixId,
brCode: data.brCode,
brCodeBase64: data.brCodeBase64,
expiresAt: data.expiresAt,
});
// Salvar pixId no localStorage para verificação
localStorage.setItem("currentPixId", data.pixId);
setShowPixModal(true);
} else {
setError(data.error || "Erro ao processar doação");
setShowChoiceModal(true);
}
} catch (error) {
console.error("Donation error:", error);
setError("Erro ao processar doação. Tente novamente.");
setShowChoiceModal(true);
} finally {
setLoading(false);
}
};
const handleIdentifiedClick = () => {
setShowChoiceModal(false);
setShowDonorForm(true);
setError(null);
};
const handleSubmitForm = async () => {
// Validação
if (!donorName.trim() || donorName.trim().length < 3) {
setError("Nome deve ter no mínimo 3 caracteres");
return;
}
if (!donorPhone.trim()) {
setError("Telefone é obrigatório");
return;
}
if (!donorEmail.trim()) {
setError("Email é obrigatório");
return;
}
if (!donorTaxId.trim()) {
setError("CPF/CNPJ é obrigatório");
return;
}
if (donorMessage && donorMessage.length > 280) {
setError("Mensagem deve ter no máximo 280 caracteres");
return;
}
setLoading(true);
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(requestBody),
});
const data: DonationResponse = await response.json();
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
setPixData({
pixId: data.pixId,
brCode: data.brCode,
brCodeBase64: data.brCodeBase64,
expiresAt: data.expiresAt,
});
// Salvar pixId no localStorage
localStorage.setItem("currentPixId", data.pixId);
setShowDonorForm(false);
setShowPixModal(true);
} else {
setError(data.error || "Erro ao processar doação");
}
} catch (error) {
console.error("Donation error:", error);
setError("Erro ao processar doação. Tente novamente.");
} finally {
setLoading(false);
}
};
const handleCopyPixCode = async () => {
if (pixData?.brCode) {
try {
await navigator.clipboard.writeText(pixData.brCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Erro ao copiar código PIX:", error);
}
}
};
// Funções para doação customizada
const { DONATION_PRODUCTS } = require("@/types/donation");
const customProduct = DONATION_PRODUCTS.custom;
const isValidCustomAmount = () => {
const amount = parseFloat(customAmount);
return (
!Number.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
className={`
relative p-8 rounded-2xl border bg-card backdrop-blur-lg
hover:scale-105 transition-all duration-300
${
highlighted
? "border-primary/50 shadow-[0_0_30px_rgba(34,197,94,0.3)]"
: "border-border/50"
}
`}
>
{badge && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary">
{badge}
</Badge>
)}
<div className="flex flex-col items-center text-center space-y-4">
<div className="p-4 rounded-full bg-muted/50 border border-border/30">
<Icon className="w-12 h-12 text-primary" />
</div>
<h3 className="text-2xl font-bold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
{id === "custom" ? (
<>
<div className="w-full space-y-4">
<div className="space-y-2">
<label
htmlFor="customAmount"
className="text-sm text-muted-foreground"
>
{t("form.value")}
</label>
<Input
id="customAmount"
type="text"
inputMode="decimal"
placeholder={t("form.chooseAmount")}
value={customAmount}
onChange={handleCustomAmountChange}
className="text-lg text-center"
/>
<p className="text-xs text-muted-foreground">
{t("form.min")}: R$ {customProduct.minAmount.toFixed(2)} |{" "}
{t("form.max")}: R$ {customProduct.maxAmount.toFixed(2)}
</p>
</div>
{customAmount && !isValidCustomAmount() && (
<p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount
? `${t(
"form.minAmount",
)} ${customProduct.minAmount.toFixed(2)}`
: `${t(
"form.maxAmount",
)} ${customProduct.maxAmount.toFixed(2)}`}
</p>
)}
</div>
<Button
size="lg"
className="w-full"
onClick={handleCustomDonationClick}
disabled={loading || !isValidCustomAmount()}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t("form.processing")}
</>
) : customAmount ? (
`${t("form.donate")} ${parseFloat(
customAmount || "0",
).toFixed(2)}`
) : (
t("form.chooseAmount")
)}
</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" />
{t("form.processing")}
</>
) : (
t("form.supportWithPix")
)}
</Button>
</>
)}
</div>
</div>
{/* Modal de Escolha (Anônimo vs Identificado) */}
<Dialog open={showChoiceModal} onOpenChange={setShowChoiceModal}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-center text-2xl">
{t("form.howToContribute")}
</DialogTitle>
</DialogHeader>
<div className="grid md:grid-cols-2 gap-4 mt-6">
{/* Card Anônimo */}
<div
className="p-6 rounded-xl border border-border/50 bg-card hover:border-primary/30 transition-all cursor-pointer"
onClick={handleAnonymousDonation}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-muted">
<Lock className="h-5 w-5" />
</div>
<h3 className="text-xl font-bold">
{t("form.anonymousTitle")}
</h3>
</div>
<p className="text-muted-foreground mb-4">
{t("form.anonymousDesc")}
</p>
<Button className="w-full" variant="outline" disabled={loading}>
{t("form.anonymousButton")}
</Button>
</div>
{/* Card Identificado */}
<div
className="p-6 rounded-xl border-2 border-primary/50 bg-primary/5 hover:border-primary transition-all cursor-pointer relative"
onClick={handleIdentifiedClick}
>
<Badge className="absolute -top-3 right-6 bg-primary">
{t("form.recommended")}
</Badge>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<h3 className="text-xl font-bold">
{t("form.identifiedTitle")}
</h3>
</div>
<p className="text-muted-foreground mb-4">
{t("form.identifiedDesc")}
</p>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.thankByEmail")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.earlyAccess")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.contributionHistory")}</span>
</div>
</div>
<Button className="w-full" disabled={loading}>
{t("form.identifyMe")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de Formulário do Doador Identificado */}
<Dialog open={showDonorForm} onOpenChange={setShowDonorForm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("form.donorInfo")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/50 rounded-lg text-destructive text-sm">
{error}
</div>
)}
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.name")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.nameHint")})
</span>
</label>
<Input
value={donorName}
onChange={(e) => setDonorName(e.target.value)}
placeholder={t("form.namePlaceholder")}
maxLength={50}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.email")}
</label>
<Input
type="email"
value={donorEmail}
onChange={(e) => setDonorEmail(e.target.value)}
placeholder={t("form.emailPlaceholder")}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.phone")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.phoneHint")})
</span>
</label>
<Input
value={donorPhone}
onChange={(e) => setDonorPhone(formatPhone(e.target.value))}
placeholder={t("form.phonePlaceholder")}
maxLength={15}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.taxId")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.taxIdHint")})
</span>
</label>
<Input
value={donorTaxId}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "");
if (value.length <= 11) {
setDonorTaxId(formatCPF(value));
} else {
setDonorTaxId(formatCNPJ(value));
}
}}
placeholder={t("form.taxIdPlaceholder")}
maxLength={18}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.message")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.messageHint")})
</span>
</label>
<Textarea
value={donorMessage}
onChange={(e) => {
if (e.target.value.length <= 280) {
setDonorMessage(e.target.value);
}
}}
placeholder={t("form.messagePlaceholder")}
rows={3}
maxLength={280}
/>
<p className="text-xs text-muted-foreground text-right mt-1">
{donorMessage.length}/280
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
setShowDonorForm(false);
setShowChoiceModal(true);
setError(null);
}}
variant="outline"
className="flex-1"
disabled={loading}
>
{t("form.close")}
</Button>
<Button
onClick={handleSubmitForm}
className="flex-1"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("form.processing")}
</>
) : (
t("form.generatePix")
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de QR Code PIX */}
<Dialog open={showPixModal} onOpenChange={setShowPixModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-center">
{t("form.qrTitle")}
</DialogTitle>
</DialogHeader>
{pixData && (
<div className="space-y-4">
<div className="flex justify-center p-4 bg-white rounded-lg">
<img
src={pixData.brCodeBase64}
alt="QR Code PIX"
className="w-64 h-64"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
{t("form.copyCode")}
</p>
<div className="flex gap-2">
<Input
value={pixData.brCode}
readOnly
className="font-mono text-sm"
/>
<Button
onClick={handleCopyPixCode}
variant="outline"
size="sm"
>
{copied ? (
<>
<Check className="h-4 w-4" />
{t("form.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
{t("form.copy")}
</>
)}
</Button>
</div>
</div>
{timeRemaining && (
<p className="text-xs text-center text-muted-foreground">
⏱️ {t("form.remainingTime")} {timeRemaining}
</p>
)}
<div className="flex gap-2">
<Button
onClick={() => setShowPixModal(false)}
variant="outline"
className="flex-1"
>
{t("form.close")}
</Button>
<Button
onClick={() => (window.location.href = "/apoiar/obrigado")}
className="flex-1"
>
{t("form.paid")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Modal de Agradecimento */}
<Dialog open={showThankYouModal} onOpenChange={setShowThankYouModal}>
<DialogContent className="max-w-md">
<div className="text-center space-y-6 py-4">
{/* Ícone de sucesso animado */}
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-green-500/20 rounded-full animate-ping" />
<div className="relative p-4 rounded-full bg-green-500/10 border-4 border-green-500">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
</div>
</div>
{/* Título e mensagem */}
<div>
<h2 className="text-2xl font-bold mb-2">
{t("form.paymentConfirmed")} 🎉
</h2>
<p className="text-muted-foreground">
{t("form.paymentConfirmedDesc")}
</p>
</div>
{/* Mensagem especial */}
<div className="p-4 rounded-lg bg-primary/5 border border-primary/20">
<p className="text-sm text-muted-foreground">
{t("form.contributionMatters")} ❤️
</p>
</div>
{/* Botões */}
<div className="flex gap-3">
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/apoiar/obrigado";
}}
className="flex-1"
size="lg"
>
{t("form.viewThankYouPage")}
</Button>
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/";
}}
variant="outline"
className="flex-1"
size="lg"
>
{t("form.backToHome")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}