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 ? (
+
+ ) : (
+
+
+
+ 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) => (
+ handlePresetSelect(preset)}
+ className="h-12 text-base"
+ >
+ ${preset}
+
+ ))}
+
+
+
+ Or enter a custom amount (USDC)
+
+
+
+
+ {/* Wallet Not Connected */}
+ {!connectedWallet ? (
+
+
+ Connect your Stellar wallet to donate
+
+
+ {isConnecting ? (
+ <>
+
+ Connecting...
+ >
+ ) : (
+ <>
+
+ Connect Wallet
+ >
+ )}
+
+
+ ) : (
+
+
+
+ USDC Balance
+
+
+ {formatAmount(walletInfo?.usdcBalance)} USDC
+
+
+
+
+ Network
+
+ {network}
+
+ {hasInsufficientBalance && (
+
+ Insufficient USDC balance for this amount.
+
+ )}
+
+ )}
+
+ {connectedWallet && (
+
+ {hasInsufficientBalance ? (
+ "Insufficient Balance"
+ ) : (
+ <>
+ Donate {isValidAmount ? `$${formatAmount(amount)}` : ""}{" "}
+ USDC
+
+ >
+ )}
+
+ )}
+ >
+ )}
+
+ {/* 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
+
+
+
+ )}
+
+
+
+ Back
+
+
+
+ Sign & Donate
+
+
+ >
+ )}
+
+ {/* 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{" "}
+
+
+ )}
+
+ Donate Again
+
+
+ )}
+
+ {/* Error Step */}
+ {step === "error" && (
+
+
+
Donation Failed
+
{error}
+
+ Try Again
+
+
+ )}
+
+
+
+ {/* 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 && (
+
+
setShowQr((prev) => !prev)}
+ className="w-full text-muted-foreground"
+ >
+
+ {showQr ? "Hide QR code" : "Pay by QR instead"}
+
+ {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"}
+
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy URI
+ >
+ )}
+
+
+ );
+}
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",