Update:- Membership Plan- Donation- Member detail for Member Directory

This commit is contained in:
Koncept Kit
2025-12-11 19:29:00 +07:00
parent da211b6e38
commit 59f50f3fac
21 changed files with 1851 additions and 197 deletions

View File

@@ -22,15 +22,40 @@ import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import api from '../utils/api';
const MONTHS = [
{ value: 1, label: 'January' },
{ value: 2, label: 'February' },
{ value: 3, label: 'March' },
{ value: 4, label: 'April' },
{ value: 5, label: 'May' },
{ value: 6, label: 'June' },
{ value: 7, label: 'July' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'October' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' }
];
const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
price_cents: '',
price_cents: 3000, // Legacy field, default $30
billing_cycle: 'yearly',
stripe_price_id: '',
active: true
active: true,
// Custom billing cycle
custom_cycle_enabled: false,
custom_cycle_start_month: 1,
custom_cycle_start_day: 1,
custom_cycle_end_month: 12,
custom_cycle_end_day: 31,
// Dynamic pricing
minimum_price_cents: 3000, // $30 minimum
suggested_price_cents: 3000,
allow_donation: true
});
useEffect(() => {
@@ -38,19 +63,35 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
setFormData({
name: plan.name,
description: plan.description || '',
price_cents: plan.price_cents,
price_cents: plan.price_cents || 3000,
billing_cycle: plan.billing_cycle,
stripe_price_id: plan.stripe_price_id || '',
active: plan.active
active: plan.active,
custom_cycle_enabled: plan.custom_cycle_enabled || false,
custom_cycle_start_month: plan.custom_cycle_start_month || 1,
custom_cycle_start_day: plan.custom_cycle_start_day || 1,
custom_cycle_end_month: plan.custom_cycle_end_month || 12,
custom_cycle_end_day: plan.custom_cycle_end_day || 31,
minimum_price_cents: plan.minimum_price_cents || 3000,
suggested_price_cents: plan.suggested_price_cents || plan.minimum_price_cents || 3000,
allow_donation: plan.allow_donation !== undefined ? plan.allow_donation : true
});
} else {
setFormData({
name: '',
description: '',
price_cents: '',
price_cents: 3000,
billing_cycle: 'yearly',
stripe_price_id: '',
active: true
active: true,
custom_cycle_enabled: false,
custom_cycle_start_month: 1,
custom_cycle_start_day: 1,
custom_cycle_end_month: 12,
custom_cycle_end_day: 31,
minimum_price_cents: 3000,
suggested_price_cents: 3000,
allow_donation: true
});
}
}, [plan, open]);
@@ -60,6 +101,30 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
setLoading(true);
try {
// Validate minimum price
if (formData.minimum_price_cents < 3000) {
toast.error('Minimum price must be at least $30');
setLoading(false);
return;
}
// Validate suggested price
if (formData.suggested_price_cents < formData.minimum_price_cents) {
toast.error('Suggested price must be >= minimum price');
setLoading(false);
return;
}
// Validate custom cycle dates if enabled
if (formData.custom_cycle_enabled) {
if (!formData.custom_cycle_start_month || !formData.custom_cycle_start_day ||
!formData.custom_cycle_end_month || !formData.custom_cycle_end_day) {
toast.error('All custom cycle dates must be provided');
setLoading(false);
return;
}
}
const endpoint = plan
? `/admin/subscriptions/plans/${plan.id}`
: '/admin/subscriptions/plans';
@@ -68,7 +133,9 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
await api[method](endpoint, {
...formData,
price_cents: parseInt(formData.price_cents)
price_cents: parseInt(formData.price_cents),
minimum_price_cents: parseInt(formData.minimum_price_cents),
suggested_price_cents: parseInt(formData.suggested_price_cents)
});
toast.success(plan ? 'Plan updated successfully' : 'Plan created successfully');
@@ -85,14 +152,14 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
return (cents / 100).toFixed(2);
};
const handlePriceChange = (e) => {
const dollars = parseFloat(e.target.value) || 0;
setFormData({ ...formData, price_cents: Math.round(dollars * 100) });
const handlePriceChange = (field, value) => {
const dollars = parseFloat(value) || 0;
setFormData({ ...formData, [field]: Math.round(dollars * 100) });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan ? 'Edit Plan' : 'Create New Plan'}
@@ -129,57 +196,167 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
/>
</div>
{/* Price & Billing Cycle - Side by side */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="price">Price (USD) *</Label>
<Input
id="price"
type="number"
step="0.01"
min="0"
value={formatPriceForDisplay(formData.price_cents)}
onChange={handlePriceChange}
placeholder="50.00"
required
className="mt-2"
/>
{/* Dynamic Pricing */}
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Dynamic Pricing
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="minimum_price">Minimum Price (USD) *</Label>
<Input
id="minimum_price"
type="number"
step="0.01"
min="30"
value={formatPriceForDisplay(formData.minimum_price_cents)}
onChange={(e) => handlePriceChange('minimum_price_cents', e.target.value)}
placeholder="30.00"
required
className="mt-2"
/>
<p className="text-xs text-[#664fa3] mt-1">Minimum $30</p>
</div>
<div>
<Label htmlFor="suggested_price">Suggested Price (USD) *</Label>
<Input
id="suggested_price"
type="number"
step="0.01"
min="30"
value={formatPriceForDisplay(formData.suggested_price_cents)}
onChange={(e) => handlePriceChange('suggested_price_cents', e.target.value)}
placeholder="50.00"
required
className="mt-2"
/>
<p className="text-xs text-[#664fa3] mt-1">Pre-filled amount</p>
</div>
</div>
<div>
<Label htmlFor="billing_cycle">Billing Cycle *</Label>
<Select
value={formData.billing_cycle}
onValueChange={(value) => setFormData({ ...formData, billing_cycle: value })}
>
<SelectTrigger className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="lifetime">Lifetime</SelectItem>
</SelectContent>
</Select>
{/* Allow Donation Toggle */}
<div className="flex items-center justify-between pt-2">
<div>
<Label htmlFor="allow_donation">Allow Donations</Label>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Members can pay more than minimum
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="allow_donation"
checked={formData.allow_donation}
onChange={(e) => setFormData({ ...formData, allow_donation: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#664fa3]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#81B29A]"></div>
</label>
</div>
</div>
{/* Stripe Price ID */}
{/* Billing Cycle */}
<div>
<Label htmlFor="stripe_price_id">Stripe Price ID</Label>
<Input
id="stripe_price_id"
value={formData.stripe_price_id}
onChange={(e) => setFormData({ ...formData, stripe_price_id: e.target.value })}
placeholder="price_xxxxxxxxxxxxx"
className="mt-2 font-mono text-sm"
/>
<p className="text-sm text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Optional. Leave empty for manual/test plans.
</p>
<Label htmlFor="billing_cycle">Billing Cycle *</Label>
<Select
value={formData.billing_cycle}
onValueChange={(value) => setFormData({ ...formData, billing_cycle: value, custom_cycle_enabled: value === 'custom' })}
>
<SelectTrigger className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="lifetime">Lifetime</SelectItem>
<SelectItem value="custom">Custom Billing Cycle</SelectItem>
</SelectContent>
</Select>
</div>
{/* Custom Billing Cycle Dates */}
{formData.billing_cycle === 'custom' && (
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Custom Billing Period
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Set recurring date range (e.g., Jan 1 - Dec 31 for calendar year)
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Start Date</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<Select
value={formData.custom_cycle_start_month?.toString()}
onValueChange={(value) => setFormData({ ...formData, custom_cycle_start_month: parseInt(value) })}
>
<SelectTrigger>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{MONTHS.map(month => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min="1"
max="31"
value={formData.custom_cycle_start_day}
onChange={(e) => setFormData({ ...formData, custom_cycle_start_day: parseInt(e.target.value) || 1 })}
placeholder="Day"
/>
</div>
</div>
<div>
<Label>End Date</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<Select
value={formData.custom_cycle_end_month?.toString()}
onValueChange={(value) => setFormData({ ...formData, custom_cycle_end_month: parseInt(value) })}
>
<SelectTrigger>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{MONTHS.map(month => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min="1"
max="31"
value={formData.custom_cycle_end_day}
onChange={(e) => setFormData({ ...formData, custom_cycle_end_day: parseInt(e.target.value) || 31 })}
placeholder="Day"
/>
</div>
</div>
</div>
<div className="bg-[#f9f5ff] border border-[#DDD8EB] rounded p-3">
<p className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>Example:</strong> Jan 1 - Dec 31 for calendar year, or Jul 1 - Jun 30 for fiscal year
</p>
</div>
</div>
)}
{/* Active Toggle */}
<div className="flex items-center justify-between">
<div>