411 lines
15 KiB
JavaScript
411 lines
15 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './ui/dialog';
|
|
import { Button } from './ui/button';
|
|
import { Input } from './ui/input';
|
|
import { Label } from './ui/label';
|
|
import { Textarea } from './ui/textarea';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from './ui/select';
|
|
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: 3000, // Legacy field, default $30
|
|
billing_cycle: 'yearly',
|
|
stripe_price_id: '',
|
|
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(() => {
|
|
if (plan) {
|
|
setFormData({
|
|
name: plan.name,
|
|
description: plan.description || '',
|
|
price_cents: plan.price_cents || 3000,
|
|
billing_cycle: plan.billing_cycle,
|
|
stripe_price_id: plan.stripe_price_id || '',
|
|
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: 3000,
|
|
billing_cycle: 'yearly',
|
|
stripe_price_id: '',
|
|
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]);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
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';
|
|
|
|
const method = plan ? 'put' : 'post';
|
|
|
|
await api[method](endpoint, {
|
|
...formData,
|
|
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');
|
|
onSuccess();
|
|
onOpenChange(false);
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Failed to save plan');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatPriceForDisplay = (cents) => {
|
|
return (cents / 100).toFixed(2);
|
|
};
|
|
|
|
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-[700px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{plan ? 'Edit Plan' : 'Create New Plan'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{plan ? 'Update plan details below' : 'Enter plan details to create a new subscription plan'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Name */}
|
|
<div>
|
|
<Label htmlFor="name">Plan Name *</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="e.g., Annual Membership"
|
|
required
|
|
className="mt-2"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Brief description of the plan benefits"
|
|
rows={3}
|
|
className="mt-2"
|
|
/>
|
|
</div>
|
|
|
|
{/* Dynamic Pricing */}
|
|
<div className="border-2 border-chart-6 rounded-lg p-4 space-y-4">
|
|
<h3 className="font-semibold text-primary" 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-muted-foreground 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-muted-foreground mt-1">Pre-filled amount</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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-muted-foreground" 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-muted-foreground/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-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-success"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Billing Cycle */}
|
|
<div>
|
|
<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-chart-6 rounded-lg p-4 space-y-4">
|
|
<h3 className="font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Custom Billing Period
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground" 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-lavender-mist border border-chart-6 rounded p-3">
|
|
<p className="text-sm text-primary" 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>
|
|
<Label htmlFor="active">Active Status</Label>
|
|
<p className="text-sm text-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Inactive plans won't appear for new subscriptions
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
id="active"
|
|
checked={formData.active}
|
|
onChange={(e) => setFormData({ ...formData, active: 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-muted-foreground/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-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-success"></div>
|
|
</label>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="bg-chart-6 text-primary hover:bg-background"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
plan ? 'Update Plan' : 'Create Plan'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default PlanDialog;
|