feat: implement home page, business directory, and administrative management features with updated schema and API routes
Some checks failed
Build and Push App / build (push) Failing after 51s
Some checks failed
Build and Push App / build (push) Failing after 51s
This commit is contained in:
111
admin/src/app/actions/categories.ts
Normal file
111
admin/src/app/actions/categories.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function getCategories() {
|
||||
try {
|
||||
return await prisma.businessCategory.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch categories:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
||||
try {
|
||||
const { suggestionId, ...categoryData } = data;
|
||||
|
||||
const category = await prisma.businessCategory.create({
|
||||
data: categoryData
|
||||
});
|
||||
|
||||
// Si c'est une conversion de suggestion, on lie automatiquement l'entreprise concernée
|
||||
if (suggestionId) {
|
||||
await prisma.business.update({
|
||||
where: { id: suggestionId },
|
||||
data: {
|
||||
category: category.name,
|
||||
categoryId: category.id,
|
||||
suggestedCategory: null // On nettoie le champ tampon car la suggestion est validée
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/categories");
|
||||
revalidatePath("/entrepreneurs");
|
||||
return { success: true, data: category };
|
||||
} catch (error) {
|
||||
console.error("Failed to create category:", error);
|
||||
return { success: false, error: "Erreur lors de la création de la catégorie" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
|
||||
try {
|
||||
// 1. Récupérer l'ancien nom pour la migration
|
||||
const oldCategory = await prisma.businessCategory.findUnique({
|
||||
where: { id },
|
||||
select: { name: true }
|
||||
});
|
||||
|
||||
// 2. Mettre à jour la catégorie
|
||||
const category = await prisma.businessCategory.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
|
||||
// 3. Si le nom a changé, mettre à jour tous les entrepreneurs qui l'utilisaient
|
||||
if (oldCategory && oldCategory.name !== data.name) {
|
||||
await prisma.business.updateMany({
|
||||
where: { category: oldCategory.name },
|
||||
data: { category: data.name }
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/categories");
|
||||
return { success: true, data: category };
|
||||
} catch (error) {
|
||||
console.error("Failed to update category:", error);
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
try {
|
||||
await prisma.businessCategory.delete({
|
||||
where: { id }
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/categories");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to delete category:", error);
|
||||
return { success: false, error: "Erreur lors de la suppression" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSuggestedCategories() {
|
||||
try {
|
||||
return await prisma.business.findMany({
|
||||
where: {
|
||||
suggestedCategory: { not: null },
|
||||
category: 'Autre'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
suggestedCategory: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggested categories:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ export async function updatePricingPlanDetail(id: string, data: any) {
|
||||
data: {
|
||||
name: data.name,
|
||||
priceXOF: data.priceXOF,
|
||||
yearlyPriceXOF: data.yearlyPriceXOF || null,
|
||||
priceEUR: data.priceEUR,
|
||||
yearlyPriceEUR: data.yearlyPriceEUR || null,
|
||||
description: data.description,
|
||||
features: data.features, // Expected to be string[]
|
||||
offerLimit: parseInt(data.offerLimit),
|
||||
|
||||
404
admin/src/app/categories/page.tsx
Normal file
404
admin/src/app/categories/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useTransition } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Briefcase,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Cpu,
|
||||
Sprout,
|
||||
Shirt,
|
||||
Utensils,
|
||||
HardHat,
|
||||
Stethoscope,
|
||||
GraduationCap,
|
||||
Palette,
|
||||
Plane,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
Leaf,
|
||||
Camera,
|
||||
Music,
|
||||
ShoppingBag,
|
||||
Heart,
|
||||
Home
|
||||
} from 'lucide-react';
|
||||
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const ICON_LIST = [
|
||||
{ name: 'Briefcase', icon: Briefcase },
|
||||
{ name: 'Cpu', icon: Cpu },
|
||||
{ name: 'Sprout', icon: Sprout },
|
||||
{ name: 'Shirt', icon: Shirt },
|
||||
{ name: 'Sparkles', icon: Sparkles },
|
||||
{ name: 'Utensils', icon: Utensils },
|
||||
{ name: 'HardHat', icon: HardHat },
|
||||
{ name: 'Stethoscope', icon: Stethoscope },
|
||||
{ name: 'GraduationCap', icon: GraduationCap },
|
||||
{ name: 'Palette', icon: Palette },
|
||||
{ name: 'Plane', icon: Plane },
|
||||
{ name: 'Truck', icon: Truck },
|
||||
{ name: 'Wallet', icon: Wallet },
|
||||
{ name: 'Zap', icon: Zap },
|
||||
{ name: 'Leaf', icon: Leaf },
|
||||
{ name: 'Camera', icon: Camera },
|
||||
{ name: 'Music', icon: Music },
|
||||
{ name: 'ShoppingBag', icon: ShoppingBag },
|
||||
{ name: 'Heart', icon: Heart },
|
||||
{ name: 'Home', icon: Home },
|
||||
];
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<any>(null);
|
||||
|
||||
const renderIcon = (iconName: string) => {
|
||||
const found = ICON_LIST.find(i => i.name === iconName);
|
||||
if (found) {
|
||||
const IconComp = found.icon;
|
||||
return <IconComp className="w-4 h-4 text-indigo-400" />;
|
||||
}
|
||||
return <Briefcase className="w-4 h-4 text-indigo-400" />;
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: 'Briefcase',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
async function loadCategories() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [catData, suggData] = await Promise.all([
|
||||
getCategories(),
|
||||
getSuggestedCategories()
|
||||
]);
|
||||
setCategories(catData);
|
||||
setSuggestions(suggData);
|
||||
} catch (e) {
|
||||
toast.error("Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
startTransition(async () => {
|
||||
const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id });
|
||||
if (result.success) {
|
||||
toast.success("Catégorie créée !");
|
||||
setShowAddModal(false);
|
||||
setSelectedSuggestion(null);
|
||||
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
|
||||
loadCategories();
|
||||
} else {
|
||||
toast.error(result.error || "Erreur");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingCategory) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateCategory(editingCategory.id, formData);
|
||||
if (result.success) {
|
||||
toast.success("Catégorie mise à jour !");
|
||||
setEditingCategory(null);
|
||||
loadCategories();
|
||||
} else {
|
||||
toast.error(result.error || "Erreur");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Supprimer cette catégorie ?")) return;
|
||||
|
||||
const result = await deleteCategory(id);
|
||||
if (result.success) {
|
||||
toast.success("Supprimée !");
|
||||
loadCategories();
|
||||
} else {
|
||||
toast.error(result.error || "Erreur");
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (cat: any) => {
|
||||
setEditingCategory(cat);
|
||||
setFormData({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon || 'Briefcase',
|
||||
isActive: cat.isActive
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Secteurs & Catégories</h1>
|
||||
<p className="text-slate-400">Gérez les secteurs d'activité disponibles pour les entrepreneurs.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAddModal(true); setEditingCategory(null); setSelectedSuggestion(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); }}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-indigo-600/20"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nouvelle Catégorie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-white">Catégories Officielles</h2>
|
||||
<span className="text-xs text-slate-400">{categories.length} secteurs</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Nom</th>
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{categories.map((cat) => (
|
||||
<tr key={cat.id} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center">
|
||||
{renderIcon(cat.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{cat.name}</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono">{cat.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{cat.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
|
||||
<Check className="w-3 h-3" /> Actif
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-500">
|
||||
<X className="w-3 h-3" /> Inactif
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openEdit(cat)}
|
||||
className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
<h2 className="font-semibold text-white">Suggestions</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Check className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Aucune suggestion en attente.</p>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((sugg) => (
|
||||
<div key={sugg.id} className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2 hover:border-indigo-500/30 transition-colors group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-400/10 px-2 py-0.5 rounded">Proposé</span>
|
||||
<span className="text-[10px] text-slate-500">{new Date(sugg.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-white font-bold text-lg">{sugg.suggestedCategory}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
<span>Par : <span className="text-slate-300 font-medium">{sugg.name}</span></span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSuggestion(sugg);
|
||||
setFormData({
|
||||
name: sugg.suggestedCategory,
|
||||
slug: sugg.suggestedCategory.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, ''),
|
||||
icon: 'Briefcase',
|
||||
isActive: true
|
||||
});
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="w-full mt-3 text-xs bg-indigo-600/10 text-indigo-400 hover:bg-indigo-600 hover:text-white font-bold py-2.5 rounded-lg transition-all border border-indigo-600/20"
|
||||
>
|
||||
Convertir en catégorie officielle
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showAddModal || editingCategory) && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedSuggestion && <Sparkles className="w-5 h-5 text-yellow-400" />}
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
{editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingCategory ? handleUpdateCategory : handleAddCategory} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Nom de la catégorie</label>
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
const slug = name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, '');
|
||||
setFormData({...formData, name, slug});
|
||||
}}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Slug (URL)</label>
|
||||
<input
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({...formData, slug: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
|
||||
className="w-5 h-5 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-slate-300">Rendre active immédiatement</label>
|
||||
</div>
|
||||
|
||||
{selectedSuggestion && (
|
||||
<div className="p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-xs text-indigo-300">
|
||||
<p>✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 block mb-2">Choisir une icône</label>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{ICON_LIST.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, icon: item.name})}
|
||||
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${
|
||||
formData.icon === item.name
|
||||
? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30 scale-105'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
|
||||
}`}
|
||||
title={item.name}
|
||||
>
|
||||
{typeof item.icon === 'function' ? item.icon() : <item.icon className="w-5 h-5" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all border border-slate-700"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
|
||||
>
|
||||
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{editingCategory ? 'Sauvegarder les modifications' : selectedSuggestion ? 'Valider et Créer' : 'Créer la catégorie'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default function PlansPage() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPlans() {
|
||||
@@ -29,9 +30,27 @@ export default function PlansPage() {
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1>
|
||||
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
|
||||
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle Billing */}
|
||||
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'monthly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Mensuel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'yearly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Annuel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
@@ -55,16 +74,27 @@ export default function PlansPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider">{plan.name}</h3>
|
||||
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider font-serif">{plan.name}</h3>
|
||||
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Price & Limit */}
|
||||
<div className="p-6 bg-slate-950/30 flex-1">
|
||||
<div className="mb-6">
|
||||
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div>
|
||||
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<div className="text-3xl font-bold text-white mb-1">
|
||||
{billingCycle === 'yearly'
|
||||
? (plan.yearlyPriceXOF || plan.priceXOF)
|
||||
: plan.priceXOF
|
||||
}
|
||||
<span className="text-xs text-slate-500 font-normal ml-2">
|
||||
/ {billingCycle === 'yearly' ? 'an' : 'mois'}
|
||||
</span>
|
||||
</div>
|
||||
{billingCycle === 'yearly' && plan.yearlyPriceXOF && (
|
||||
<div className="text-xs text-emerald-500 font-bold mb-2">PRIX ANNUEL ACTIF</div>
|
||||
)}
|
||||
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
||||
|
||||
@@ -16,7 +16,9 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
const [formData, setFormData] = useState({
|
||||
name: plan.name,
|
||||
priceXOF: plan.priceXOF,
|
||||
yearlyPriceXOF: plan.yearlyPriceXOF || '',
|
||||
priceEUR: plan.priceEUR,
|
||||
yearlyPriceEUR: plan.yearlyPriceEUR || '',
|
||||
description: plan.description,
|
||||
offerLimit: plan.offerLimit.toString(),
|
||||
recommended: plan.recommended,
|
||||
@@ -100,7 +102,7 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Texte)</label>
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
|
||||
<input
|
||||
value={formData.priceXOF}
|
||||
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
||||
@@ -108,13 +110,31 @@ export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalPr
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Texte)</label>
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
|
||||
<input
|
||||
value={formData.yearlyPriceXOF}
|
||||
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
|
||||
placeholder="ex: 45 000 FCFA"
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
|
||||
<input
|
||||
value={formData.priceEUR}
|
||||
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
|
||||
<input
|
||||
value={formData.yearlyPriceEUR}
|
||||
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
|
||||
placeholder="ex: 69 €"
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
CreditCard,
|
||||
Settings,
|
||||
Globe,
|
||||
FileText
|
||||
FileText,
|
||||
Briefcase
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
@@ -26,6 +27,7 @@ const menuItems = [
|
||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
||||
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' },
|
||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||
|
||||
Reference in New Issue
Block a user