Files
membership-fe/src/pages/Plans.js

484 lines
20 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../components/ui/dialog';
import Navbar from '../components/Navbar';
import { CheckCircle, CreditCard, Loader2, Heart, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
const Plans = () => {
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);
const [selectedPlan, setSelectedPlan] = useState(null);
const [amountInput, setAmountInput] = useState('');
useEffect(() => {
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_validation: {
title: "Application Under Review",
message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
action: null,
canView: true,
canSubscribe: false
},
pre_validated: {
title: "Application Under Review",
message: "Your application is being reviewed by our admin team. You'll receive an email once validated 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 is currently inactive. Please contact support for assistance.",
action: null,
canView: true,
canSubscribe: false
},
canceled: {
title: "Membership Canceled",
message: "Your membership was canceled. You can rejoin by selecting a plan below.",
action: null,
canView: true,
canSubscribe: true
},
expired: {
title: "Membership Expired",
message: "Your membership has expired. Please renew by selecting a plan below.",
action: null,
canView: true,
canSubscribe: true
},
abandoned: {
title: "Application Incomplete",
message: "Your application was not completed. Please contact support to restart the registration process.",
action: null,
canView: true,
canSubscribe: false
}
};
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');
setPlans(response.data);
} catch (error) {
console.error('Failed to fetch plans:', error);
toast.error('Failed to load subscription plans');
} finally {
setLoading(false);
}
};
const handleSelectPlan = (plan) => {
if (!user) {
navigate('/login');
return;
}
setSelectedPlan(plan);
// Pre-fill with suggested price or minimum price
const suggestedAmount = (plan.suggested_price_cents || plan.minimum_price_cents) / 100;
setAmountInput(suggestedAmount.toFixed(2));
setAmountDialogOpen(true);
};
const handleCheckout = async () => {
const amountCents = Math.round(parseFloat(amountInput) * 100);
const minimumCents = selectedPlan.minimum_price_cents || 3000;
// Validate amount
if (!amountInput || isNaN(amountCents) || amountCents < minimumCents) {
toast.error(`Amount must be at least $${(minimumCents / 100).toFixed(2)}`);
return;
}
// Check if plan allows donations
const donationCents = amountCents - minimumCents;
if (donationCents > 0 && !selectedPlan.allow_donation) {
toast.error('This plan does not accept donations above the minimum price');
return;
}
setProcessingPlanId(selectedPlan.id);
setAmountDialogOpen(false);
try {
const response = await api.post('/subscriptions/checkout', {
plan_id: selectedPlan.id,
amount_cents: amountCents
});
// Redirect to Stripe Checkout
window.location.href = response.data.checkout_url;
} catch (error) {
console.error('Failed to create checkout session:', error);
toast.error(error.response?.data?.detail || 'Failed to start checkout process');
setProcessingPlanId(null);
}
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const getBillingCycleLabel = (billingCycle) => {
const labels = {
yearly: 'per year',
monthly: 'per month',
quarterly: 'per quarter',
lifetime: 'one-time',
custom: 'custom period'
};
return labels[billingCycle] || billingCycle;
};
// Calculate donation breakdown
const getAmountBreakdown = () => {
if (!selectedPlan || !amountInput) return null;
const totalCents = Math.round(parseFloat(amountInput) * 100);
const minimumCents = selectedPlan.minimum_price_cents || 3000;
const donationCents = Math.max(0, totalCents - minimumCents);
return {
total: totalCents,
base: minimumCents,
donation: donationCents
};
};
const breakdown = getAmountBreakdown();
return (
<div className="min-h-screen bg-background">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-12 text-center">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Membership Plans
</h1>
<p className="text-lg text-brand-purple max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose the membership plan that works best for you and become part of our vibrant community.
</p>
</div>
{/* Status Banner */}
{statusInfo && statusInfo.title && (
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 border-2 border-brand-purple ">
<div className="flex items-start gap-4">
<AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
<div className="flex-1">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{statusInfo.title}
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{statusInfo.message}
</p>
{statusInfo.action && statusInfo.actionLink && (
<Button
onClick={() => navigate(statusInfo.actionLink)}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
>
{statusInfo.action}
</Button>
)}
</div>
</div>
</Card>
)}
{loading ? (
<div className="text-center py-20">
<Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div>
) : plans.length > 0 ? (
<div className={`grid gap-6 sm:gap-8 mx-auto ${plans.length === 1
? 'grid-cols-1 max-w-md'
: plans.length === 2
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
}`}>
{plans.map((plan) => {
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
return (
<Card
key={plan.id}
className="p-8 bg-background rounded-2xl border-2 border-[var(--neutral-800)] hover:border-brand-purple hover:shadow-xl transition-all"
data-testid={`plan-card-${plan.id}`}
>
{/* Plan Header */}
<div className="text-center mb-6">
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<CreditCard className="h-8 w-8 text-brand-purple " />
</div>
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan.name}
</h2>
{plan.description && (
<p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{plan.description}
</p>
)}
</div>
{/* Pricing */}
<div className="text-center mb-8">
<div className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Starting at
</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(minimumPrice)}
</div>
{suggestedPrice > minimumPrice && (
<div className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Suggested: {formatPrice(suggestedPrice)}
</div>
)}
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getBillingCycleLabel(plan.billing_cycle)}
</p>
{plan.allow_donation && (
<div className="mt-2 flex items-center justify-center gap-1 text-xs text-[var(--orange-light)]">
<Heart className="h-3 w-3" />
<span>Donations welcome</span>
</div>
)}
</div>
{/* Features */}
<div className="space-y-3 mb-8">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Access to all member events</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Community directory access</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Exclusive member benefits</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Newsletter subscription</span>
</div>
</div>
{/* CTA Button */}
<Button
onClick={() => handleSelectPlan(plan)}
disabled={processingPlanId === plan.id || (statusInfo && !statusInfo.canSubscribe)}
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
data-testid={`subscribe-button-${plan.id}`}
>
{processingPlanId === plan.id ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processing...
</>
) : statusInfo && !statusInfo.canSubscribe ? (
'Validation Required'
) : (
'Choose Amount & Subscribe'
)}
</Button>
</Card>
);
})}
</div>
) : (
<div className="text-center py-20">
<CreditCard className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Plans Available
</h3>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Membership plans are not currently available. Please check back later!
</p>
</div>
)}
{/* Info Section */}
<div className="mt-16 max-w-3xl mx-auto">
<Card className="p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
Need Help Choosing?
</h3>
<p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If you have any questions about our membership plans or need assistance, please contact us.
</p>
<div className="text-center">
<a
href="mailto:support@loaf.org"
className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
>
support@loaf.org
</a>
</div>
</Card>
</div>
</div>
{/* Amount Selection Dialog */}
<Dialog open={amountDialogOpen} onOpenChange={setAmountDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Choose Your Amount
</DialogTitle>
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan?.name} - {getBillingCycleLabel(selectedPlan?.billing_cycle)}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Amount Input */}
<div>
<Label htmlFor="amount" className="text-[var(--purple-ink)]">
Amount (USD) *
</Label>
<div className="relative mt-2">
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-brand-purple text-lg font-semibold">
$
</span>
<Input
id="amount"
type="number"
step="0.01"
min={selectedPlan ? (selectedPlan.minimum_price_cents / 100).toFixed(2) : "30.00"}
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
className="pl-8 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="50.00"
/>
</div>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Minimum: {selectedPlan ? formatPrice(selectedPlan.minimum_price_cents || 3000) : '$30.00'}
</p>
</div>
{/* Breakdown Display */}
{breakdown && breakdown.total >= breakdown.base && (
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex justify-between text-[var(--purple-ink)]">
<span>Membership Fee:</span>
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
</div>
{breakdown.donation > 0 && (
<div className="flex justify-between text-[var(--orange-light)]">
<span className="flex items-center gap-1">
<Heart className="h-4 w-4" />
Additional Donation:
</span>
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
</div>
)}
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
<span>Total:</span>
<span>{formatPrice(breakdown.total)}</span>
</div>
</div>
</Card>
)}
{/* Donation Message */}
{selectedPlan?.allow_donation && (
<div className="bg-[var(--neutral-800)]/20 rounded-lg p-4">
<p className="text-sm text-[var(--purple-ink)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>Thank you for supporting our community!</strong><br />
Your donation helps us continue our mission and provide meaningful experiences for all members.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAmountDialogOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
onClick={handleCheckout}
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
Continue to Checkout
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default Plans;