feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s
Some checks failed
Build and Push App / build (push) Failing after 16m2s
This commit is contained in:
@@ -5,90 +5,104 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
VISITOR
|
||||
ENTREPRENEUR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
role UserRole @default(VISITOR)
|
||||
avatar String?
|
||||
phone String?
|
||||
bio String? @db.Text
|
||||
location String?
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
|
||||
businesses Business?
|
||||
comments Comment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Messaging relations
|
||||
sentMessages Message[]
|
||||
conversations ConversationParticipant[]
|
||||
reports MessageReport[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
role UserRole @default(VISITOR)
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
bio String?
|
||||
location String?
|
||||
countryId String?
|
||||
phone String?
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
businesses Business?
|
||||
comments Comment[]
|
||||
conversations ConversationParticipant[]
|
||||
sentMessages Message[]
|
||||
reports MessageReport[]
|
||||
ratings Rating[]
|
||||
country Country? @relation(fields: [countryId], references: [id])
|
||||
}
|
||||
|
||||
model Business {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String? @unique
|
||||
category String
|
||||
location String
|
||||
description String @db.Text
|
||||
logoUrl String
|
||||
videoUrl String?
|
||||
|
||||
// Contact & Social
|
||||
contactEmail String
|
||||
contactPhone String?
|
||||
websiteUrl String?
|
||||
// We use JSON to store social links (facebook, linkedin, instagram, website)
|
||||
socialLinks Json?
|
||||
|
||||
// Privacy & Status
|
||||
showEmail Boolean @default(true)
|
||||
showPhone Boolean @default(true)
|
||||
showSocials Boolean @default(true)
|
||||
isActive Boolean @default(false)
|
||||
|
||||
// Stats
|
||||
verified Boolean @default(false)
|
||||
viewCount Int @default(0)
|
||||
rating Float @default(0.0)
|
||||
tags String[]
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
|
||||
// Entrepreneur of the Month fields
|
||||
isFeatured Boolean @default(false)
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
category String
|
||||
location String // Legacy location string
|
||||
countryId String?
|
||||
city String?
|
||||
description String
|
||||
logoUrl String
|
||||
videoUrl String?
|
||||
contactEmail String
|
||||
contactPhone String?
|
||||
socialLinks Json?
|
||||
verified Boolean @default(false)
|
||||
viewCount Int @default(0)
|
||||
rating Float @default(0.0)
|
||||
ratingCount Int @default(0)
|
||||
tags String[]
|
||||
isFeatured Boolean @default(false)
|
||||
founderName String?
|
||||
founderImageUrl String?
|
||||
keyMetric String?
|
||||
|
||||
// Relations
|
||||
ownerId String @unique
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
offers Offer[]
|
||||
comments Comment[]
|
||||
conversations Conversation[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ownerId String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
slug String? @unique
|
||||
isActive Boolean @default(false)
|
||||
showEmail Boolean @default(true)
|
||||
showPhone Boolean @default(true)
|
||||
showSocials Boolean @default(true)
|
||||
websiteUrl String?
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
plan Plan @default(STARTER)
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
comments Comment[]
|
||||
conversations Conversation[]
|
||||
offers Offer[]
|
||||
ratings Rating[]
|
||||
country Country? @relation(fields: [countryId], references: [id])
|
||||
}
|
||||
|
||||
enum OfferType {
|
||||
PRODUCT
|
||||
SERVICE
|
||||
model Country {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
code String @unique // ISO alpha-2
|
||||
flag String? // Emoji or URL
|
||||
isActive Boolean @default(true)
|
||||
businesses Business[]
|
||||
users User[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
STARTER
|
||||
BOOSTER
|
||||
EMPIRE
|
||||
}
|
||||
|
||||
model PricingPlan {
|
||||
id String @id @default(uuid())
|
||||
tier Plan @unique
|
||||
name String
|
||||
priceXOF String
|
||||
priceEUR String
|
||||
description String
|
||||
features String[]
|
||||
offerLimit Int @default(1)
|
||||
recommended Boolean @default(false)
|
||||
color String @default("gray")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Offer {
|
||||
@@ -96,52 +110,28 @@ model Offer {
|
||||
title String
|
||||
type OfferType
|
||||
price Float
|
||||
currency String @default("XOF") // EUR or XOF
|
||||
description String? @db.Text
|
||||
currency String @default("XOF")
|
||||
description String?
|
||||
imageUrl String
|
||||
active Boolean @default(true)
|
||||
|
||||
// Relations
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
excerpt String @db.Text
|
||||
content String @db.Text
|
||||
author String
|
||||
excerpt String
|
||||
content String
|
||||
author String
|
||||
date DateTime @default(now())
|
||||
imageUrl String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum InterviewType {
|
||||
VIDEO
|
||||
ARTICLE
|
||||
}
|
||||
|
||||
model SiteSetting {
|
||||
id String @id @default("singleton")
|
||||
siteName String @default("Afropreunariat")
|
||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
||||
contactEmail String @default("support@afropreunariat.com")
|
||||
contactPhone String? @default("+225 00 00 00 00 00")
|
||||
address String? @default("Abidjan, Côte d'Ivoire")
|
||||
facebookUrl String?
|
||||
twitterUrl String?
|
||||
instagramUrl String?
|
||||
linkedinUrl String?
|
||||
footerText String? @default("© 2025 Afropreunariat. Tous droits réservés.")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Interview {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
@@ -151,73 +141,97 @@ model Interview {
|
||||
type InterviewType
|
||||
thumbnailUrl String
|
||||
videoUrl String?
|
||||
content String? @db.Text
|
||||
excerpt String @db.Text
|
||||
content String?
|
||||
excerpt String
|
||||
date DateTime @default(now())
|
||||
duration String? // "15 min" ou "5 min de lecture"
|
||||
|
||||
duration String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid())
|
||||
content String @db.Text
|
||||
|
||||
// Relations
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(uuid())
|
||||
content String
|
||||
authorId String
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(uuid())
|
||||
type String // PAGE_VIEW, CLICK, DWELL_TIME
|
||||
type String
|
||||
path String
|
||||
label String? // For clicks (button text, link name)
|
||||
value Float? // For dwell time (seconds)
|
||||
metadata Json? // Browser, OS, etc.
|
||||
label String?
|
||||
value Float?
|
||||
metadata Json?
|
||||
ip String?
|
||||
country String?
|
||||
city String?
|
||||
browser String?
|
||||
os String?
|
||||
device String?
|
||||
referrer String?
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
model Rating {
|
||||
id String @id @default(uuid())
|
||||
value Int
|
||||
comment String?
|
||||
reply String?
|
||||
replyAt DateTime?
|
||||
status RatingStatus @default(PENDING)
|
||||
userId String
|
||||
businessId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, businessId])
|
||||
}
|
||||
|
||||
enum RatingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
participants ConversationParticipant[]
|
||||
messages Message[]
|
||||
}
|
||||
|
||||
model ConversationParticipant {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
conversationId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
isArchived Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, conversationId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
content String @db.Text
|
||||
id String @id @default(uuid())
|
||||
content String
|
||||
senderId String
|
||||
conversationId String
|
||||
createdAt DateTime @default(now())
|
||||
read Boolean @default(false)
|
||||
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
reports MessageReport[]
|
||||
createdAt DateTime @default(now())
|
||||
read Boolean @default(false)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
reports MessageReport[]
|
||||
}
|
||||
|
||||
model MessageReport {
|
||||
@@ -227,9 +241,8 @@ model MessageReport {
|
||||
reporterId String
|
||||
status ReportStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ReportStatus {
|
||||
@@ -237,3 +250,34 @@ enum ReportStatus {
|
||||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
VISITOR
|
||||
ENTREPRENEUR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum OfferType {
|
||||
PRODUCT
|
||||
SERVICE
|
||||
}
|
||||
|
||||
enum InterviewType {
|
||||
VIDEO
|
||||
ARTICLE
|
||||
}
|
||||
|
||||
model SiteSetting {
|
||||
id String @id @default("singleton")
|
||||
siteName String @default("Afrohub")
|
||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
||||
contactEmail String @default("support@afrohub.com")
|
||||
contactPhone String? @default("+225 00 00 00 00 00")
|
||||
address String? @default("Abidjan, Côte d'Ivoire")
|
||||
facebookUrl String?
|
||||
twitterUrl String?
|
||||
instagramUrl String?
|
||||
linkedinUrl String?
|
||||
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -61,3 +61,21 @@ export async function removeFeaturedBusiness(id: string) {
|
||||
return { success: false, error: "Erreur lors de la suppression" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingVerificationCount() {
|
||||
try {
|
||||
return await prisma.business.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
verified: false,
|
||||
isSuspended: false,
|
||||
plan: {
|
||||
in: ['BOOSTER', 'EMPIRE']
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get pending count:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
60
admin/src/app/actions/countries.ts
Normal file
60
admin/src/app/actions/countries.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function getCountries() {
|
||||
try {
|
||||
return await prisma.country.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching countries:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCountry(data: { name: string, code: string, flag?: string }) {
|
||||
try {
|
||||
const country = await prisma.country.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
code: data.code.toUpperCase(),
|
||||
flag: data.flag || null,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
revalidatePath('/countries');
|
||||
return { success: true, country };
|
||||
} catch (error) {
|
||||
console.error('Error adding country:', error);
|
||||
return { success: false, error: 'Ce pays ou code existe déjà.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCountry(id: string) {
|
||||
try {
|
||||
await prisma.country.delete({
|
||||
where: { id }
|
||||
});
|
||||
revalidatePath('/countries');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting country:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleCountryStatus(id: string, isActive: boolean) {
|
||||
try {
|
||||
await prisma.country.update({
|
||||
where: { id },
|
||||
data: { isActive }
|
||||
});
|
||||
revalidatePath('/countries');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error toggling country status:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
31
admin/src/app/actions/legal.ts
Normal file
31
admin/src/app/actions/legal.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
||||
try {
|
||||
return await prisma.legalDocument.findUnique({
|
||||
where: { type }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${type}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
|
||||
try {
|
||||
const doc = await prisma.legalDocument.upsert({
|
||||
where: { type },
|
||||
update: { title, content },
|
||||
create: { type, title, content }
|
||||
});
|
||||
|
||||
revalidatePath('/legal');
|
||||
return { success: true, doc };
|
||||
} catch (error) {
|
||||
console.error(`Error updating ${type}:`, error);
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
}
|
||||
}
|
||||
@@ -50,3 +50,31 @@ export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMI
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingRatings() {
|
||||
return await prisma.rating.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true }
|
||||
},
|
||||
business: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
||||
try {
|
||||
await prisma.rating.update({
|
||||
where: { id },
|
||||
data: { status }
|
||||
});
|
||||
revalidatePath('/moderation');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: "Erreur lors de la mise à jour de l'avis" };
|
||||
}
|
||||
}
|
||||
|
||||
54
admin/src/app/actions/plans.ts
Normal file
54
admin/src/app/actions/plans.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function getPricingPlans() {
|
||||
try {
|
||||
return await prisma.pricingPlan.findMany({
|
||||
orderBy: { offerLimit: 'asc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pricing plans:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePricingPlanDetail(id: string, data: any) {
|
||||
try {
|
||||
await prisma.pricingPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
priceXOF: data.priceXOF,
|
||||
priceEUR: data.priceEUR,
|
||||
description: data.description,
|
||||
features: data.features, // Expected to be string[]
|
||||
offerLimit: parseInt(data.offerLimit),
|
||||
recommended: !!data.recommended,
|
||||
color: data.color
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath('/plans');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating pricing plan detail:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour des détails du forfait.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
|
||||
try {
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: { plan }
|
||||
});
|
||||
|
||||
revalidatePath('/entrepreneurs');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating business plan:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour du forfait.' };
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,16 @@ export async function getSiteSettings() {
|
||||
|
||||
// Default fallback values if not initialized
|
||||
const defaultSettings = {
|
||||
siteName: "Afropreunariat",
|
||||
siteName: "Afrohub",
|
||||
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.",
|
||||
contactEmail: "support@afropreunariat.com",
|
||||
contactEmail: "support@afrohub.com",
|
||||
contactPhone: "+225 00 00 00 00 00",
|
||||
address: "Abidjan, Côte d'Ivoire",
|
||||
facebookUrl: "",
|
||||
twitterUrl: "",
|
||||
instagramUrl: "",
|
||||
linkedinUrl: "",
|
||||
footerText: "© 2025 Afropreunariat. Tous droits réservés."
|
||||
footerText: "© 2025 Afrohub. Tous droits réservés."
|
||||
};
|
||||
|
||||
if (!settings) return defaultSettings;
|
||||
|
||||
159
admin/src/app/countries/page.tsx
Normal file
159
admin/src/app/countries/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useTransition } from 'react';
|
||||
import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries';
|
||||
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function CountriesPage() {
|
||||
const [countries, setCountries] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await getCountries();
|
||||
setCountries(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
const code = formData.get('code') as string;
|
||||
const flag = formData.get('flag') as string;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await addCountry({ name, code, flag });
|
||||
if (result.success) {
|
||||
toast.success("Pays ajouté avec succès");
|
||||
setIsAdding(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de l'ajout");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Supprimer le pays "${name}" ? Cela pourrait affecter les entreprises liées.`)) return;
|
||||
|
||||
const result = await deleteCountry(id);
|
||||
if (result.success) {
|
||||
toast.success("Pays supprimé");
|
||||
loadData();
|
||||
} else {
|
||||
toast.error("Erreur lors de la suppression");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (id: string, current: boolean) => {
|
||||
const result = await toggleCountryStatus(id, !current);
|
||||
if (result.success) {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Pays</h1>
|
||||
<p className="text-slate-400">Gérez les pays disponibles pour les entreprises et le filtrage du répertoire.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Ajouter un pays
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<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">
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
|
||||
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white p-3 rounded-xl font-bold transition-all">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-800/50 text-slate-400 uppercase text-xs font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Pays</th>
|
||||
<th className="px-6 py-4">Code</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{countries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
|
||||
</tr>
|
||||
) : countries.map((country) => (
|
||||
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{country.flag || <MapPin className="w-5 h-5 text-slate-600" />}</span>
|
||||
<span className="font-bold text-white">{country.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold ${
|
||||
country.isActive ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-500/10 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{country.isActive ? 'Actif' : 'Inactif'}
|
||||
</button>
|
||||
</td>
|
||||
<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">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,13 @@ async function getStats() {
|
||||
});
|
||||
const totalViews = aggregateResult._sum.viewCount || 0;
|
||||
|
||||
return { usersCount, businessCount, pendingCount, commentsCount, totalViews };
|
||||
// Unique Visitors (by IP)
|
||||
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
|
||||
by: ['ip'],
|
||||
});
|
||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||
|
||||
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -29,6 +35,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
||||
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
||||
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
||||
|
||||
@@ -2,7 +2,8 @@ import { prisma } from '@/lib/prisma';
|
||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||
import FeaturedModal from '@/components/FeaturedModal';
|
||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||
import { ShieldAlert, ShieldX } from 'lucide-react';
|
||||
import PlanSelector from '@/components/PlanSelector';
|
||||
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
|
||||
|
||||
async function getBusinesses() {
|
||||
return await prisma.business.findMany({
|
||||
@@ -20,12 +21,31 @@ async function getBusinesses() {
|
||||
|
||||
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">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
|
||||
<p className="text-slate-400">Valider ou révoquer les comptes des entreprises.</p>
|
||||
<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">
|
||||
@@ -36,6 +56,7 @@ export default async function EntrepreneursPage() {
|
||||
<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>
|
||||
@@ -90,6 +111,9 @@ export default async function EntrepreneursPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Toaster } from 'react-hot-toast';
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AfroAdmin - Administration Afropreunariat",
|
||||
description: "Panneau d'administration pour la plateforme Afropreunariat",
|
||||
title: "AfroAdmin - Administration Afrohub",
|
||||
description: "Panneau d'administration pour la plateforme Afrohub",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
24
admin/src/app/legal/page.tsx
Normal file
24
admin/src/app/legal/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { getLegalDocument } from '@/app/actions/legal';
|
||||
import LegalEditor from '@/components/LegalEditor';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documents Légaux - AfroAdmin',
|
||||
description: 'Gérez les CGU et CGV de la plateforme.',
|
||||
};
|
||||
|
||||
export default async function LegalPage() {
|
||||
const cgu = await getLegalDocument('CGU');
|
||||
const cgv = await getLegalDocument('CGV');
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Documents Légaux</h1>
|
||||
<p className="text-slate-400">Modifiez les conditions générales d'utilisation et de vente du site.</p>
|
||||
</div>
|
||||
|
||||
<LegalEditor initialCgu={cgu} initialCgv={cgv} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +1,341 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
BarChart3,
|
||||
MousePointer2,
|
||||
Clock,
|
||||
Eye,
|
||||
ArrowUpRight,
|
||||
TrendingUp
|
||||
TrendingUp,
|
||||
Globe,
|
||||
Users,
|
||||
Zap,
|
||||
Search,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
async function getMetrics() {
|
||||
// 1. Top Pages
|
||||
const topPages = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: { type: 'PAGE_VIEW' },
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc'
|
||||
}
|
||||
},
|
||||
take: 10
|
||||
});
|
||||
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
||||
if (value === null) return null;
|
||||
const isPositive = value > 0;
|
||||
const isZero = Math.abs(value) < 0.1;
|
||||
|
||||
// 2. Top Clicks
|
||||
const topClicks = await prisma.analyticsEvent.groupBy({
|
||||
by: ['label'],
|
||||
where: { type: 'CLICK' },
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc'
|
||||
}
|
||||
},
|
||||
take: 10
|
||||
});
|
||||
if (isZero) return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
|
||||
= 0%
|
||||
</span>
|
||||
);
|
||||
|
||||
// 3. Average Dwell Time
|
||||
const dwellTime = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: { type: 'DWELL_TIME' },
|
||||
_avg: {
|
||||
value: true
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
orderBy: {
|
||||
_avg: {
|
||||
value: 'desc'
|
||||
}
|
||||
},
|
||||
take: 10
|
||||
});
|
||||
|
||||
// 4. Global Stats
|
||||
const totalEvents = await prisma.analyticsEvent.count();
|
||||
const uniquePaths = (await prisma.analyticsEvent.groupBy({ by: ['path'] })).length;
|
||||
|
||||
return { topPages, topClicks, dwellTime, totalEvents, uniquePaths };
|
||||
return (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||
isPositive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'
|
||||
}`}>
|
||||
{isPositive ? <ArrowUp className="w-2 h-2 mr-0.5" /> : <ArrowDown className="w-2 h-2 mr-0.5" />}
|
||||
{Math.abs(value).toFixed(1)}{isPercent ? '%' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function MetricsPage() {
|
||||
const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics();
|
||||
async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||
let startDate: Date;
|
||||
let endDate: Date = new Date();
|
||||
const now = new Date();
|
||||
|
||||
// 1. Determine Current Range
|
||||
if (from && to) {
|
||||
startDate = new Date(from);
|
||||
endDate = new Date(to);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
} else {
|
||||
if (range === 'day') startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
else if (range === 'month') startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
else if (range === 'all') startDate = new Date(0); // All time
|
||||
else startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Default Week
|
||||
}
|
||||
|
||||
const currentWhere = { createdAt: { gte: startDate, lte: endDate } };
|
||||
|
||||
// 2. Determine Previous Range (for comparison)
|
||||
let prevWhere: any = null;
|
||||
if (range !== 'all') {
|
||||
const duration = endDate.getTime() - startDate.getTime();
|
||||
const prevEndDate = new Date(startDate.getTime() - 1);
|
||||
const prevStartDate = new Date(startDate.getTime() - duration);
|
||||
prevWhere = { createdAt: { gte: prevStartDate, lte: prevEndDate } };
|
||||
}
|
||||
|
||||
// 3. Fetch Current Data
|
||||
const topPages = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: { type: 'PAGE_VIEW', ...currentWhere },
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 10
|
||||
});
|
||||
|
||||
const topCountries = await prisma.analyticsEvent.groupBy({
|
||||
by: ['country'],
|
||||
where: currentWhere,
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 10
|
||||
});
|
||||
|
||||
const devices = await prisma.analyticsEvent.groupBy({
|
||||
by: ['device'],
|
||||
where: currentWhere,
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } }
|
||||
});
|
||||
|
||||
const browsers = await prisma.analyticsEvent.groupBy({
|
||||
by: ['browser'],
|
||||
where: currentWhere,
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 5
|
||||
});
|
||||
|
||||
const totalEvents = await prisma.analyticsEvent.count({ where: currentWhere });
|
||||
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
|
||||
by: ['ip'],
|
||||
where: currentWhere
|
||||
});
|
||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||
|
||||
// 4. Fetch Previous Data (for Deltas)
|
||||
let prevTotalEvents = 0;
|
||||
let prevUniqueVisitors = 0;
|
||||
let prevMobileCount = 0;
|
||||
|
||||
if (prevWhere) {
|
||||
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
|
||||
const prevVisitorsRes = await prisma.analyticsEvent.groupBy({
|
||||
by: ['ip'],
|
||||
where: prevWhere
|
||||
});
|
||||
prevUniqueVisitors = prevVisitorsRes.length;
|
||||
|
||||
const prevDevices = await prisma.analyticsEvent.groupBy({
|
||||
by: ['device'],
|
||||
where: prevWhere,
|
||||
_count: { id: true }
|
||||
});
|
||||
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||
}
|
||||
|
||||
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||
|
||||
return {
|
||||
topPages,
|
||||
topCountries,
|
||||
devices,
|
||||
browsers,
|
||||
totalEvents,
|
||||
uniqueVisitors,
|
||||
deltas: {
|
||||
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
||||
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
||||
mobile: prevTotalEvents > 0 && totalEvents > 0
|
||||
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
|
||||
: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
||||
const searchParams = await searchParamsPromise;
|
||||
const range = searchParams.range || 'week';
|
||||
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||
|
||||
const rangeLabels: Record<string, string> = {
|
||||
day: 'Dernières 24h',
|
||||
week: '7 derniers jours',
|
||||
month: '30 derniers jours',
|
||||
all: 'Depuis le début'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
|
||||
<p className="text-slate-400">Comportement des utilisateurs et engagement en temps réel.</p>
|
||||
<p className="text-slate-400">Comportement des utilisateurs et engagement géographique en temps réel.</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
|
||||
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls: Shortcuts + Custom Calendar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||
{Object.entries(rangeLabels).map(([id, label]) => (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/metrics?range=${id}`}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
range === id && !searchParams.from
|
||||
? 'bg-indigo-600 text-white shadow-lg'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form action="/metrics" className="flex items-center gap-2 bg-slate-900/50 p-2 rounded-xl border border-slate-800">
|
||||
<Calendar className="w-4 h-4 text-slate-500 ml-2" />
|
||||
<input
|
||||
type="date"
|
||||
name="from"
|
||||
defaultValue={searchParams.from}
|
||||
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
|
||||
/>
|
||||
<span className="text-slate-600">→</span>
|
||||
<input
|
||||
type="date"
|
||||
name="to"
|
||||
defaultValue={searchParams.to}
|
||||
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
|
||||
/>
|
||||
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] uppercase font-bold px-3 py-1.5 rounded-lg transition-colors">
|
||||
Filtrer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<div className="card border-t-4 border-indigo-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
|
||||
<ComparisonBadge value={deltas.visitors} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card border-t-4 border-emerald-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Eye className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">{uniquePaths}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-400 font-medium">Pages uniques explorées</h3>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-400">
|
||||
<MousePointer2 className="w-6 h-6" />
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">{totalEvents}</div>
|
||||
<ComparisonBadge value={deltas.events} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{topClicks.reduce((acc, curr) => acc + curr._count.id, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-slate-400 font-medium">Clicks enregistrés (Top 10)</h3>
|
||||
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
|
||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
|
||||
<div className="card border-t-4 border-amber-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Clock className="w-6 h-6" />
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}%
|
||||
</div>
|
||||
<ComparisonBadge value={deltas.mobile} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-slate-400 font-medium">Temps moyen / page</h3>
|
||||
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
|
||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Top Pages Table */}
|
||||
{/* Top Countries Table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-emerald-400" /> Pages les plus visitées
|
||||
<Globe className="w-5 h-5 text-indigo-400" /> TOP 10 - Géographie
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Pays</th>
|
||||
<th className="px-6 py-4 text-right">Visites</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{topCountries.map((c) => (
|
||||
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
||||
{c._count.id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Device & Browser Stats */}
|
||||
<div className="space-y-8">
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-400" /> Appareils
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{devices.map(d => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-400 uppercase font-bold">
|
||||
<span>{d.device || 'Autre'}</span>
|
||||
<span>{Math.round(d._count.id / totalEvents * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-indigo-500 h-full transition-all duration-500"
|
||||
style={{ width: `${(d._count.id / totalEvents * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-emerald-400" /> Navigateurs
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-2 gap-4">
|
||||
{browsers.map(b => (
|
||||
<div key={b.browser} className="bg-slate-950/50 border border-slate-800 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-white mb-1">{b._count.id}</div>
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-widest">{b.browser || 'Autre'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Pages Table (Moved and compact) */}
|
||||
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-rose-400" /> Pages les plus visitées
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
@@ -141,64 +357,6 @@ export default async function MetricsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Top Clicks Table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<MousePointer2 className="w-5 h-5 text-indigo-400" /> Actions & Clicks
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Élément / Texte</th>
|
||||
<th className="px-6 py-4 text-right">Interactions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{topClicks.map((click) => (
|
||||
<tr key={click.label} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-slate-300 truncate max-w-xs">{click.label || 'Sans label'}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
||||
{click._count.id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Dwell Time Table */}
|
||||
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-amber-400" /> Temps d'attention par page
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Page</th>
|
||||
<th className="px-6 py-4">Échantillon</th>
|
||||
<th className="px-6 py-4 text-right">Moyenne de rétention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{dwellTime.map((time) => (
|
||||
<tr key={time.path} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-mono text-slate-300">{time.path}</td>
|
||||
<td className="px-6 py-4 text-xs text-slate-500">{time._count.id} sessions</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
{Math.round(time._avg.value || 0)} secondes
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,133 +1,179 @@
|
||||
import { getReports } from '@/app/actions/moderation';
|
||||
import { getReports, getPendingRatings } from '@/app/actions/moderation';
|
||||
import ReportActionButtons from '@/components/ReportActionButtons';
|
||||
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
|
||||
import { Flag, MessageCircle, User, Calendar } from 'lucide-react';
|
||||
import RatingModerationButtons from '@/components/RatingModerationButtons';
|
||||
import { Flag, MessageCircle, User, Calendar, Star, CheckCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function ModerationPage() {
|
||||
export default async function ModerationPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>
|
||||
}) {
|
||||
const { tab = 'messages' } = await searchParams;
|
||||
const reports = await getReports();
|
||||
const pendingRatings = await getPendingRatings();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
|
||||
<Flag className="text-red-500" />
|
||||
Modération des Messages
|
||||
Centre de Modération
|
||||
</h1>
|
||||
<p className="text-slate-400">Gérer les signalements de contenus inappropriés dans les discussions.</p>
|
||||
<p className="text-slate-400">Gérer les signalements et valider les nouveaux avis.</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="/moderation?tab=messages"
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'messages' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Messages ({reports.filter(r => r.status === 'PENDING').length})
|
||||
</Link>
|
||||
<Link
|
||||
href="/moderation?tab=avis"
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'avis' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
Avis ({pendingRatings.length})
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{reports.length === 0 ? (
|
||||
<div className="card p-10 text-center text-slate-500">
|
||||
Aucun signalement en attente.
|
||||
</div>
|
||||
) : (
|
||||
reports.map((report) => (
|
||||
<div key={report.id} className={`card border-l-4 ${report.status === 'PENDING' ? 'border-red-500' : report.status === 'RESOLVED' ? 'border-green-500' : 'border-slate-600'}`}>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* 1. Report Info */}
|
||||
<div className="md:w-1/3 space-y-4">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{new Date(report.createdAt).toLocaleString('fr-FR')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-xs">
|
||||
{report.reporter.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{report.reporter.name}</div>
|
||||
<div className="text-xs text-slate-500">{report.reporter.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison invoquée</div>
|
||||
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<span className={`badge ${
|
||||
report.status === 'PENDING' ? 'badge-pending' :
|
||||
report.status === 'RESOLVED' ? 'badge-verified' :
|
||||
'bg-slate-700 text-slate-300'
|
||||
}`}>
|
||||
{report.status === 'PENDING' ? 'En attente' :
|
||||
report.status === 'RESOLVED' ? 'Résolu' :
|
||||
'Rejeté'}
|
||||
</span>
|
||||
</div>
|
||||
{tab === 'messages' && (
|
||||
<>
|
||||
{reports.length === 0 ? (
|
||||
<div className="card p-10 text-center text-slate-500">
|
||||
Aucun signalement en attente.
|
||||
</div>
|
||||
|
||||
{/* 2. Conversation History */}
|
||||
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 flex flex-col overflow-hidden">
|
||||
<div className="bg-slate-800/50 p-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-indigo-400">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">Historique de la discussion</span>
|
||||
</div>
|
||||
{report.message.conversation.business && (
|
||||
<div className="text-[10px] text-slate-500 font-medium">
|
||||
Shop: <span className="text-indigo-400">{report.message.conversation.business.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto max-h-[400px] p-4 space-y-3 bg-slate-950/20">
|
||||
{report.message.conversation.messages.map((msg: any) => {
|
||||
const isReported = msg.id === report.messageId;
|
||||
const senderName = msg.sender.name;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`flex flex-col ${isReported ? 'bg-red-500/10 border-red-500/30' : 'bg-slate-800/40 border-slate-700/50'} border rounded-lg p-3 transition-colors`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-tighter ${isReported ? 'text-red-400' : 'text-slate-400'}`}>
|
||||
{senderName}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-500">
|
||||
{new Date(msg.createdAt).toLocaleString('fr-FR', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm ${isReported ? 'text-white font-medium' : 'text-slate-300'}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
{isReported && (
|
||||
<div className="mt-2 text-[9px] flex items-center gap-1 text-red-500 font-bold uppercase">
|
||||
<Flag className="w-3 h-3" />
|
||||
Message Signalé
|
||||
) : (
|
||||
reports.map((report) => (
|
||||
<div key={report.id} className={`card border-l-4 ${report.status === 'PENDING' ? 'border-red-500' : report.status === 'RESOLVED' ? 'border-green-500' : 'border-slate-600'}`}>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/3 space-y-4">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{new Date(report.createdAt).toLocaleString('fr-FR')}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-xs uppercase">{report.reporter.name.charAt(0)}</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{report.reporter.name}</div>
|
||||
<div className="text-xs text-slate-500">{report.reporter.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison</div>
|
||||
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<span className={`badge ${
|
||||
report.status === 'PENDING' ? 'badge-pending' : 'badge-verified'
|
||||
}`}>
|
||||
{report.status === 'PENDING' ? 'En attente' : 'Terminé'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 p-4">
|
||||
<Link
|
||||
href={`/messages/${report.message.conversationId}`}
|
||||
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 mb-4"
|
||||
>
|
||||
Voir la conversation complète →
|
||||
</Link>
|
||||
<div className="p-3 bg-slate-800/80 rounded-lg border border-red-500/20 mb-4">
|
||||
<div className="text-[10px] text-red-400 font-bold uppercase mb-1">Message Signalé</div>
|
||||
<div className="text-sm text-white">"{report.message.content}"</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">Auteur: {report.message.sender.name}</div>
|
||||
</div>
|
||||
{report.status === 'PENDING' && (
|
||||
<div className="flex flex-col gap-4 mt-6 pt-6 border-t border-slate-700">
|
||||
<ReportActionButtons reportId={report.id} />
|
||||
<ModerationSuspensionButton
|
||||
userId={report.message.senderId}
|
||||
userName={report.message.sender.name}
|
||||
isSuspended={!!report.message.sender.isSuspended}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{report.status === 'PENDING' && (
|
||||
<div className="p-4 bg-slate-800/30 border-t border-slate-700">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-3 text-[10px] text-slate-500 font-bold uppercase">Résoudre le signalement</div>
|
||||
<ReportActionButtons reportId={report.id} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-[10px] text-slate-500 font-bold uppercase">Sanctionner l'auteur</div>
|
||||
<ModerationSuspensionButton
|
||||
userId={report.message.senderId}
|
||||
userName={report.message.sender.name}
|
||||
isSuspended={!!report.message.sender.isSuspended}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'avis' && (
|
||||
<>
|
||||
{pendingRatings.length === 0 ? (
|
||||
<div className="card p-20 text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500/20 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-white mb-1">Tous les avis sont modérés</h3>
|
||||
<p className="text-slate-500 text-sm">Beau travail ! Il n'y a plus aucun avis en attente.</p>
|
||||
</div>
|
||||
) : (
|
||||
pendingRatings.map((r) => (
|
||||
<div key={r.id} className="card border-l-4 border-amber-500 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="lg:w-1/4 space-y-5">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-xs">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{new Date(r.createdAt).toLocaleString('fr-FR')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Auteur</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-brand-400 font-bold text-sm uppercase">{r.user.name.charAt(0)}</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{r.user.name}</div>
|
||||
<div className="text-[10px] text-slate-500">{r.user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Entrepreneur Concerné</div>
|
||||
<div className="bg-slate-900/80 p-2.5 rounded-lg border border-slate-800">
|
||||
<div className="text-sm font-bold text-indigo-400">{r.business.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-slate-900/60 p-5 rounded-2xl border border-slate-800 relative">
|
||||
<div className="flex text-yellow-500 mb-3">
|
||||
{[1,2,3,4,5].map(s => (
|
||||
<Star key={s} className={`w-4 h-4 ${s <= r.value ? 'fill-current' : 'text-slate-800'}`} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-200 text-sm leading-relaxed italic">
|
||||
{r.comment || "Pas de commentaire."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-slate-800 flex items-center justify-between">
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<Flag className="w-3 h-3 text-amber-500" />
|
||||
En attente de validation
|
||||
</div>
|
||||
<RatingModerationButtons ratingId={r.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
103
admin/src/app/plans/page.tsx
Normal file
103
admin/src/app/plans/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getPricingPlans } from '@/app/actions/plans';
|
||||
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
|
||||
import PlanEditModal from '@/components/PlanEditModal';
|
||||
|
||||
export default function PlansPage() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPlan, setEditingPlan] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPlans() {
|
||||
const data = await getPricingPlans();
|
||||
setPlans(data);
|
||||
setLoading(false);
|
||||
}
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1>
|
||||
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.id} className={`bg-slate-900 border ${plan.recommended ? 'border-indigo-500 ring-1 ring-indigo-500/50' : 'border-slate-800'} rounded-2xl overflow-hidden flex flex-col group transition-all hover:shadow-2xl hover:shadow-indigo-500/10`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-slate-800/50 border-b border-slate-800">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-3 rounded-xl ${
|
||||
plan.tier === 'STARTER' ? 'bg-slate-700/50 text-slate-400' :
|
||||
plan.tier === 'BOOSTER' ? 'bg-indigo-500/20 text-indigo-400' :
|
||||
'bg-amber-500/20 text-amber-400'
|
||||
}`}>
|
||||
{plan.tier === 'STARTER' ? <Zap className="w-5 h-5" /> :
|
||||
plan.tier === 'BOOSTER' ? <Zap className="w-5 h-5" /> :
|
||||
<Star className="w-5 h-5" />}
|
||||
</div>
|
||||
{plan.recommended && (
|
||||
<span className="bg-indigo-600 text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded text-white shadow-lg">
|
||||
Populaire
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider">{plan.name}</h3>
|
||||
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Price & Limit */}
|
||||
<div className="p-6 bg-slate-950/30 flex-1">
|
||||
<div className="mb-6">
|
||||
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div>
|
||||
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
||||
{plan.features.map((feature: string, idx: number) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm text-slate-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="p-4 bg-slate-800/20 border-t border-slate-800">
|
||||
<button
|
||||
onClick={() => setEditingPlan(plan)}
|
||||
className="w-full py-3 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold transition-all flex items-center justify-center gap-2 uppercase tracking-widest text-xs border border-slate-700"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Modifier le plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editingPlan && (
|
||||
<PlanEditModal
|
||||
isOpen={!!editingPlan}
|
||||
onClose={() => setEditingPlan(null)}
|
||||
plan={editingPlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function FeaturedModal({ business }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
const handleRemove = async () => {
|
||||
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
|
||||
startTransition(async () => {
|
||||
const result = await removeFeaturedBusiness(business.id);
|
||||
@@ -48,7 +48,7 @@ export default function FeaturedModal({ business }: Props) {
|
||||
toast.success("Titre révoqué");
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
toast.error(result.error || "Une erreur est survenue lors de la suppression");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
131
admin/src/components/LegalEditor.tsx
Normal file
131
admin/src/components/LegalEditor.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LegalDocument } from '@prisma/client';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
import { updateLegalDocument } from '@/app/actions/legal';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { FileText, Save, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
initialCgu: LegalDocument | null;
|
||||
initialCgv: LegalDocument | null;
|
||||
}
|
||||
|
||||
export default function LegalEditor({ initialCgu, initialCgv }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'CGU' | 'CGV'>('CGU');
|
||||
const [cgu, setCgu] = useState({
|
||||
title: initialCgu?.title || "Conditions Générales d'Utilisation",
|
||||
content: initialCgu?.content || ""
|
||||
});
|
||||
const [cgv, setCgv] = useState({
|
||||
title: initialCgv?.title || "Conditions Générales de Vente",
|
||||
content: initialCgv?.content || ""
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentDoc = activeTab === 'CGU' ? cgu : cgv;
|
||||
|
||||
const handleUpdate = (field: string, value: string) => {
|
||||
if (activeTab === 'CGU') {
|
||||
setCgu(prev => ({ ...prev, [field]: value }));
|
||||
} else {
|
||||
setCgv(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateLegalDocument(activeTab, currentDoc.title, currentDoc.content);
|
||||
if (result.success) {
|
||||
toast.success(`${activeTab} mis à jour avec succès`);
|
||||
} else {
|
||||
toast.error(result.error || "Une erreur est survenue");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erreur de connexion au serveur");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('CGU')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'CGU'
|
||||
? 'bg-indigo-600 text-white shadow-lg'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
CGU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('CGV')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'CGV'
|
||||
? 'bg-indigo-600 text-white shadow-lg'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
CGV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-600/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Modifier {activeTab}</h2>
|
||||
<p className="text-sm text-slate-400">Dernière mise à jour : {new Date().toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Titre du document</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentDoc.title}
|
||||
onChange={(e) => handleUpdate('title', e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
||||
placeholder="Ex: Conditions Générales d'Utilisation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Contenu</label>
|
||||
<RichTextEditor
|
||||
value={currentDoc.content}
|
||||
onChange={(val) => handleUpdate('content', val)}
|
||||
placeholder={`Commencez à rédiger vos ${activeTab}...`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 p-4 rounded-xl flex items-center gap-3 text-amber-500">
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">!</div>
|
||||
<p className="text-sm font-medium">
|
||||
Attention : Les modifications seront immédiatement visibles pour tous les utilisateurs sur le site public une fois enregistrées.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
admin/src/components/PlanEditModal.tsx
Normal file
217
admin/src/components/PlanEditModal.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Trash2, Save, Loader2 } from 'lucide-react';
|
||||
import { updatePricingPlanDetail } from '@/app/actions/plans';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface PlanEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
plan: any;
|
||||
}
|
||||
|
||||
export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: plan.name,
|
||||
priceXOF: plan.priceXOF,
|
||||
priceEUR: plan.priceEUR,
|
||||
description: plan.description,
|
||||
offerLimit: plan.offerLimit.toString(),
|
||||
recommended: plan.recommended,
|
||||
color: plan.color,
|
||||
});
|
||||
const [features, setFeatures] = useState<string[]>(plan.features || []);
|
||||
|
||||
const handleAddFeature = () => {
|
||||
setFeatures([...features, '']);
|
||||
};
|
||||
|
||||
const handleFeatureChange = (index: number, value: string) => {
|
||||
const newFeatures = [...features];
|
||||
newFeatures[index] = value;
|
||||
setFeatures(newFeatures);
|
||||
};
|
||||
|
||||
const handleRemoveFeature = (index: number) => {
|
||||
setFeatures(features.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await updatePricingPlanDetail(plan.id, {
|
||||
...formData,
|
||||
features: features.filter(f => f.trim() !== ''),
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Détails du forfait mis à jour !");
|
||||
onClose();
|
||||
window.location.reload(); // Refresh to catch changes
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors de la sauvegarde.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erreur réseau");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onClick={onClose}></div>
|
||||
|
||||
<div className="relative bg-slate-900 border border-slate-700 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col transition-all">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
|
||||
<p className="text-sm text-slate-400">Configurez l'affichage et les limites de ce forfait.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<form id="plan-form" onSubmit={handleSubmit} className="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 font-sans uppercase tracking-wider">Nom affiché</label>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Limite d'offres</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.offerLimit}
|
||||
onChange={(e) => setFormData({...formData, offerLimit: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Texte)</label>
|
||||
<input
|
||||
value={formData.priceXOF}
|
||||
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Texte)</label>
|
||||
<input
|
||||
value={formData.priceEUR}
|
||||
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Couleur (Tailwind)</label>
|
||||
<select
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData({...formData, color: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
>
|
||||
<option value="gray">Gris (Starter)</option>
|
||||
<option value="brand">Brand (Booster)</option>
|
||||
<option value="amber">Amber (Empire)</option>
|
||||
<option value="rose">Rose</option>
|
||||
<option value="blue">Blue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end pb-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<div className={`w-10 h-6 rounded-full transition-colors relative ${formData.recommended ? 'bg-indigo-600' : 'bg-slate-700'}`}>
|
||||
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${formData.recommended ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={formData.recommended}
|
||||
onChange={(e) => setFormData({...formData, recommended: e.target.checked})}
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors uppercase tracking-wider">Mettre en avant</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Avantages du plan</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFeature}
|
||||
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 uppercase tracking-tight"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Ajouter un avantage
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
value={feature}
|
||||
onChange={(e) => handleFeatureChange(index, e.target.value)}
|
||||
placeholder="Ex: 50 Offres produits"
|
||||
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFeature(index)}
|
||||
className="p-3 text-slate-500 hover:text-rose-500 bg-slate-950 border border-slate-700 rounded-xl transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 rounded-xl font-bold text-slate-400 hover:text-white border border-slate-700 hover:bg-slate-700 transition-all uppercase tracking-wider text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="plan-form"
|
||||
disabled={loading}
|
||||
className="px-8 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white rounded-xl font-bold transition-all shadow-lg flex items-center gap-2 uppercase tracking-wider text-sm"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
admin/src/components/PlanSelector.tsx
Normal file
69
admin/src/components/PlanSelector.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { updateBusinessPlan } from '@/app/actions/plans';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { CreditCard, Loader2 } from 'lucide-react';
|
||||
|
||||
interface PlanSelectorProps {
|
||||
businessId: string;
|
||||
currentPlan: string;
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{ id: 'STARTER', name: 'Starter', color: 'text-slate-400', bg: 'bg-slate-400/10' },
|
||||
{ id: 'BOOSTER', name: 'Booster', color: 'text-brand-400', bg: 'bg-brand-400/10' },
|
||||
{ id: 'EMPIRE', name: 'Empire', color: 'text-amber-400', bg: 'bg-amber-400/10' }
|
||||
];
|
||||
|
||||
export default function PlanSelector({ businessId, currentPlan }: PlanSelectorProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [plan, setPlan] = useState(currentPlan);
|
||||
|
||||
const handlePlanChange = async (newPlan: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const result = await updateBusinessPlan(businessId, newPlan as any);
|
||||
if (result.success) {
|
||||
setPlan(newPlan);
|
||||
toast.success(`Forfait mis à jour vers ${newPlan}`);
|
||||
} else {
|
||||
toast.error(result.error || "Erreur lors du changement de forfait");
|
||||
// Reset to old plan in UI
|
||||
setPlan(currentPlan);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erreur réseau");
|
||||
setPlan(currentPlan);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center group">
|
||||
<div className={`absolute left-2 text-slate-500 pointer-events-none ${isUpdating ? 'animate-spin' : ''}`}>
|
||||
{isUpdating ? <Loader2 className="w-3.5 h-3.5" /> : <CreditCard className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<select
|
||||
value={plan}
|
||||
onChange={(e) => handlePlanChange(e.target.value)}
|
||||
disabled={isUpdating}
|
||||
className={`appearance-none bg-slate-900 border border-slate-700 pl-8 pr-8 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider focus:outline-none focus:ring-2 focus:ring-brand-500/50 transition-all cursor-pointer hover:border-slate-600 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
PLANS.find(p => p.id === plan)?.color || 'text-white'
|
||||
}`}
|
||||
>
|
||||
{PLANS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-slate-900 text-white font-sans capitalize">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2 pointer-events-none">
|
||||
<svg className="w-3 h-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
admin/src/components/RatingModerationButtons.tsx
Normal file
51
admin/src/components/RatingModerationButtons.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, X, Loader2 } from 'lucide-react';
|
||||
import { updateRatingStatus } from '@/app/actions/moderation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface RatingModerationButtonsProps {
|
||||
ratingId: string;
|
||||
}
|
||||
|
||||
export default function RatingModerationButtons({ ratingId }: RatingModerationButtonsProps) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const handleAction = async (status: 'APPROVED' | 'REJECTED') => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await updateRatingStatus(ratingId, status);
|
||||
if (result.success) {
|
||||
toast.success(status === 'APPROVED' ? "Avis approuvé !" : "Avis rejeté.");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erreur lors de l'opération");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAction('APPROVED')}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 bg-brand-600 hover:bg-brand-700 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-all disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
Approuver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('REJECTED')}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 bg-slate-800 hover:bg-red-500/20 hover:text-red-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
|
||||
Rejeter
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
ShieldCheck,
|
||||
BarChart3,
|
||||
Flag,
|
||||
Settings
|
||||
CreditCard,
|
||||
Settings,
|
||||
Globe,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
@@ -22,11 +25,22 @@ const menuItems = [
|
||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getPendingVerificationCount } from '@/app/actions/business';
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getPendingVerificationCount().then(setPendingCount);
|
||||
}, [pathname]); // Refresh on navigation
|
||||
|
||||
return (
|
||||
<div className="admin-sidebar p-6 flex flex-col">
|
||||
@@ -44,10 +58,18 @@ export default function Sidebar() {
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`nav-link ${isActive ? 'active' : ''}`}
|
||||
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
<span>{item.name}</span>
|
||||
<div className="flex items-center">
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
|
||||
{item.name === 'Entrepreneurs' && 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">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
// Updated: 2026-04-12 08:34 (Refreshed for new models)
|
||||
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
|
||||
import pg from 'pg'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!
|
||||
@@ -11,10 +11,10 @@ function makePrisma() {
|
||||
return new PrismaClient({ adapter })
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma_v2: PrismaClient }
|
||||
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
|
||||
|
||||
export const prisma = globalForPrisma.prisma_v2 || makePrisma()
|
||||
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v2 = prisma
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
|
||||
|
||||
export default prisma
|
||||
|
||||
17
admin/test-pricing.ts
Normal file
17
admin/test-pricing.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import prisma from './src/lib/prisma'
|
||||
|
||||
async function debug() {
|
||||
console.log('Checking prisma.pricingPlan...')
|
||||
// @ts-ignore
|
||||
if (prisma.pricingPlan) {
|
||||
console.log('✅ pricingPlan is defined')
|
||||
// @ts-ignore
|
||||
const count = await prisma.pricingPlan.count()
|
||||
console.log('Count:', count)
|
||||
} else {
|
||||
console.log('❌ pricingPlan is NOT defined')
|
||||
console.log('Available models:', Object.keys(prisma).filter(k => !k.startsWith('$') && !k.startsWith('_')))
|
||||
}
|
||||
}
|
||||
|
||||
debug().catch(console.error)
|
||||
Reference in New Issue
Block a user