1. Système d'Analytics (Nouveauté Majeure)
Modèle de Données : Ajout de la table AnalyticsEvent dans prisma/schema.prisma pour suivre les vues de pages, les clics et le temps de rétention (DWELL_TIME). API Backend : Création de la route app/api/analytics/route.ts pour enregistrer les événements en base de données. Tracking Client : Création du composant AnalyticsTracker.tsx (intégré dans le layout principal) qui capture automatiquement : Le changement de page (Page Views). Les clics sur les boutons et liens importants. Le temps passé sur chaque page. 2. Intégration Base de Données (PostgreSQL/Prisma) Fiches Entreprises : Mise à jour de app/directory/[id]/page.tsx pour récupérer les données réelles depuis PostgreSQL via Prisma au lieu d'utiliser les données de test (mockData). Navigation Dashboard : Correction du bouton "Voir ma fiche" dans le tableau de bord pour qu'il redirige correctement vers le profil public de l'entrepreneur. 3. Administration & Metrics Nouvelle Application Admin : Initialisation d'un dossier admin/ (application Next.js séparée) destinée à la gestion de la plateforme et à la visualisation des statistiques (Metrics & Analytics). 4. Authentification & Inscription Nouvelle Page : Création de app/register/page.tsx pour permettre l'inscription des nouveaux utilisateurs. Gestion de Session : Amélioration de UserProvider.tsx pour une meilleure gestion de l'état utilisateur à travers l'application. 5. Maintenance & Types Mise à jour des types globaux dans types.ts et rafraîchissement du package-lock.json suite à l'ajout de dépendances.
This commit is contained in:
138
admin/src/components/BlogForm.tsx
Normal file
138
admin/src/components/BlogForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
||||
import { Loader2, ArrowLeft, Save } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props {
|
||||
initialData?: {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const data = {
|
||||
title: formData.get('title') as string,
|
||||
excerpt: formData.get('excerpt') as string,
|
||||
content: formData.get('content') as string,
|
||||
author: formData.get('author') as string,
|
||||
imageUrl: formData.get('imageUrl') as string,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result = initialData
|
||||
? await updateBlogPost(initialData.id, data)
|
||||
: await createBlogPost(data);
|
||||
|
||||
if (result.success) {
|
||||
router.push('/blog');
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initialData?.title}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: L'essor de la Tech en Afrique"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Auteur</label>
|
||||
<input
|
||||
name="author"
|
||||
defaultValue={initialData?.author}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: Jean Dupont"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
|
||||
<input
|
||||
name="imageUrl"
|
||||
defaultValue={initialData?.imageUrl}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="https://images.unsplash.com/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
|
||||
<textarea
|
||||
name="excerpt"
|
||||
defaultValue={initialData?.excerpt}
|
||||
required
|
||||
rows={2}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Un court résumé de l'article..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
|
||||
<textarea
|
||||
name="content"
|
||||
defaultValue={initialData?.content}
|
||||
required
|
||||
rows={12}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500 font-serif leading-relaxed"
|
||||
placeholder="Rédigez votre article ici..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-6 h-6" />
|
||||
)}
|
||||
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
admin/src/components/DeleteBlogButton.tsx
Normal file
24
admin/src/components/DeleteBlogButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { deleteBlogPost } from '@/app/actions/blog';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function DeleteBlogButton({ id }: { id: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Supprimer cet article ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteBlogPost(id);
|
||||
if (!result.success) alert(result.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
39
admin/src/components/DeleteCommentButton.tsx
Normal file
39
admin/src/components/DeleteCommentButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { deleteComment } from '@/app/actions/comment';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function DeleteCommentButton({ id }: Props) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteComment(id);
|
||||
if (!result.success) {
|
||||
alert(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
admin/src/components/DeleteInterviewButton.tsx
Normal file
24
admin/src/components/DeleteInterviewButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { deleteInterview } from '@/app/actions/interview';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function DeleteInterviewButton({ id }: { id: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Supprimer cette interview ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteInterview(id);
|
||||
if (!result.success) alert(result.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
140
admin/src/components/FeaturedModal.tsx
Normal file
140
admin/src/components/FeaturedModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
||||
import { Star, X, Loader2, Save } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
business: {
|
||||
id: string;
|
||||
name: string;
|
||||
isFeatured: boolean;
|
||||
founderName?: string | null;
|
||||
founderImageUrl?: string | null;
|
||||
keyMetric?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function FeaturedModal({ business }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const data = {
|
||||
founderName: formData.get('founderName') as string,
|
||||
founderImageUrl: formData.get('founderImageUrl') as string,
|
||||
keyMetric: formData.get('keyMetric') as string,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await setFeaturedBusiness(business.id, data);
|
||||
if (result.success) {
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await removeFeaturedBusiness(business.id);
|
||||
if (result.success) setIsOpen(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
|
||||
business.isFeatured
|
||||
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
||||
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">💰 Entrepreneur du Mois</h2>
|
||||
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleToggleFeature} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
|
||||
<input
|
||||
name="founderName"
|
||||
defaultValue={business.founderName || ''}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||
placeholder="Ex: Aliko Dangote"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
|
||||
<input
|
||||
name="founderImageUrl"
|
||||
defaultValue={business.founderImageUrl || ''}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
|
||||
<input
|
||||
name="keyMetric"
|
||||
defaultValue={business.keyMetric || ''}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||
placeholder="Ex: +500 emplois créés"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||
Définir comme Entrepreneur du Mois
|
||||
</button>
|
||||
|
||||
{business.isFeatured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
||||
>
|
||||
Révoquer le titre
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
187
admin/src/components/InterviewForm.tsx
Normal file
187
admin/src/components/InterviewForm.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createInterview, updateInterview } from '@/app/actions/interview';
|
||||
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { InterviewType } from '@prisma/client';
|
||||
|
||||
interface Props {
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export default function InterviewForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const data: any = {
|
||||
title: formData.get('title') as string,
|
||||
guestName: formData.get('guestName') as string,
|
||||
companyName: formData.get('companyName') as string,
|
||||
role: formData.get('role') as string,
|
||||
type: formData.get('type') as InterviewType,
|
||||
thumbnailUrl: formData.get('thumbnailUrl') as string,
|
||||
videoUrl: formData.get('videoUrl') as string || null,
|
||||
excerpt: formData.get('excerpt') as string,
|
||||
content: formData.get('content') as string || null,
|
||||
duration: formData.get('duration') as string || null,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result = initialData
|
||||
? await updateInterview(initialData.id, data)
|
||||
: await createInterview(data);
|
||||
|
||||
if (result.success) {
|
||||
router.push('/blog');
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{initialData ? 'Modifier l\'interview' : 'Nouvelle Interview'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initialData?.title}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: Entreprise X : Une success story..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
|
||||
<input
|
||||
name="guestName"
|
||||
defaultValue={initialData?.guestName}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: Sarah Koné"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Entreprise</label>
|
||||
<input
|
||||
name="companyName"
|
||||
defaultValue={initialData?.companyName}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: TechAfrica"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
|
||||
<input
|
||||
name="role"
|
||||
defaultValue={initialData?.role}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: Fondatrice & CEO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={initialData?.type || 'VIDEO'}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="VIDEO">Vidéo</option>
|
||||
<option value="ARTICLE">Article (Texte)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
||||
<input
|
||||
name="duration"
|
||||
defaultValue={initialData?.duration}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Ex: 12 min"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label>
|
||||
<input
|
||||
name="thumbnailUrl"
|
||||
defaultValue={initialData?.thumbnailUrl}
|
||||
required
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg 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">URL Vidéo (Si type Vidéo)</label>
|
||||
<input
|
||||
name="videoUrl"
|
||||
defaultValue={initialData?.videoUrl}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
placeholder="https://youtube.com/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
|
||||
<textarea
|
||||
name="excerpt"
|
||||
defaultValue={initialData?.excerpt}
|
||||
required
|
||||
rows={2}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg 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">Contenu / Transcription (Optionnel)</label>
|
||||
<textarea
|
||||
name="content"
|
||||
defaultValue={initialData?.content}
|
||||
rows={8}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-emerald-600"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-6 h-6" />
|
||||
)}
|
||||
{initialData ? 'Enregistrer les modifications' : 'Créer l\'interview'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
admin/src/components/Sidebar.tsx
Normal file
59
admin/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
|
||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="admin-sidebar p-6 flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
||||
<ShieldCheck className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`nav-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-slate-700">
|
||||
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
|
||||
<LogOut className="w-5 h-5 mr-3" />
|
||||
<span>Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
admin/src/components/ToggleVerifyButton.tsx
Normal file
42
admin/src/components/ToggleVerifyButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { toggleVerification } from '@/app/actions/business';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export default function ToggleVerifyButton({ id, verified }: Props) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggle = () => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleVerification(id, verified);
|
||||
if (!result.success) {
|
||||
alert(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isPending}
|
||||
className={verified ? "btn-unverify" : "btn-verify"}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : verified ? (
|
||||
<XCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
{verified ? "Révoquer" : "Valider"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user