Files
Dramlog-Prod/src/components/PlanManagementClient.tsx
robin 42b4b2b2e1 feat: implement subscription plan system with monthly credits
- Database schema:
  * subscription_plans table - stores plan tiers (Starter, Bronze, Silver, Gold)
  * user_subscriptions table - assigns users to plans
  * Default plans created (10, 50, 100, 250 credits/month)
  * All existing users assigned to Starter plan

- Subscription service (subscription-service.ts):
  * getAllPlans() - fetch all plans
  * getActivePlans() - fetch active plans for users
  * createPlan() - admin creates new plan
  * updatePlan() - admin edits plan
  * deletePlan() - admin removes plan
  * getUserSubscription() - get user's current plan
  * setUserPlan() - admin assigns user to plan
  * grantMonthlyCredits() - distribute credits to all users

- Plan management interface (/admin/plans):
  * Visual plan cards with credits, price, description
  * Create/Edit/Delete plans
  * Toggle active/inactive status
  * Sort order management
  * Grant monthly credits button (manual trigger)

- Features:
  * Monthly credit allocation based on plan
  * Prevents duplicate credit grants (tracks last_credit_grant_at)
  * Admin can manually trigger monthly credit distribution
  * Plans can be activated/deactivated
  * Custom pricing and credit amounts per plan

- UI:
  * Beautiful plan cards with color coding
  * Modal for create/edit with validation
  * Success/error messages
  * Manage Plans button in admin dashboard

Ready for future automation (cron job for monthly credits)
and payment integration (Stripe/PayPal).
2025-12-18 15:16:44 +01:00

322 lines
16 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Plus, Edit, Trash2, X, Check, AlertCircle, Zap } from 'lucide-react';
import { createPlan, updatePlan, deletePlan, grantMonthlyCredits, type SubscriptionPlan } from '@/services/subscription-service';
interface PlanManagementClientProps {
initialPlans: SubscriptionPlan[];
}
export default function PlanManagementClient({ initialPlans }: PlanManagementClientProps) {
const [plans, setPlans] = useState<SubscriptionPlan[]>(initialPlans);
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [formData, setFormData] = useState({
name: '',
display_name: '',
monthly_credits: 0,
price: 0,
description: '',
is_active: true,
sort_order: 0
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleEdit = (plan: SubscriptionPlan) => {
setEditingPlan(plan);
setFormData({
name: plan.name,
display_name: plan.display_name,
monthly_credits: plan.monthly_credits,
price: plan.price,
description: plan.description || '',
is_active: plan.is_active,
sort_order: plan.sort_order
});
setIsCreating(false);
setMessage(null);
};
const handleCreate = () => {
setEditingPlan(null);
setFormData({
name: '',
display_name: '',
monthly_credits: 0,
price: 0,
description: '',
is_active: true,
sort_order: plans.length + 1
});
setIsCreating(true);
setMessage(null);
};
const handleSave = async () => {
if (!formData.name || !formData.display_name || formData.monthly_credits <= 0) {
setMessage({ type: 'error', text: 'Please fill in all required fields' });
return;
}
setLoading(true);
setMessage(null);
if (isCreating) {
const result = await createPlan(formData);
if (result.success && result.plan) {
setPlans([...plans, result.plan]);
setMessage({ type: 'success', text: 'Plan created successfully' });
setIsCreating(false);
} else {
setMessage({ type: 'error', text: result.error || 'Failed to create plan' });
}
} else if (editingPlan) {
const result = await updatePlan(editingPlan.id, formData);
if (result.success) {
setPlans(plans.map(p => p.id === editingPlan.id ? { ...p, ...formData } : p));
setMessage({ type: 'success', text: 'Plan updated successfully' });
setEditingPlan(null);
} else {
setMessage({ type: 'error', text: result.error || 'Failed to update plan' });
}
}
setLoading(false);
};
const handleDelete = async (planId: string) => {
if (!confirm('Are you sure you want to delete this plan? Users on this plan will be unassigned.')) {
return;
}
setLoading(true);
const result = await deletePlan(planId);
if (result.success) {
setPlans(plans.filter(p => p.id !== planId));
setMessage({ type: 'success', text: 'Plan deleted successfully' });
} else {
setMessage({ type: 'error', text: result.error || 'Failed to delete plan' });
}
setLoading(false);
};
const handleGrantCredits = async () => {
if (!confirm('Grant monthly credits to all users based on their subscription plan?')) {
return;
}
setLoading(true);
setMessage(null);
const result = await grantMonthlyCredits();
if (result.success) {
setMessage({
type: 'success',
text: `Credits granted! Processed: ${result.processed}, Failed: ${result.failed}`
});
} else {
setMessage({ type: 'error', text: result.error || 'Failed to grant credits' });
}
setLoading(false);
};
return (
<div className="space-y-6">
{/* Actions Bar */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex gap-3">
<button
onClick={handleCreate}
className="flex-1 py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2"
>
<Plus size={18} />
Create New Plan
</button>
<button
onClick={handleGrantCredits}
disabled={loading}
className="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<Zap size={18} />
Grant Monthly Credits
</button>
</div>
</div>
{message && (
<div className={`p-4 rounded-xl flex items-center gap-3 ${message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-500'
}`}>
{message.type === 'success' ? <Check size={20} /> : <AlertCircle size={20} />}
{message.text}
</div>
)}
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{plans.map((plan) => (
<div
key={plan.id}
className={`bg-white dark:bg-zinc-900 rounded-2xl p-6 border-2 ${plan.is_active
? 'border-amber-200 dark:border-amber-800'
: 'border-zinc-200 dark:border-zinc-800 opacity-60'
} shadow-sm relative`}
>
{!plan.is_active && (
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 text-xs font-bold rounded">
Inactive
</div>
)}
<div className="mb-4">
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">{plan.display_name}</h3>
<p className="text-xs text-zinc-500 mt-1">{plan.name}</p>
</div>
<div className="mb-4">
<div className="text-3xl font-black text-amber-600">{plan.monthly_credits}</div>
<div className="text-xs text-zinc-500">Credits/Month</div>
</div>
<div className="mb-4">
<div className="text-2xl font-bold text-zinc-900 dark:text-white">{plan.price.toFixed(2)}</div>
<div className="text-xs text-zinc-500">per month</div>
</div>
{plan.description && (
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">{plan.description}</p>
)}
<div className="flex gap-2">
<button
onClick={() => handleEdit(plan)}
className="flex-1 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-bold rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
>
<Edit size={14} />
Edit
</button>
<button
onClick={() => handleDelete(plan.id)}
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg text-sm transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
{/* Edit/Create Modal */}
{(editingPlan || isCreating) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 max-w-2xl w-full border border-zinc-200 dark:border-zinc-800">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">
{isCreating ? 'Create Plan' : 'Edit Plan'}
</h3>
<button
onClick={() => { setEditingPlan(null); setIsCreating(false); }}
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Name (ID)</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. starter"
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Display Name</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="e.g. Starter"
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Monthly Credits</label>
<input
type="number"
value={formData.monthly_credits}
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Price ()</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the plan"
rows={3}
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Sort Order</label>
<input
type="number"
value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-5 h-5 rounded border-zinc-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-sm font-bold text-zinc-900 dark:text-white">Active</span>
</label>
</div>
</div>
<button
onClick={handleSave}
disabled={loading}
className="w-full py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<Check size={18} />
{isCreating ? 'Create Plan' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
</div>
);
}