diff --git a/app/dashboard/sadaqah/page.jsx b/app/dashboard/sadaqah/page.jsx new file mode 100644 index 0000000..4a696d2 --- /dev/null +++ b/app/dashboard/sadaqah/page.jsx @@ -0,0 +1,546 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + HeartHandshake, + Loader2, + Wallet, + CheckCircle, + AlertCircle, + ExternalLink, + ArrowRight, + RefreshCw, + Sparkles, + ShieldCheck, + Coins, +} from "lucide-react"; +import { toast } from "sonner"; +import { useStellar } from "@/components/stellar/StellarProvider"; +import useStellarDonation from "@/hooks/useStellarDonation"; +import Sep7QrCode from "@/components/stellar/Sep7QrCode"; + +const PRESET_AMOUNTS = [5, 10, 25, 100]; + +const formatAmount = (value) => { + const num = parseFloat(value || 0); + return num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +}; + +const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export default function SadaqahPage() { + const { connectedWallet, walletInfo, connectWallet, network, isConnecting } = + useStellar(); + const { + initializeDonation, + executeDonation, + cancelDonation, + getDonationStats, + isProcessing, + } = useStellarDonation(); + + // Fund stats state + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + const [statsError, setStatsError] = useState(null); + const [statsUnconfigured, setStatsUnconfigured] = useState(false); + + // Donation flow state + const [step, setStep] = useState("amount"); // amount | confirm | processing | success | error + const [selectedAmount, setSelectedAmount] = useState(10); + const [customAmount, setCustomAmount] = useState(""); + const [donationData, setDonationData] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const fetchStats = useCallback(async () => { + setStatsLoading(true); + setStatsError(null); + setStatsUnconfigured(false); + + const data = await getDonationStats(); + if (data.success === false) { + if (data.unconfigured) { + setStatsUnconfigured(true); + } else { + setStatsError(data.message || "Failed to load fund stats"); + } + } else { + setStats(data); + } + setStatsLoading(false); + }, [getDonationStats]); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + const amount = customAmount !== "" ? parseFloat(customAmount) : selectedAmount; + const isValidAmount = !isNaN(amount) && amount > 0; + const hasInsufficientBalance = + connectedWallet && + isValidAmount && + parseFloat(walletInfo?.usdcBalance || 0) < amount; + + const handlePresetSelect = (value) => { + setSelectedAmount(value); + setCustomAmount(""); + }; + + const handleCustomChange = (e) => { + const value = e.target.value; + if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { + setCustomAmount(value); + } + }; + + const handleInitiate = async () => { + if (!isValidAmount) { + toast.error("Please enter a valid donation amount"); + return; + } + + setStep("processing"); + const data = await initializeDonation({ amount }); + + if (data) { + setDonationData(data); + setStep("confirm"); + } else { + setStep("amount"); + } + }; + + const handleConfirm = async () => { + setStep("processing"); + const success = await executeDonation(donationData); + + if (success) { + setResult(success); + setStep("success"); + setDonationData(null); + fetchStats(); + } else { + setStep("error"); + setError("Transaction failed. Please try again."); + } + }; + + const handleBack = () => { + cancelDonation(); + setDonationData(null); + setStep("amount"); + }; + + const handleReset = () => { + cancelDonation(); + setDonationData(null); + setResult(null); + setError(null); + setStep("amount"); + }; + + return ( +
+ {/* Hero Header */} +
+
+ +

+ Sadaqah Jariyah +

+
+

+ Give a charity that keeps on giving. Your donation funds scholarships + for students of knowledge, held in a transparent on-chain USDC fund + on the Stellar network. Every contribution is publicly verifiable, + from your wallet to the scholarship pool. +

+
+ + + On-chain transparency + + + + USDC on Stellar + + + + Funds scholarships + +
+
+ + {/* Fund Stats */} + {statsLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : statsUnconfigured ? ( +
+ +

+ The donation fund is not configured yet. Please check back soon, + insha'Allah. +

+
+ ) : statsError ? ( +
+
+ +

{statsError}

+
+ +
+ ) : ( +
+
+

+ Scholarship Pool Balance +

+

+ ${formatAmount(stats?.poolBalance)}{" "} + USDC +

+
+
+

+ Total Donated +

+

+ ${formatAmount(stats?.totalDonated)}{" "} + USDC +

+
+
+

+ Donations +

+

+ {stats?.donationCount ?? 0} +

+
+
+ )} + +
+ {/* Donate Card */} + + + + {step === "success" ? "Donation Complete!" : "Make a Donation"} + + + {step === "amount" && + "Choose an amount to donate to the scholarship fund"} + {step === "confirm" && + "Sign with your wallet, or scan the QR with a mobile wallet"} + {step === "processing" && "Processing your donation..."} + {step === "success" && "May Allah accept it from you"} + {step === "error" && "Something went wrong"} + + + + {/* Amount Selection */} + {step === "amount" && ( + <> +
+ {PRESET_AMOUNTS.map((preset) => ( + + ))} +
+
+ + +
+ + {/* Wallet Not Connected */} + {!connectedWallet ? ( +
+

+ Connect your Stellar wallet to donate +

+ +
+ ) : ( +
+
+ + USDC Balance + + + {formatAmount(walletInfo?.usdcBalance)} USDC + +
+
+ + Network + + {network} +
+ {hasInsufficientBalance && ( +

+ Insufficient USDC balance for this amount. +

+ )} +
+ )} + + {connectedWallet && ( + + )} + + )} + + {/* Confirm Step */} + {step === "confirm" && donationData && ( + <> +
+

+ Please confirm the transaction in your wallet extension. +

+
+ Donating: + + ${formatAmount(donationData.amount)} USDC + +
+
+ + {donationData.sep7Uri && ( +
+

+ Prefer mobile? Pay by QR instead +

+ +
+ )} + +
+ + +
+ + )} + + {/* Processing Step */} + {step === "processing" && ( +
+ +

+ {isProcessing + ? "Processing donation..." + : "Preparing transaction..."} +

+

+ Please check your wallet for the signing request +

+
+ )} + + {/* Success Step */} + {step === "success" && ( +
+ +

+ JazakAllahu Khairan! +

+

+ Your donation was recorded on the Stellar network. +

+ {result?.explorerUrl && ( + + View on Stellar Explorer{" "} + + + )} + +
+ )} + + {/* Error Step */} + {step === "error" && ( +
+ +

Donation Failed

+

{error}

+ +
+ )} +
+
+ + {/* Recent Donations */} + + + Recent Donations + + Latest contributions to the fund, verifiable on-chain + + + + {statsLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : statsUnconfigured || statsError ? ( +
+

+ Donation activity is unavailable right now +

+
+ ) : !stats?.recent?.length ? ( +
+

No donations yet

+

+ Be the first to give Sadaqah Jariyah +

+
+ ) : ( +
+ {stats.recent.map((donation, i) => ( +
+
+

+ ${formatAmount(donation.amount)}{" "} + + USDC + +

+

+ {formatDate(donation.createdAt)} +

+
+ {donation.explorerUrl && ( + + + + )} +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/components/molecules/dashboard/nav-routers.jsx b/components/molecules/dashboard/nav-routers.jsx index fed0cdd..c5792a7 100644 --- a/components/molecules/dashboard/nav-routers.jsx +++ b/components/molecules/dashboard/nav-routers.jsx @@ -8,7 +8,8 @@ import { Inbox, Book, Play, - LaptopMinimal + LaptopMinimal, + HeartHandshake } from "lucide-react"; import { SidebarGroup, @@ -51,6 +52,12 @@ const links = [ icon: Inbox, }, + { + name: "Sadaqah", + link: "/dashboard/sadaqah", + icon: HeartHandshake, + }, + ]; const Navrouter = () => { diff --git a/components/stellar/PaymentModal.jsx b/components/stellar/PaymentModal.jsx index 7b75ba1..557b3a3 100644 --- a/components/stellar/PaymentModal.jsx +++ b/components/stellar/PaymentModal.jsx @@ -16,9 +16,11 @@ import { AlertCircle, ExternalLink, ArrowRight, + QrCode, } from "lucide-react"; import { useStellar } from "./StellarProvider"; import useStellarPayment from "@/hooks/useStellarPayment"; +import Sep7QrCode from "./Sep7QrCode"; export default function PaymentModal({ isOpen, @@ -36,6 +38,7 @@ export default function PaymentModal({ const [paymentData, setPaymentData] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [showQr, setShowQr] = useState(false); // Reset state when modal opens/closes useEffect(() => { @@ -44,6 +47,7 @@ export default function PaymentModal({ setPaymentData(null); setResult(null); setError(null); + setShowQr(false); } }, [isOpen]); @@ -81,9 +85,12 @@ export default function PaymentModal({ setPaymentData(null); setResult(null); setError(null); + setShowQr(false); onClose(); }; + const sep7Uri = paymentData?.sep7Uri || paymentData?.payment?.sep7Uri; + const truncateAddress = (addr) => { if (!addr) return ""; return `${addr.slice(0, 8)}...${addr.slice(-8)}`; @@ -213,6 +220,27 @@ export default function PaymentModal({ )} + {/* Pay by QR (SEP-7) */} + {step === "confirm" && sep7Uri && ( +
+ + {showQr && ( +
+ +
+ )} +
+ )} + {/* Processing Step */} {step === "processing" && (
diff --git a/components/stellar/Sep7QrCode.jsx b/components/stellar/Sep7QrCode.jsx new file mode 100644 index 0000000..85865b9 --- /dev/null +++ b/components/stellar/Sep7QrCode.jsx @@ -0,0 +1,58 @@ +"use client"; +import { useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { Button } from "@/components/ui/button"; +import { Copy, Check } from "lucide-react"; +import { toast } from "sonner"; + +/** + * Renders a QR code for a SEP-7 (web+stellar:) payment URI so users + * can complete a payment by scanning with a Stellar mobile wallet. + */ +export default function Sep7QrCode({ uri, size = 168, caption }) { + const [copied, setCopied] = useState(false); + + if (!uri) return null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(uri); + setCopied(true); + toast.success("Payment URI copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy URI:", error); + toast.error("Failed to copy URI"); + } + }; + + return ( +
+
+ +
+

+ {caption || "Scan with a Stellar mobile wallet"} +

+ +
+ ); +} diff --git a/hooks/useStellarDonation.js b/hooks/useStellarDonation.js new file mode 100644 index 0000000..37fd068 --- /dev/null +++ b/hooks/useStellarDonation.js @@ -0,0 +1,155 @@ +"use client"; +import { useState, useCallback } from "react"; +import { Networks } from "@creit.tech/stellar-wallets-kit"; +import { useStellar } from "@/components/stellar/StellarProvider"; +import { toast } from "sonner"; +import axiosInstance from "@/lib/config/axios.config"; + +/** + * Hook for handling Stellar Sadaqah donation operations + */ +export const useStellarDonation = () => { + const { connectedWallet, signTransaction, refreshBalance, network } = + useStellar(); + const [isProcessing, setIsProcessing] = useState(false); + const [currentDonation, setCurrentDonation] = useState(null); + + /** + * Initialize a donation by creating a pending transaction + * Returns the XDR to be signed plus a SEP-7 URI for mobile wallets + */ + const initializeDonation = useCallback( + async ({ amount }) => { + if (!connectedWallet) { + toast.error("Please connect your wallet first"); + return null; + } + + setIsProcessing(true); + try { + const res = await axiosInstance.post( + "/api/stellar/donation/initialize", + { + amount, + publicKey: connectedWallet, + } + ); + + if (res.data.success !== false) { + const donation = { ...res.data, amount }; + setCurrentDonation(donation); + return donation; + } else { + throw new Error(res.data.message); + } + } catch (error) { + const message = error.response?.data?.message || error.message; + if (error.response?.status === 503) { + toast.error("Donations are not available right now. Please try again later."); + } else { + toast.error(message); + } + return null; + } finally { + setIsProcessing(false); + } + }, + [connectedWallet] + ); + + /** + * Execute donation by signing and submitting the transaction + */ + const executeDonation = useCallback( + async (donationData) => { + if (!donationData) { + toast.error("No donation data available"); + return false; + } + + setIsProcessing(true); + try { + // Sign the transaction with wallet + const networkPassphrase = + donationData.networkPassphrase || + (network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET); + const signedXdr = await signTransaction( + donationData.transactionXdr, + networkPassphrase + ); + + // Submit to backend + const res = await axiosInstance.post("/api/stellar/donation/submit", { + donationId: donationData.donationId, + signedXdr, + }); + + if (res.data.success) { + toast.success("JazakAllahu khairan! Donation successful!"); + + // Refresh wallet balance + await refreshBalance(); + + setCurrentDonation(null); + return res.data; + } else { + throw new Error(res.data.message); + } + } catch (error) { + const message = error.response?.data?.message || error.message; + + // Handle specific Stellar errors + if (message.includes("insufficient") || message.includes("underfunded")) { + toast.error("Insufficient USDC balance"); + } else if (message.includes("op_no_trust") || message.includes("trustline")) { + toast.error( + "You need to add USDC trustline to your wallet first" + ); + } else if (message.includes("rejected") || message.includes("cancelled")) { + toast.error("Transaction was cancelled"); + } else { + toast.error(`Donation failed: ${message}`); + } + return false; + } finally { + setIsProcessing(false); + } + }, + [signTransaction, refreshBalance, network] + ); + + /** + * Clear the current pending donation + */ + const cancelDonation = useCallback(() => { + setCurrentDonation(null); + }, []); + + /** + * Get public donation fund stats (pool balance, totals, recent donations) + */ + const getDonationStats = useCallback(async () => { + try { + const res = await axiosInstance.get("/api/stellar/donation/stats"); + return res.data; + } catch (error) { + console.error("Failed to fetch donation stats:", error); + return { + success: false, + unconfigured: error.response?.status === 503, + message: error.response?.data?.message || error.message, + }; + } + }, []); + + return { + initializeDonation, + executeDonation, + cancelDonation, + getDonationStats, + isProcessing, + currentDonation, + }; +}; + +export default useStellarDonation; diff --git a/package-lock.json b/package-lock.json index 57fee47..3c8ea16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "next-themes": "^0.4.6", "nextjs-toploader": "^3.8.16", "pdfjs-dist": "^3.11.174", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-countup": "^6.5.3", "react-day-picker": "^9.6.7", @@ -17970,6 +17971,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qrcode/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", diff --git a/package.json b/package.json index 87ef8b0..6e9bfe6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "next-themes": "^0.4.6", "nextjs-toploader": "^3.8.16", "pdfjs-dist": "^3.11.174", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-countup": "^6.5.3", "react-day-picker": "^9.6.7",