From 44f2be5d84a859d9723e0d202d1594f932768bcc Mon Sep 17 00:00:00 2001
From: Koncept Kit <63216427+konceptkit@users.noreply.github.com>
Date: Thu, 11 Dec 2025 23:14:23 +0700
Subject: [PATCH] Donation page update and Subscription update on Admin
Dashboard
---
src/App.js | 16 +-
src/components/AdminSidebar.js | 9 +-
src/pages/Donate.js | 146 +++++++-
src/pages/DonationSuccess.js | 102 ++++++
src/pages/Plans.js | 95 ++++-
src/pages/admin/AdminSubscriptions.js | 506 ++++++++++++++++++++++++++
6 files changed, 858 insertions(+), 16 deletions(-)
create mode 100644 src/pages/DonationSuccess.js
create mode 100644 src/pages/admin/AdminSubscriptions.js
diff --git a/src/App.js b/src/App.js
index c48718b..928ac78 100644
--- a/src/App.js
+++ b/src/App.js
@@ -24,6 +24,7 @@ import AdminMembers from './pages/admin/AdminMembers';
import AdminEvents from './pages/admin/AdminEvents';
import AdminApprovals from './pages/admin/AdminApprovals';
import AdminPlans from './pages/admin/AdminPlans';
+import AdminSubscriptions from './pages/admin/AdminSubscriptions';
import AdminLayout from './layouts/AdminLayout';
import { AuthProvider, useAuth } from './context/AuthContext';
import MemberRoute from './components/MemberRoute';
@@ -42,6 +43,7 @@ import History from './pages/History';
import MissionValues from './pages/MissionValues';
import BoardOfDirectors from './pages/BoardOfDirectors';
import Donate from './pages/Donate';
+import DonationSuccess from './pages/DonationSuccess';
const PrivateRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth();
@@ -77,7 +79,11 @@ function App() {
} />
- } />
+
+
+
+ } />
} />
} />
} />
@@ -89,6 +95,7 @@ function App() {
{/* Donation Page - Public Access */}
} />
+ } />
@@ -209,6 +216,13 @@ function App() {
} />
+
+
+
+
+
+ } />
diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js
index efde1e4..622579a 100644
--- a/src/components/AdminSidebar.js
+++ b/src/components/AdminSidebar.js
@@ -20,7 +20,8 @@ import {
FileText,
DollarSign,
Scale,
- HardDrive
+ HardDrive,
+ Repeat
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -116,6 +117,12 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin/plans',
disabled: false
},
+ {
+ name: 'Subscriptions',
+ icon: Repeat,
+ path: '/admin/subscriptions',
+ disabled: false
+ },
{
name: 'Events',
icon: Calendar,
diff --git a/src/pages/Donate.js b/src/pages/Donate.js
index 1ce1024..278ce2f 100644
--- a/src/pages/Donate.js
+++ b/src/pages/Donate.js
@@ -1,14 +1,59 @@
-import React from 'react';
+import React, { useState } from 'react';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
-import { CreditCard, Mail } from 'lucide-react';
+import { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '../components/ui/dialog';
+import { CreditCard, Mail, Heart, Loader2 } from 'lucide-react';
+import api from '../utils/api';
+import { toast } from 'sonner';
const Donate = () => {
const loafHearts = `${process.env.PUBLIC_URL}/loaf-hearts.png`;
const zelleLogo = `${process.env.PUBLIC_URL}/zelle-logo.png`;
+ const [customAmountDialogOpen, setCustomAmountDialogOpen] = useState(false);
+ const [customAmount, setCustomAmount] = useState('');
+ const [processingAmount, setProcessingAmount] = useState(null);
+
+ const handleDonateAmount = async (amountCents) => {
+ setProcessingAmount(amountCents);
+ try {
+ const response = await api.post('/donations/checkout', {
+ amount_cents: amountCents
+ });
+
+ // Redirect to Stripe Checkout
+ window.location.href = response.data.checkout_url;
+ } catch (error) {
+ console.error('Failed to process donation:', error);
+ toast.error(error.response?.data?.detail || 'Failed to process donation. Please try again.');
+ setProcessingAmount(null);
+ }
+ };
+
+ const handleCustomDonate = () => {
+ const amount = parseFloat(customAmount);
+
+ if (!customAmount || isNaN(amount) || amount < 1) {
+ toast.error('Please enter a valid amount (minimum $1.00)');
+ return;
+ }
+
+ const amountCents = Math.round(amount * 100);
+ setCustomAmountDialogOpen(false);
+ handleDonateAmount(amountCents);
+ };
+
return (
@@ -44,22 +89,33 @@ const Donate = () => {
{/* Donation Buttons Grid */}
{[25, 50, 100, 250].map(amount => (
-
{/* Custom Amount Button */}
-
+ setCustomAmountDialogOpen(true)}
+ disabled={processingAmount !== null}
+ className="w-full bg-[#664fa3] hover:bg-[#48286e] text-white text-xl py-8 rounded-full flex items-center justify-center gap-2"
+ >
+
Donate Any Amount
-
- Online donations coming soon
+
+ Secure donation processing powered by Stripe
@@ -106,6 +162,76 @@ const Donate = () => {
+
+ {/* Custom Amount Dialog */}
+
);
};
diff --git a/src/pages/DonationSuccess.js b/src/pages/DonationSuccess.js
new file mode 100644
index 0000000..0a49e26
--- /dev/null
+++ b/src/pages/DonationSuccess.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import PublicNavbar from '../components/PublicNavbar';
+import PublicFooter from '../components/PublicFooter';
+import { Card } from '../components/ui/card';
+import { Button } from '../components/ui/button';
+import { CheckCircle, Heart } from 'lucide-react';
+
+const DonationSuccess = () => {
+ const navigate = useNavigate();
+ const loafHearts = `${process.env.PUBLIC_URL}/loaf-hearts.png`;
+
+ return (
+
+
+
+
+
+
+ {/* Success Icon */}
+
+

e.target.style.display = 'none'}
+ />
+
+
+
+
+
+ {/* Title */}
+
+ Thank You for Your Donation!
+
+
+ {/* Message */}
+
+
+ Your generous contribution helps support our community and continue our mission.
+
+
+
+
+
+
+ Your Support Makes a Difference
+
+
+
+ A receipt for your donation has been sent to your email address.
+
+
+
+
+ We deeply appreciate your support and commitment to LOAF's mission of building a vibrant, inclusive community.
+
+
+
+ {/* Actions */}
+
+ navigate('/')}
+ className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+ Return to Home
+
+ navigate('/donate')}
+ variant="outline"
+ className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#DDD8EB]/20 rounded-full px-8 py-6 text-lg font-medium"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+ Make Another Donation
+
+
+
+
+ {/* Additional Info */}
+
+
+
+
+
+
+ );
+};
+
+export default DonationSuccess;
diff --git a/src/pages/Plans.js b/src/pages/Plans.js
index d9c88f3..47c6bd1 100644
--- a/src/pages/Plans.js
+++ b/src/pages/Plans.js
@@ -15,15 +15,16 @@ import {
DialogTitle,
} from '../components/ui/dialog';
import Navbar from '../components/Navbar';
-import { CheckCircle, CreditCard, Loader2, Heart } from 'lucide-react';
+import { CheckCircle, CreditCard, Loader2, Heart, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
const Plans = () => {
- const { user } = useAuth();
+ const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [processingPlanId, setProcessingPlanId] = useState(null);
+ const [statusInfo, setStatusInfo] = useState(null);
// Amount selection dialog state
const [amountDialogOpen, setAmountDialogOpen] = useState(false);
@@ -34,6 +35,65 @@ const Plans = () => {
fetchPlans();
}, []);
+ // Status-based access control
+ useEffect(() => {
+ if (!authLoading && user) {
+ // Define status-to-message mapping
+ const statusMessages = {
+ pending_email: {
+ title: "Email Verification Required",
+ message: "Please verify your email address before selecting a membership plan. Check your inbox for the verification link.",
+ action: null,
+ canView: true,
+ canSubscribe: false
+ },
+ pending_approval: {
+ title: "Application Under Review",
+ message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
+ action: null,
+ canView: true,
+ canSubscribe: false
+ },
+ pre_approved: {
+ title: "Application Under Review",
+ message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
+ action: null,
+ canView: true,
+ canSubscribe: false
+ },
+ payment_pending: {
+ title: null,
+ message: null,
+ canView: true,
+ canSubscribe: true
+ },
+ active: {
+ title: "Already a Member",
+ message: "You already have an active membership. Visit your dashboard to view your membership details.",
+ action: "Go to Dashboard",
+ actionLink: "/dashboard",
+ canView: true,
+ canSubscribe: false
+ },
+ inactive: {
+ title: "Membership Inactive",
+ message: "Your membership has expired. Please select a plan below to renew your membership.",
+ action: null,
+ canView: true,
+ canSubscribe: true
+ }
+ };
+
+ const userStatus = statusMessages[user.status];
+ setStatusInfo(userStatus || {
+ title: "Access Restricted",
+ message: "Please contact support for assistance with your account.",
+ canView: true,
+ canSubscribe: false
+ });
+ }
+ }, [user, authLoading]);
+
const fetchPlans = async () => {
try {
const response = await api.get('/subscriptions/plans');
@@ -141,6 +201,31 @@ const Plans = () => {
+ {/* Status Banner */}
+ {statusInfo && statusInfo.title && (
+
+
+
+
+
+ {statusInfo.title}
+
+
+ {statusInfo.message}
+
+ {statusInfo.action && statusInfo.actionLink && (
+
navigate(statusInfo.actionLink)}
+ className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
+ >
+ {statusInfo.action}
+
+ )}
+
+
+
+ )}
+
{loading ? (
@@ -220,8 +305,8 @@ const Plans = () => {
{/* CTA Button */}
handleSelectPlan(plan)}
- disabled={processingPlanId === plan.id}
- className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-semibold"
+ disabled={processingPlanId === plan.id || (statusInfo && !statusInfo.canSubscribe)}
+ className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
data-testid={`subscribe-button-${plan.id}`}
>
{processingPlanId === plan.id ? (
@@ -229,6 +314,8 @@ const Plans = () => {
Processing...
>
+ ) : statusInfo && !statusInfo.canSubscribe ? (
+ 'Approval Required'
) : (
'Choose Amount & Subscribe'
)}
diff --git a/src/pages/admin/AdminSubscriptions.js b/src/pages/admin/AdminSubscriptions.js
new file mode 100644
index 0000000..37fa334
--- /dev/null
+++ b/src/pages/admin/AdminSubscriptions.js
@@ -0,0 +1,506 @@
+import React, { useState, useEffect } from 'react';
+import { Card } from '../../components/ui/card';
+import { Button } from '../../components/ui/button';
+import { Input } from '../../components/ui/input';
+import { Label } from '../../components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '../../components/ui/select';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '../../components/ui/dialog';
+import { Badge } from '../../components/ui/badge';
+import api from '../../utils/api';
+import { toast } from 'sonner';
+import {
+ DollarSign,
+ CreditCard,
+ TrendingUp,
+ Heart,
+ Search,
+ Loader2,
+ Calendar,
+ Edit,
+ XCircle
+} from 'lucide-react';
+
+const AdminSubscriptions = () => {
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
+ const [plans, setPlans] = useState([]);
+ const [stats, setStats] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [planFilter, setPlanFilter] = useState('all');
+
+ // Edit subscription dialog state
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [selectedSubscription, setSelectedSubscription] = useState(null);
+ const [editFormData, setEditFormData] = useState({
+ status: '',
+ end_date: ''
+ });
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ filterSubscriptions();
+ }, [searchQuery, statusFilter, planFilter, subscriptions]);
+
+ const fetchData = async () => {
+ setLoading(true);
+ try {
+ const [subsResponse, statsResponse, plansResponse] = await Promise.all([
+ api.get('/admin/subscriptions'),
+ api.get('/admin/subscriptions/stats'),
+ api.get('/admin/subscriptions/plans')
+ ]);
+
+ setSubscriptions(subsResponse.data);
+ setStats(statsResponse.data);
+ setPlans(plansResponse.data);
+ } catch (error) {
+ console.error('Failed to fetch subscription data:', error);
+ toast.error('Failed to load subscription data');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const filterSubscriptions = () => {
+ let filtered = [...subscriptions];
+
+ // Search filter (member name or email)
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(sub =>
+ sub.user.first_name.toLowerCase().includes(query) ||
+ sub.user.last_name.toLowerCase().includes(query) ||
+ sub.user.email.toLowerCase().includes(query)
+ );
+ }
+
+ // Status filter
+ if (statusFilter !== 'all') {
+ filtered = filtered.filter(sub => sub.status === statusFilter);
+ }
+
+ // Plan filter
+ if (planFilter !== 'all') {
+ filtered = filtered.filter(sub => sub.plan.id === planFilter);
+ }
+
+ setFilteredSubscriptions(filtered);
+ };
+
+ const handleEdit = (subscription) => {
+ setSelectedSubscription(subscription);
+ setEditFormData({
+ status: subscription.status,
+ end_date: subscription.end_date ? new Date(subscription.end_date).toISOString().split('T')[0] : ''
+ });
+ setEditDialogOpen(true);
+ };
+
+ const handleSaveSubscription = async () => {
+ if (!selectedSubscription) return;
+
+ setIsUpdating(true);
+ try {
+ await api.put(`/admin/subscriptions/${selectedSubscription.id}`, {
+ status: editFormData.status,
+ end_date: editFormData.end_date ? new Date(editFormData.end_date).toISOString() : null
+ });
+
+ toast.success('Subscription updated successfully');
+ setEditDialogOpen(false);
+ fetchData(); // Refresh data
+ } catch (error) {
+ console.error('Failed to update subscription:', error);
+ toast.error(error.response?.data?.detail || 'Failed to update subscription');
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ const handleCancelSubscription = async (subscriptionId) => {
+ if (!window.confirm('Are you sure you want to cancel this subscription? This will also set the user status to inactive.')) {
+ return;
+ }
+
+ try {
+ await api.post(`/admin/subscriptions/${subscriptionId}/cancel`);
+ toast.success('Subscription cancelled successfully');
+ fetchData(); // Refresh data
+ } catch (error) {
+ console.error('Failed to cancel subscription:', error);
+ toast.error(error.response?.data?.detail || 'Failed to cancel subscription');
+ }
+ };
+
+ const formatPrice = (cents) => {
+ return `$${(cents / 100).toFixed(2)}`;
+ };
+
+ const formatDate = (dateString) => {
+ if (!dateString) return 'N/A';
+ return new Date(dateString).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ };
+
+ const getStatusBadgeVariant = (status) => {
+ const variants = {
+ active: 'default',
+ cancelled: 'destructive',
+ expired: 'secondary'
+ };
+ return variants[status] || 'outline';
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Subscription Management
+
+
+ View and manage all member subscriptions
+
+
+
+ {/* Stats Cards */}
+
+
+
+
+
+ Total Subscriptions
+
+
+ {stats.total || 0}
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Members
+
+
+ {stats.active || 0}
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Revenue
+
+
+ {formatPrice(stats.total_revenue || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Donations
+
+
+ {formatPrice(stats.total_donations || 0)}
+
+
+
+
+
+
+
+
+
+ {/* Search & Filter Bar */}
+
+
+ {/* Search */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
+ />
+
+
+
+ {/* Status Filter */}
+
+
+
+
+ {/* Plan Filter */}
+
+
+
+
+
+
+ Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
+
+
+
+ {/* Subscriptions Table */}
+
+
+
+
+
+ |
+ Member
+ |
+
+ Plan
+ |
+
+ Status
+ |
+
+ Period
+ |
+
+ Base Fee
+ |
+
+ Donation
+ |
+
+ Total
+ |
+
+ Actions
+ |
+
+
+
+ {filteredSubscriptions.length > 0 ? (
+ filteredSubscriptions.map((sub) => (
+
+ |
+
+ {sub.user.first_name} {sub.user.last_name}
+
+
+ {sub.user.email}
+
+ |
+
+
+ {sub.plan.name}
+
+
+ {sub.plan.billing_cycle}
+
+ |
+
+
+ {sub.status}
+
+ |
+
+
+ {formatDate(sub.start_date)}
+ to {formatDate(sub.end_date)}
+
+ |
+
+ {formatPrice(sub.base_subscription_cents || 0)}
+ |
+
+ {formatPrice(sub.donation_cents || 0)}
+ |
+
+ {formatPrice(sub.amount_paid_cents || 0)}
+ |
+
+
+ handleEdit(sub)}
+ className="text-[#664fa3] hover:bg-[#DDD8EB]"
+ >
+
+
+ {sub.status === 'active' && (
+ handleCancelSubscription(sub.id)}
+ className="text-red-600 hover:bg-red-50"
+ >
+
+
+ )}
+
+ |
+
+ ))
+ ) : (
+
+ |
+ No subscriptions found
+ |
+
+ )}
+
+
+
+
+
+ {/* Edit Subscription Dialog */}
+
+
+ );
+};
+
+export default AdminSubscriptions;