feat: implement authentication system with email verification, user management dashboard, and administrative business controls
Some checks failed
Build and Push App / build (push) Failing after 1m9s
Some checks failed
Build and Push App / build (push) Failing after 1m9s
This commit is contained in:
2
admin/package-lock.json
generated
2
admin/package-lock.json
generated
@@ -55,6 +55,7 @@
|
|||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"nodemailer": "^8.0.6",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.10",
|
||||||
"prisma": "6.19.3",
|
"prisma": "6.19.3",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"typescript": "~5.8.2"
|
"typescript": "~5.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"@prisma/client": "6.19.3",
|
"@prisma/client": "6.19.3",
|
||||||
"@prisma/config": "6.19.3",
|
"@prisma/config": "6.19.3",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"afrohub": "file:..",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ model User {
|
|||||||
password String
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
verificationToken String? @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bio String?
|
bio String?
|
||||||
@@ -312,9 +314,11 @@ model SiteSetting {
|
|||||||
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
||||||
homeBlogCount Int @default(3)
|
homeBlogCount Int @default(3)
|
||||||
homeBlogSelection String @default("latest")
|
homeBlogSelection String @default("latest")
|
||||||
homeBlogIds String[] @default([])
|
|
||||||
homeBlogCategories String[] @default([])
|
|
||||||
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
||||||
|
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
||||||
|
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
||||||
|
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
|
||||||
|
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export async function getCountries() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addCountry(data: { name: string, code: string, flag?: string }) {
|
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
|
||||||
try {
|
try {
|
||||||
const country = await prisma.country.create({
|
const country = await prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
code: data.code.toUpperCase(),
|
code: data.code.toUpperCase(),
|
||||||
flag: data.flag || null,
|
flag: data.flag || null,
|
||||||
|
description: data.description || null,
|
||||||
isActive: true
|
isActive: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,3 +59,17 @@ export async function toggleCountryStatus(id: string, isActive: boolean) {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCountryDescription(id: string, description: string) {
|
||||||
|
try {
|
||||||
|
await prisma.country.update({
|
||||||
|
where: { id },
|
||||||
|
data: { description }
|
||||||
|
});
|
||||||
|
revalidatePath('/countries');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating country description:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,31 @@ export async function getSiteSettings() {
|
|||||||
homeBlogSelection: "latest",
|
homeBlogSelection: "latest",
|
||||||
homeBlogIds: [],
|
homeBlogIds: [],
|
||||||
homeBlogCategories: [],
|
homeBlogCategories: [],
|
||||||
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"]
|
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
|
||||||
|
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
|
||||||
|
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
|
</div>
|
||||||
|
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`,
|
||||||
|
verifyEmailSubject: "Vérification de votre compte - Afrohub",
|
||||||
|
verifyEmailTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
<p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!settings) return defaultSettings;
|
if (!settings) return defaultSettings;
|
||||||
|
|||||||
@@ -25,3 +25,19 @@ export async function getClients() {
|
|||||||
return { success: false, error: "Erreur lors de la récupération des clients" };
|
return { success: false, error: "Erreur lors de la récupération des clients" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyUserEmail(userId: string) {
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to verify user email:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { getClients } from "@/app/actions/user";
|
|
||||||
import { User, Mail, Phone, MapPin } from "lucide-react";
|
|
||||||
import ClientActions from "@/components/ClientActions";
|
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
|
||||||
const result = await getClients();
|
|
||||||
const clients = result.success ? (result.data || []) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Clients</h1>
|
|
||||||
<p className="text-slate-400">Liste des utilisateurs n'ayant pas encore de boutique active.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800/50 px-4 py-2 rounded-lg border border-slate-700/50">
|
|
||||||
<span className="text-slate-400 text-sm">Total Clients: </span>
|
|
||||||
<span className="text-indigo-400 font-bold ml-1">{clients.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Utilisateur</th>
|
|
||||||
<th>Contact</th>
|
|
||||||
<th>Localisation</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th className="text-right">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800">
|
|
||||||
{clients.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="py-12 text-center text-slate-500">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<User className="w-8 h-8 opacity-20" />
|
|
||||||
<p>Aucun client trouvé.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
clients.map((client: any) => (
|
|
||||||
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
|
||||||
<td className="py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
|
||||||
{client.avatar ? (
|
|
||||||
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
client.name.charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-semibold">{client.name}</p>
|
|
||||||
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
|
||||||
<Mail className="w-3 h-3 text-slate-500" />
|
|
||||||
{client.email}
|
|
||||||
</div>
|
|
||||||
{client.phone && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
|
||||||
<Phone className="w-3 h-3 text-slate-500" />
|
|
||||||
{client.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
||||||
<MapPin className="w-3 h-3" />
|
|
||||||
{client.location || "Non spécifié"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{client.isSuspended ? (
|
|
||||||
<>
|
|
||||||
<span className="badge badge-error">Compte Suspendu</span>
|
|
||||||
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
|
||||||
{client.suspensionReason}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : client.businesses ? (
|
|
||||||
<span className="badge badge-pending">Shop Inactif</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge bg-slate-800 text-slate-400">Actif</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useTransition } from 'react';
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries';
|
import { getCountries, addCountry, deleteCountry, toggleCountryStatus, updateCountryDescription } from '@/app/actions/countries';
|
||||||
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react';
|
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag, Edit2, Check, X } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
export default function CountriesPage() {
|
export default function CountriesPage() {
|
||||||
const [countries, setCountries] = useState<any[]>([]);
|
const [countries, setCountries] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -27,9 +29,10 @@ export default function CountriesPage() {
|
|||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const code = formData.get('code') as string;
|
const code = formData.get('code') as string;
|
||||||
const flag = formData.get('flag') as string;
|
const flag = formData.get('flag') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await addCountry({ name, code, flag });
|
const result = await addCountry({ name, code, flag, description });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Pays ajouté avec succès");
|
toast.success("Pays ajouté avec succès");
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
@@ -55,10 +58,22 @@ export default function CountriesPage() {
|
|||||||
const handleToggleStatus = async (id: string, current: boolean) => {
|
const handleToggleStatus = async (id: string, current: boolean) => {
|
||||||
const result = await toggleCountryStatus(id, !current);
|
const result = await toggleCountryStatus(id, !current);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
toast.success(!current ? "Pays activé" : "Pays désactivé");
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDescription = async (id: string) => {
|
||||||
|
const result = await updateCountryDescription(id, editDesc);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Description mise à jour");
|
||||||
|
setEditingId(null);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error("Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-20">
|
<div className="flex items-center justify-center p-20">
|
||||||
@@ -85,7 +100,8 @@ export default function CountriesPage() {
|
|||||||
{isAdding && (
|
{isAdding && (
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
|
||||||
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
|
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
|
||||||
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
<form onSubmit={handleAdd} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
|
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
|
||||||
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
@@ -98,13 +114,20 @@ export default function CountriesPage() {
|
|||||||
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
|
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
|
||||||
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<button disabled={isPending} type="submit" className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white p-3 rounded-xl font-bold transition-all disabled:opacity-50">
|
|
||||||
{isPending ? 'Chargement...' : 'Enregistrer'}
|
<div className="space-y-2">
|
||||||
</button>
|
<label className="text-sm font-medium text-slate-400">Note / Description (interne)</label>
|
||||||
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white p-3 rounded-xl font-bold transition-all">
|
<textarea name="description" placeholder="Ajouter un commentaire sur ce pays..." rows={2} className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-xl font-bold transition-all">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
|
<button disabled={isPending} type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white px-10 py-3 rounded-xl font-bold transition-all disabled:opacity-50">
|
||||||
|
{isPending ? 'Chargement...' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +139,7 @@ export default function CountriesPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Pays</th>
|
<th className="px-6 py-4">Pays</th>
|
||||||
<th className="px-6 py-4">Code</th>
|
<th className="px-6 py-4">Code</th>
|
||||||
|
<th className="px-6 py-4">Description / Notes</th>
|
||||||
<th className="px-6 py-4">Status</th>
|
<th className="px-6 py-4">Status</th>
|
||||||
<th className="px-6 py-4 text-right">Actions</th>
|
<th className="px-6 py-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -123,7 +147,7 @@ export default function CountriesPage() {
|
|||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{countries.length === 0 ? (
|
{countries.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
|
<td colSpan={5} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : countries.map((country) => (
|
) : countries.map((country) => (
|
||||||
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
|
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
@@ -134,15 +158,55 @@ export default function CountriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
|
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{editingId === country.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 w-full"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => handleSaveDescription(country.id)} className="text-emerald-500 hover:text-emerald-400">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-slate-500 hover:text-slate-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center gap-2 max-w-xs">
|
||||||
|
<span className="text-slate-400 text-sm truncate">
|
||||||
|
{country.description || <span className="italic text-slate-600">Pas de note</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(country.id);
|
||||||
|
setEditDesc(country.description || "");
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-white transition-opacity"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
country.isActive ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-500/10 text-slate-500'
|
country.isActive ? 'bg-indigo-600' : 'bg-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{country.isActive ? 'Actif' : 'Inactif'}
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
country.isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<span className={`ml-3 text-xs font-bold ${country.isActive ? 'text-emerald-500' : 'text-slate-500'}`}>
|
||||||
|
{country.isActive ? 'ACTIF' : 'INACTIF'}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">
|
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
|
||||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
|
||||||
import PlanSelector from '@/components/PlanSelector';
|
|
||||||
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
async function getBusinesses() {
|
|
||||||
return await prisma.business.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
owner: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EntrepreneursPage() {
|
|
||||||
const businesses = await getBusinesses();
|
|
||||||
const pendingCount = businesses.filter((b: any) =>
|
|
||||||
!b.verified &&
|
|
||||||
!b.isSuspended &&
|
|
||||||
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
|
|
||||||
<p className="text-slate-400">Gérer les abonnements et certifier les comptes (Badge Bleu).</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse">
|
|
||||||
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
|
|
||||||
<ShieldCheck className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
|
|
||||||
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Entreprise</th>
|
|
||||||
<th>Catégorie</th>
|
|
||||||
<th>Propriétaire</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th className="text-center">Forfait</th>
|
|
||||||
<th className="text-center">Visibilité</th>
|
|
||||||
<th className="text-center">Mise en avant</th>
|
|
||||||
<th className="text-right">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{businesses.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="text-center py-10 text-slate-500">
|
|
||||||
Aucun entrepreneur trouvé.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
businesses.map((business: any) => (
|
|
||||||
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
|
||||||
<td>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
|
||||||
{business.logoUrl ? (
|
|
||||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500">LOGO</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-white">{business.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{business.location}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="text-sm text-slate-300">{business.category}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="text-sm text-white">{business.owner.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{business.owner?.isSuspended ? (
|
|
||||||
<span className="badge badge-error flex items-center gap-1">
|
|
||||||
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
|
||||||
</span>
|
|
||||||
) : business.isSuspended ? (
|
|
||||||
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
|
|
||||||
<ShieldAlert className="w-3 h-3" /> Shop Masqué
|
|
||||||
</span>
|
|
||||||
) : business.verified ? (
|
|
||||||
<span className="badge badge-verified">Vérifié</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-pending">En attente</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-center">
|
|
||||||
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
|
||||||
</td>
|
|
||||||
<td className="text-center font-medium text-slate-300">
|
|
||||||
{business.viewCount.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="text-center">
|
|
||||||
<FeaturedModal business={business} />
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<ToggleVerifyButton id={business.id} verified={business.verified} />
|
|
||||||
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
|
||||||
<EntrepreneurActions
|
|
||||||
businessId={business.id}
|
|
||||||
businessName={business.name}
|
|
||||||
userId={business.owner.id}
|
|
||||||
userName={business.owner.name}
|
|
||||||
isBusinessSuspended={!!business.isSuspended}
|
|
||||||
isUserSuspended={!!business.owner?.isSuspended}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Ne
|
|||||||
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { getBlogPosts } from '@/app/actions/blog';
|
import { getBlogPosts } from '@/app/actions/blog';
|
||||||
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
|
|
||||||
const BUSINESS_CATEGORIES = [
|
const BUSINESS_CATEGORIES = [
|
||||||
"Technologie & IT",
|
"Technologie & IT",
|
||||||
@@ -33,7 +34,11 @@ const BLOG_CATEGORIES = [
|
|||||||
"Formation"
|
"Formation"
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [settings, setSettings] = useState<any>(null);
|
const [settings, setSettings] = useState<any>(null);
|
||||||
const [allPosts, setAllPosts] = useState<any[]>([]);
|
const [allPosts, setAllPosts] = useState<any[]>([]);
|
||||||
@@ -41,6 +46,12 @@ export default function SettingsPage() {
|
|||||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||||
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
||||||
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
||||||
|
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
|
||||||
|
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
|
||||||
|
|
||||||
|
// Use a state for the tab if we want it to be fully client-side reactive without full reload
|
||||||
|
// But since the user wants it "like moderation center", I'll use URL search params
|
||||||
|
const [currentTab, setCurrentTab] = useState('general');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -53,6 +64,8 @@ export default function SettingsPage() {
|
|||||||
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||||
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||||
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||||
|
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
||||||
|
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
@@ -80,6 +93,10 @@ export default function SettingsPage() {
|
|||||||
homeBlogIds: selectedPostIds,
|
homeBlogIds: selectedPostIds,
|
||||||
homeBlogCategories: selectedBlogCategories,
|
homeBlogCategories: selectedBlogCategories,
|
||||||
homeCategories: selectedHomeCategories,
|
homeCategories: selectedHomeCategories,
|
||||||
|
resetPasswordSubject: formData.get('resetPasswordSubject'),
|
||||||
|
resetPasswordTemplate: resetPasswordTemplate,
|
||||||
|
verifyEmailSubject: formData.get('verifyEmailSubject'),
|
||||||
|
verifyEmailTemplate: verifyEmailTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -123,11 +140,31 @@ export default function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
|
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
|
||||||
<p className="text-slate-400">Gérez les informations globales et les coordonnées de contact du site principal.</p>
|
<p className="text-slate-400">Gérez les informations globales, les sections de l'accueil et les modèles d'emails.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Switcher */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('general')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Général
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('emails')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
Configuration Mail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{currentTab === 'general' && (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
{/* Section Générale */}
|
{/* Section Générale */}
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
<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">
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
@@ -358,7 +395,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="facebookUrl"
|
name="facebookUrl"
|
||||||
defaultValue={settings?.facebookUrl}
|
defaultValue={settings?.facebookUrl}
|
||||||
@@ -370,7 +407,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="twitterUrl"
|
name="twitterUrl"
|
||||||
defaultValue={settings?.twitterUrl}
|
defaultValue={settings?.twitterUrl}
|
||||||
@@ -382,7 +419,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="instagramUrl"
|
name="instagramUrl"
|
||||||
defaultValue={settings?.instagramUrl}
|
defaultValue={settings?.instagramUrl}
|
||||||
@@ -394,7 +431,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkIcon className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
name="linkedinUrl"
|
name="linkedinUrl"
|
||||||
defaultValue={settings?.linkedinUrl}
|
defaultValue={settings?.linkedinUrl}
|
||||||
@@ -405,6 +442,71 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'emails' && (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{/* Section Mail */}
|
||||||
|
<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">
|
||||||
|
<Mail className="w-5 h-5 text-orange-400" />
|
||||||
|
<h2 className="font-semibold text-white">Gestion des Emails</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Password Reset */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="resetPasswordSubject"
|
||||||
|
defaultValue={settings?.resetPasswordSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={resetPasswordTemplate}
|
||||||
|
onChange={setResetPasswordTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{resetUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800"></div>
|
||||||
|
|
||||||
|
{/* Account Verification */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="verifyEmailSubject"
|
||||||
|
defaultValue={settings?.verifyEmailSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={verifyEmailTemplate}
|
||||||
|
onChange={setVerifyEmailTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{verifyUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -424,3 +526,4 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
298
admin/src/app/users/page.tsx
Normal file
298
admin/src/app/users/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getClients } from "@/app/actions/user";
|
||||||
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
|
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||||
|
import PlanSelector from '@/components/PlanSelector';
|
||||||
|
import ClientActions from "@/components/ClientActions";
|
||||||
|
import ManualVerifyButton from "@/components/ManualVerifyButton";
|
||||||
|
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
async function getBusinesses() {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UsersPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
|
const { tab = 'entrepreneurs' } = await searchParams;
|
||||||
|
|
||||||
|
// Data for Entrepreneurs
|
||||||
|
const businesses = await getBusinesses();
|
||||||
|
const pendingCount = businesses.filter((b: any) =>
|
||||||
|
!b.verified &&
|
||||||
|
!b.isSuspended &&
|
||||||
|
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Data for Clients/Visitors
|
||||||
|
const clientsResult = await getClients();
|
||||||
|
const clients = (clientsResult.success ? (clientsResult.data || []) : []) as any[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Utilisateurs</h1>
|
||||||
|
<p className="text-slate-400">Gérer les entrepreneurs et les visiteurs de la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<Link
|
||||||
|
href="/users?tab=entrepreneurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'entrepreneurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Entrepreneurs ({businesses.length})
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/users?tab=visiteurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'visiteurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Visiteurs ({clients.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'entrepreneurs' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse max-w-fit">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
|
||||||
|
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entreprise</th>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Propriétaire</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-center">Forfait</th>
|
||||||
|
<th className="text-center">Mise en avant</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{businesses.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="text-center py-10 text-slate-500">
|
||||||
|
Aucun entrepreneur trouvé.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
businesses.map((business: any) => (
|
||||||
|
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
|
{business.logoUrl ? (
|
||||||
|
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">LOGO</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-white">{business.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-sm text-slate-300">{business.category}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm text-white">{business.owner.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{business.owner.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={business.owner.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{business.owner?.isSuspended ? (
|
||||||
|
<span className="badge badge-error flex items-center gap-1">
|
||||||
|
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
||||||
|
</span>
|
||||||
|
) : business.isSuspended ? (
|
||||||
|
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
|
||||||
|
<ShieldAlert className="w-3 h-3" /> Shop Masqué
|
||||||
|
</span>
|
||||||
|
) : business.verified ? (
|
||||||
|
<span className="badge badge-verified">Vérifié</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-pending">En attente</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<FeaturedModal business={business} />
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<ToggleVerifyButton id={business.id} verified={business.verified} />
|
||||||
|
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
||||||
|
<EntrepreneurActions
|
||||||
|
businessId={business.id}
|
||||||
|
businessName={business.name}
|
||||||
|
userId={business.owner.id}
|
||||||
|
userName={business.owner.name}
|
||||||
|
isBusinessSuspended={!!business.isSuspended}
|
||||||
|
isUserSuspended={!!business.owner?.isSuspended}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Utilisateur</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Vérification</th>
|
||||||
|
<th>Localisation</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Users className="w-8 h-8 opacity-20" />
|
||||||
|
<p>Aucun visiteur trouvé.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
clients.map((client: any) => (
|
||||||
|
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
||||||
|
{client.avatar ? (
|
||||||
|
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
client.name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold">{client.name}</p>
|
||||||
|
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Mail className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.email}
|
||||||
|
</div>
|
||||||
|
{client.phone && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Phone className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{client.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={client.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{client.location || "Non spécifié"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{client.isSuspended ? (
|
||||||
|
<>
|
||||||
|
<span className="badge badge-error">Compte Suspendu</span>
|
||||||
|
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
||||||
|
{client.suspensionReason}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : client.businesses ? (
|
||||||
|
<span className="badge badge-pending">Shop Inactif</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-slate-800 text-slate-400">Actif</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
admin/src/components/ManualVerifyButton.tsx
Normal file
41
admin/src/components/ManualVerifyButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { verifyUserEmail } from '@/app/actions/user';
|
||||||
|
import { MailCheck, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManualVerifyButton({ userId }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await verifyUserEmail(userId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Email vérifié avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-indigo-500/20"
|
||||||
|
title="Vérifier manuellement l'email"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MailCheck className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Vérifier Email
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||||
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
|
{ name: 'Utilisateurs', icon: Users, href: '/users' },
|
||||||
{ name: 'Clients', icon: Users, href: '/clients' },
|
|
||||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
@@ -67,7 +66,7 @@ export default function Sidebar() {
|
|||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.name === 'Entrepreneurs' && pendingCount > 0 && (
|
{item.name === 'Utilisateurs' && pendingCount > 0 && (
|
||||||
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -34,12 +34,17 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
||||||
|
|
||||||
await sendEmail({
|
// Get site settings for email template
|
||||||
to: email,
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
subject: 'Réinitialisation de votre mot de passe - Afrohub',
|
where: { id: 'singleton' }
|
||||||
html: `
|
});
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; rounded-lg">
|
|
||||||
<h2 style="color: #0d9488; text-align: center;">Afrohub</h2>
|
const siteName = settings?.siteName || 'Afrohub';
|
||||||
|
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
|
||||||
|
|
||||||
|
let html = settings?.resetPasswordTemplate || `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
<p>Bonjour ${user.name},</p>
|
<p>Bonjour ${user.name},</p>
|
||||||
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
@@ -47,9 +52,20 @@ export async function POST(req: Request) {
|
|||||||
</div>
|
</div>
|
||||||
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||||
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 Afrohub. Tous droits réservés.</p>
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 ${siteName}. Tous droits réservés.</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html
|
||||||
|
.replace(/{name}/g, user.name)
|
||||||
|
.replace(/{resetUrl}/g, resetUrl)
|
||||||
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
|
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email is verified
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Omit password from response
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -36,16 +37,60 @@ export async function POST(request: NextRequest) {
|
|||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: 'VISITOR' // Default role
|
role: 'VISITOR', // Default role
|
||||||
|
verificationToken,
|
||||||
|
emailVerified: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: 'singleton' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteName = settings?.siteName || 'Afrohub';
|
||||||
|
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||||
|
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||||
|
|
||||||
|
let html = settings?.verifyEmailTemplate || `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
|
<p>Bonjour ${name},</p>
|
||||||
|
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
html = html
|
||||||
|
.replace(/{name}/g, name)
|
||||||
|
.replace(/{verifyUrl}/g, verifyUrl)
|
||||||
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
const { sendEmail } = await import('@/lib/mail');
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error('Failed to send verification email:', mailError);
|
||||||
|
// We don't fail registration if mail fails, but maybe we should?
|
||||||
|
// For now, just log it.
|
||||||
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Omit password from response
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
|||||||
35
app/api/auth/verify/route.ts
Normal file
35
app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Token manquant', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { verificationToken: token }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Token invalide ou expiré', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL('/login?verified=true', request.url));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Verification error:', error);
|
||||||
|
return NextResponse.redirect(new URL('/login?error=Erreur de vérification', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useUser } from '../../components/UserProvider';
|
import { useUser } from '../../components/UserProvider';
|
||||||
|
import { CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const { settings, login } = useUser();
|
const { settings, login } = useUser();
|
||||||
@@ -14,6 +15,10 @@ const LoginPage = () => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const verified = searchParams.get('verified') === 'true';
|
||||||
|
const queryError = searchParams.get('error');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -55,12 +60,23 @@ const LoginPage = () => {
|
|||||||
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1€</Link>
|
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1€</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
||||||
{error && (
|
<div className="mt-8 space-y-6">
|
||||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
|
{verified && (
|
||||||
{error}
|
<div className="bg-emerald-50 border-l-4 border-emerald-500 p-4 rounded flex items-center gap-3 text-emerald-700 text-sm">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-500 flex-shrink-0" />
|
||||||
|
<p>Votre compte a été vérifié avec succès ! Vous pouvez maintenant vous connecter.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(error || queryError) && (
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded flex items-center gap-3 text-red-700 text-sm">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||||
|
<p>{error || queryError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="rounded-md shadow-sm -space-y-px">
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -102,6 +118,7 @@ const LoginPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ model User {
|
|||||||
password String
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
verificationToken String? @unique
|
||||||
resetToken String? @unique
|
resetToken String? @unique
|
||||||
resetTokenExpiry DateTime?
|
resetTokenExpiry DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -99,6 +101,7 @@ model Country {
|
|||||||
code String @unique // ISO alpha-2
|
code String @unique // ISO alpha-2
|
||||||
flag String? // Emoji or URL
|
flag String? // Emoji or URL
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
description String? @db.Text
|
||||||
businesses Business[]
|
businesses Business[]
|
||||||
users User[]
|
users User[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -317,6 +320,10 @@ model SiteSetting {
|
|||||||
homeBlogIds String[] @default([])
|
homeBlogIds String[] @default([])
|
||||||
homeBlogCategories String[] @default([])
|
homeBlogCategories String[] @default([])
|
||||||
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
||||||
|
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
||||||
|
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
||||||
|
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
|
||||||
|
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user