- Migrate from tailwindcss v3.3 to v4.1.18 - Replace @tailwind directives with @import 'tailwindcss' - Move custom colors to @theme block in globals.css - Convert custom utilities to @utility syntax - Update PostCSS config to use @tailwindcss/postcss - Remove autoprefixer (now built-in)
322 lines
16 KiB
TypeScript
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-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-xs">
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleCreate}
|
|
className="flex-1 py-3 bg-orange-600 hover:bg-orange-700 text-white font-bold rounded-2xl 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-zinc-800 hover:bg-zinc-700 text-white font-bold rounded-2xl transition-colors flex items-center justify-center gap-2 disabled:opacity-50 border border-zinc-700"
|
|
>
|
|
<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-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active
|
|
? 'border-orange-500/30'
|
|
: 'border-zinc-800 opacity-60'
|
|
} shadow-xs relative`}
|
|
>
|
|
{!plan.is_active && (
|
|
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded-sm">
|
|
Inactive
|
|
</div>
|
|
)}
|
|
<div className="mb-4">
|
|
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter">{plan.display_name}</h3>
|
|
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mt-1">{plan.name}</p>
|
|
</div>
|
|
<div className="mb-4">
|
|
<div className="text-3xl font-bold text-orange-600 tracking-tighter">{plan.monthly_credits}</div>
|
|
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Credits/Month</div>
|
|
</div>
|
|
<div className="mb-4">
|
|
<div className="text-2xl font-bold text-white tracking-tight">€{plan.price.toFixed(2)}</div>
|
|
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest leading-none mt-1">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-800 hover:bg-zinc-700 text-white font-bold rounded-xl text-[10px] uppercase tracking-widest transition-colors flex items-center justify-center gap-2 border border-zinc-700"
|
|
>
|
|
<Edit size={14} />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(plan.id)}
|
|
className="px-3 py-2 bg-red-600/20 hover:bg-red-600 text-red-500 hover:text-white font-bold rounded-xl text-sm transition-colors border border-red-900/50"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Edit/Create Modal */}
|
|
{(editingPlan || isCreating) && (
|
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
|
|
<div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
|
|
{isCreating ? 'Create Plan' : 'Edit Plan'}
|
|
</h3>
|
|
<button
|
|
onClick={() => { setEditingPlan(null); setIsCreating(false); }}
|
|
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[10px] font-bold text-zinc-500 uppercase tracking-widest 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-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<label className="flex items-center gap-2 cursor-pointer pb-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_active}
|
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
|
className="w-5 h-5 rounded-sm border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600"
|
|
/>
|
|
<span className="text-sm font-bold text-white">Active</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={loading}
|
|
className="w-full py-4 bg-orange-600 hover:bg-orange-700 text-white font-bold rounded-2xl transition-all shadow-lg shadow-orange-950/20 disabled:opacity-50 flex items-center justify-center gap-2 mt-4"
|
|
>
|
|
<Check size={18} />
|
|
{isCreating ? 'Create Plan' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|