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:
2
.env
2
.env
@@ -1,5 +1,5 @@
|
|||||||
# Connexion locale via le Docker Compose fourni :
|
# Connexion locale via le Docker Compose fourni :
|
||||||
DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afropreunariat_dev?schema=public"
|
DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
# Exemple avec Supabase ou Neon (si base distante)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
@@ -5,13 +5,6 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UserRole {
|
|
||||||
VISITOR
|
|
||||||
ENTREPRENEUR
|
|
||||||
ADMIN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -21,74 +14,95 @@ model User {
|
|||||||
password String
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
phone String?
|
|
||||||
bio String? @db.Text
|
|
||||||
location String?
|
|
||||||
isSuspended Boolean @default(false)
|
|
||||||
suspensionReason String?
|
|
||||||
|
|
||||||
businesses Business?
|
|
||||||
comments Comment[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
bio String?
|
||||||
// Messaging relations
|
location String?
|
||||||
sentMessages Message[]
|
countryId String?
|
||||||
|
phone String?
|
||||||
|
isSuspended Boolean @default(false)
|
||||||
|
suspensionReason String?
|
||||||
|
businesses Business?
|
||||||
|
comments Comment[]
|
||||||
conversations ConversationParticipant[]
|
conversations ConversationParticipant[]
|
||||||
|
sentMessages Message[]
|
||||||
reports MessageReport[]
|
reports MessageReport[]
|
||||||
|
ratings Rating[]
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
slug String? @unique
|
|
||||||
category String
|
category String
|
||||||
location String
|
location String // Legacy location string
|
||||||
description String @db.Text
|
countryId String?
|
||||||
|
city String?
|
||||||
|
description String
|
||||||
logoUrl String
|
logoUrl String
|
||||||
videoUrl String?
|
videoUrl String?
|
||||||
|
|
||||||
// Contact & Social
|
|
||||||
contactEmail String
|
contactEmail String
|
||||||
contactPhone String?
|
contactPhone String?
|
||||||
websiteUrl String?
|
|
||||||
// We use JSON to store social links (facebook, linkedin, instagram, website)
|
|
||||||
socialLinks Json?
|
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)
|
verified Boolean @default(false)
|
||||||
viewCount Int @default(0)
|
viewCount Int @default(0)
|
||||||
rating Float @default(0.0)
|
rating Float @default(0.0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
tags String[]
|
tags String[]
|
||||||
isSuspended Boolean @default(false)
|
|
||||||
suspensionReason String?
|
|
||||||
|
|
||||||
// Entrepreneur of the Month fields
|
|
||||||
isFeatured Boolean @default(false)
|
isFeatured Boolean @default(false)
|
||||||
founderName String?
|
founderName String?
|
||||||
founderImageUrl String?
|
founderImageUrl String?
|
||||||
keyMetric String?
|
keyMetric String?
|
||||||
|
|
||||||
// Relations
|
|
||||||
ownerId String @unique
|
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])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
offers Offer[]
|
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations Conversation[]
|
conversations Conversation[]
|
||||||
|
offers Offer[]
|
||||||
|
ratings Rating[]
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OfferType {
|
enum Plan {
|
||||||
PRODUCT
|
STARTER
|
||||||
SERVICE
|
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 {
|
model Offer {
|
||||||
@@ -96,52 +110,28 @@ model Offer {
|
|||||||
title String
|
title String
|
||||||
type OfferType
|
type OfferType
|
||||||
price Float
|
price Float
|
||||||
currency String @default("XOF") // EUR or XOF
|
currency String @default("XOF")
|
||||||
description String? @db.Text
|
description String?
|
||||||
imageUrl String
|
imageUrl String
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
|
||||||
// Relations
|
|
||||||
businessId String
|
businessId String
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model BlogPost {
|
model BlogPost {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
excerpt String @db.Text
|
excerpt String
|
||||||
content String @db.Text
|
content String
|
||||||
author String
|
author String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
imageUrl String
|
imageUrl String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Interview {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
@@ -151,45 +141,71 @@ model Interview {
|
|||||||
type InterviewType
|
type InterviewType
|
||||||
thumbnailUrl String
|
thumbnailUrl String
|
||||||
videoUrl String?
|
videoUrl String?
|
||||||
content String? @db.Text
|
content String?
|
||||||
excerpt String @db.Text
|
excerpt String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
duration String? // "15 min" ou "5 min de lecture"
|
duration String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
content String @db.Text
|
content String
|
||||||
|
|
||||||
// Relations
|
|
||||||
authorId String
|
authorId String
|
||||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
||||||
businessId String
|
businessId String
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model AnalyticsEvent {
|
model AnalyticsEvent {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String // PAGE_VIEW, CLICK, DWELL_TIME
|
type String
|
||||||
path String
|
path String
|
||||||
label String? // For clicks (button text, link name)
|
label String?
|
||||||
value Float? // For dwell time (seconds)
|
value Float?
|
||||||
metadata Json? // Browser, OS, etc.
|
metadata Json?
|
||||||
|
ip String?
|
||||||
|
country String?
|
||||||
|
city String?
|
||||||
|
browser String?
|
||||||
|
os String?
|
||||||
|
device String?
|
||||||
|
referrer String?
|
||||||
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
model Conversation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
participants ConversationParticipant[]
|
participants ConversationParticipant[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
@@ -199,24 +215,22 @@ model ConversationParticipant {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
conversationId 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])
|
@@unique([userId, conversationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
content String @db.Text
|
content String
|
||||||
senderId String
|
senderId String
|
||||||
conversationId String
|
conversationId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
read Boolean @default(false)
|
read Boolean @default(false)
|
||||||
|
|
||||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
|
||||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
reports MessageReport[]
|
reports MessageReport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +241,6 @@ model MessageReport {
|
|||||||
reporterId String
|
reporterId String
|
||||||
status ReportStatus @default(PENDING)
|
status ReportStatus @default(PENDING)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
@@ -237,3 +250,34 @@ enum ReportStatus {
|
|||||||
RESOLVED
|
RESOLVED
|
||||||
DISMISSED
|
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" };
|
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" };
|
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
|
// Default fallback values if not initialized
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
siteName: "Afropreunariat",
|
siteName: "Afrohub",
|
||||||
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.",
|
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",
|
contactPhone: "+225 00 00 00 00 00",
|
||||||
address: "Abidjan, Côte d'Ivoire",
|
address: "Abidjan, Côte d'Ivoire",
|
||||||
facebookUrl: "",
|
facebookUrl: "",
|
||||||
twitterUrl: "",
|
twitterUrl: "",
|
||||||
instagramUrl: "",
|
instagramUrl: "",
|
||||||
linkedinUrl: "",
|
linkedinUrl: "",
|
||||||
footerText: "© 2025 Afropreunariat. Tous droits réservés."
|
footerText: "© 2025 Afrohub. Tous droits réservés."
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!settings) return defaultSettings;
|
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;
|
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() {
|
export default async function DashboardPage() {
|
||||||
@@ -29,6 +35,7 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
{ 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: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
||||||
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
||||||
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-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 ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
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() {
|
async function getBusinesses() {
|
||||||
return await prisma.business.findMany({
|
return await prisma.business.findMany({
|
||||||
@@ -20,12 +21,31 @@ async function getBusinesses() {
|
|||||||
|
|
||||||
export default async function EntrepreneursPage() {
|
export default async function EntrepreneursPage() {
|
||||||
const businesses = await getBusinesses();
|
const businesses = await getBusinesses();
|
||||||
|
const pendingCount = businesses.filter((b: any) =>
|
||||||
|
!b.verified &&
|
||||||
|
!b.isSuspended &&
|
||||||
|
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<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>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
<div className="card overflow-hidden p-0">
|
||||||
@@ -36,6 +56,7 @@ export default async function EntrepreneursPage() {
|
|||||||
<th>Catégorie</th>
|
<th>Catégorie</th>
|
||||||
<th>Propriétaire</th>
|
<th>Propriétaire</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
|
<th className="text-center">Forfait</th>
|
||||||
<th className="text-center">Visibilité</th>
|
<th className="text-center">Visibilité</th>
|
||||||
<th className="text-center">Mise en avant</th>
|
<th className="text-center">Mise en avant</th>
|
||||||
<th className="text-right">Action</th>
|
<th className="text-right">Action</th>
|
||||||
@@ -90,6 +111,9 @@ export default async function EntrepreneursPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
|
</td>
|
||||||
<td className="text-center font-medium text-slate-300">
|
<td className="text-center font-medium text-slate-300">
|
||||||
{business.viewCount.toLocaleString()}
|
{business.viewCount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Toaster } from 'react-hot-toast';
|
|||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "AfroAdmin - Administration Afropreunariat",
|
title: "AfroAdmin - Administration Afrohub",
|
||||||
description: "Panneau d'administration pour la plateforme Afropreunariat",
|
description: "Panneau d'administration pour la plateforme Afrohub",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
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 { prisma } from '@/lib/prisma';
|
||||||
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
TrendingUp
|
TrendingUp,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
Search,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Calendar
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
async function getMetrics() {
|
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
||||||
// 1. Top Pages
|
if (value === null) return null;
|
||||||
const topPages = await prisma.analyticsEvent.groupBy({
|
const isPositive = value > 0;
|
||||||
by: ['path'],
|
const isZero = Math.abs(value) < 0.1;
|
||||||
where: { type: 'PAGE_VIEW' },
|
|
||||||
_count: {
|
|
||||||
id: true
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
_count: {
|
|
||||||
id: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
take: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Top Clicks
|
if (isZero) return (
|
||||||
const topClicks = await prisma.analyticsEvent.groupBy({
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
|
||||||
by: ['label'],
|
= 0%
|
||||||
where: { type: 'CLICK' },
|
</span>
|
||||||
_count: {
|
);
|
||||||
id: true
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
_count: {
|
|
||||||
id: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
take: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Average Dwell Time
|
return (
|
||||||
const dwellTime = await prisma.analyticsEvent.groupBy({
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||||
by: ['path'],
|
isPositive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'
|
||||||
where: { type: 'DWELL_TIME' },
|
}`}>
|
||||||
_avg: {
|
{isPositive ? <ArrowUp className="w-2 h-2 mr-0.5" /> : <ArrowDown className="w-2 h-2 mr-0.5" />}
|
||||||
value: true
|
{Math.abs(value).toFixed(1)}{isPercent ? '%' : ''}
|
||||||
},
|
</span>
|
||||||
_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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MetricsPage() {
|
async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||||
const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics();
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
|
<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>
|
||||||
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
|
<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
|
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<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="flex justify-between items-start mb-4">
|
||||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
<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" />
|
<Eye className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-white">{uniquePaths}</span>
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white">{totalEvents}</div>
|
||||||
|
<ComparisonBadge value={deltas.events} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 font-medium">Pages uniques explorées</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-400">
|
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||||
<MousePointer2 className="w-6 h-6" />
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-white">
|
|
||||||
{topClicks.reduce((acc, curr) => acc + curr._count.id, 0)}
|
<div className="card border-t-4 border-amber-500">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-slate-400 font-medium">Clicks enregistrés (Top 10)</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
<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>
|
||||||
<span className="text-2xl font-bold text-white">
|
<div className="text-right">
|
||||||
{Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s
|
<div className="text-2xl font-bold text-white">
|
||||||
</span>
|
{totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}%
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-slate-400 font-medium">Temps moyen / page</h3>
|
<ComparisonBadge value={deltas.mobile} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<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="card p-0 overflow-hidden">
|
||||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
<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">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
@@ -141,64 +357,6 @@ export default async function MetricsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
import { getReports } from '@/app/actions/moderation';
|
import { getReports, getPendingRatings } from '@/app/actions/moderation';
|
||||||
import ReportActionButtons from '@/components/ReportActionButtons';
|
import ReportActionButtons from '@/components/ReportActionButtons';
|
||||||
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
|
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 reports = await getReports();
|
||||||
|
const pendingRatings = await getPendingRatings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
|
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
|
||||||
<Flag className="text-red-500" />
|
<Flag className="text-red-500" />
|
||||||
Modération des Messages
|
Centre de Modération
|
||||||
</h1>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{tab === 'messages' && (
|
||||||
|
<>
|
||||||
{reports.length === 0 ? (
|
{reports.length === 0 ? (
|
||||||
<div className="card p-10 text-center text-slate-500">
|
<div className="card p-10 text-center text-slate-500">
|
||||||
Aucun signalement en attente.
|
Aucun signalement en attente.
|
||||||
@@ -25,110 +53,128 @@ export default async function ModerationPage() {
|
|||||||
reports.map((report) => (
|
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 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="flex flex-col md:flex-row gap-6">
|
||||||
{/* 1. Report Info */}
|
|
||||||
<div className="md:w-1/3 space-y-4">
|
<div className="md:w-1/3 space-y-4">
|
||||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
{new Date(report.createdAt).toLocaleString('fr-FR')}
|
{new Date(report.createdAt).toLocaleString('fr-FR')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</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="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">
|
<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>
|
||||||
{report.reporter.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold text-white">{report.reporter.name}</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 className="text-xs text-slate-500">{report.reporter.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison invoquée</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>
|
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<span className={`badge ${
|
<span className={`badge ${
|
||||||
report.status === 'PENDING' ? 'badge-pending' :
|
report.status === 'PENDING' ? 'badge-pending' : 'badge-verified'
|
||||||
report.status === 'RESOLVED' ? 'badge-verified' :
|
|
||||||
'bg-slate-700 text-slate-300'
|
|
||||||
}`}>
|
}`}>
|
||||||
{report.status === 'PENDING' ? 'En attente' :
|
{report.status === 'PENDING' ? 'En attente' : 'Terminé'}
|
||||||
report.status === 'RESOLVED' ? 'Résolu' :
|
|
||||||
'Rejeté'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 p-4">
|
||||||
{/* 2. Conversation History */}
|
<Link
|
||||||
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 flex flex-col overflow-hidden">
|
href={`/messages/${report.message.conversationId}`}
|
||||||
<div className="bg-slate-800/50 p-3 border-b border-slate-700 flex items-center justify-between">
|
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 mb-4"
|
||||||
<div className="flex items-center gap-2 text-indigo-400">
|
>
|
||||||
<MessageCircle className="w-4 h-4" />
|
Voir la conversation complète →
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">Historique de la discussion</span>
|
</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>
|
</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é
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{report.status === 'PENDING' && (
|
{report.status === 'PENDING' && (
|
||||||
<div className="p-4 bg-slate-800/30 border-t border-slate-700">
|
<div className="flex flex-col gap-4 mt-6 pt-6 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} />
|
<ReportActionButtons reportId={report.id} />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 text-[10px] text-slate-500 font-bold uppercase">Sanctionner l'auteur</div>
|
|
||||||
<ModerationSuspensionButton
|
<ModerationSuspensionButton
|
||||||
userId={report.message.senderId}
|
userId={report.message.senderId}
|
||||||
userName={report.message.sender.name}
|
userName={report.message.sender.name}
|
||||||
isSuspended={!!report.message.sender.isSuspended}
|
isSuspended={!!report.message.sender.isSuspended}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</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' ?")) {
|
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await removeFeaturedBusiness(business.id);
|
const result = await removeFeaturedBusiness(business.id);
|
||||||
@@ -48,7 +48,7 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
toast.success("Titre révoqué");
|
toast.success("Titre révoqué");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} else {
|
} 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,
|
ShieldCheck,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Flag,
|
Flag,
|
||||||
Settings
|
CreditCard,
|
||||||
|
Settings,
|
||||||
|
Globe,
|
||||||
|
FileText
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -22,11 +25,22 @@ const menuItems = [
|
|||||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
{ 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' },
|
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getPendingVerificationCount } from '@/app/actions/business';
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPendingVerificationCount().then(setPendingCount);
|
||||||
|
}, [pathname]); // Refresh on navigation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-sidebar p-6 flex flex-col">
|
<div className="admin-sidebar p-6 flex flex-col">
|
||||||
@@ -44,10 +58,18 @@ export default function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`nav-link ${isActive ? 'active' : ''}`}
|
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
<item.icon className="w-5 h-5 mr-3" />
|
<item.icon className="w-5 h-5 mr-3" />
|
||||||
<span>{item.name}</span>
|
<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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
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'
|
import pg from 'pg'
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!
|
const connectionString = process.env.DATABASE_URL!
|
||||||
@@ -11,10 +11,10 @@ function makePrisma() {
|
|||||||
return new PrismaClient({ adapter })
|
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
|
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)
|
||||||
@@ -3,7 +3,6 @@ import Link from 'next/link';
|
|||||||
import { Play, FileText, Clock, Mic } from 'lucide-react';
|
import { Play, FileText, Clock, Mic } from 'lucide-react';
|
||||||
import { prisma } from '../../lib/prisma';
|
import { prisma } from '../../lib/prisma';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType } from '@prisma/client';
|
||||||
import { MOCK_INTERVIEWS } from '../../lib/mockData';
|
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
@@ -28,11 +27,8 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Database connection failed on Afro Life page, using mock data.");
|
console.error("Database connection failed on Afro Life page.");
|
||||||
interviews = MOCK_INTERVIEWS.filter(i => filter === 'ALL' || i.type === filter).map(i => ({
|
interviews = [];
|
||||||
...i,
|
|
||||||
createdAt: new Date(i.date),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2 } from 'lucide-react';
|
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Twitter, Play, Share2, Send, Award, Quote, MessageCircle, Lock, Loader2, Link2, ShoppingBag } from 'lucide-react';
|
||||||
import { MOCK_BUSINESSES, MOCK_OFFERS } from '../../../lib/mockData';
|
import { Business, OfferType, Rating } from '../../../types';
|
||||||
import { Business, OfferType } from '../../../types';
|
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useUser } from '../../../components/UserProvider';
|
import { useUser } from '../../../components/UserProvider';
|
||||||
|
|
||||||
@@ -17,6 +16,22 @@ const BusinessDetailPage = () => {
|
|||||||
const [business, setBusiness] = useState<Business | null>(null);
|
const [business, setBusiness] = useState<Business | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
|
const [existingConversationId, setExistingConversationId] = useState<string | null>(null);
|
||||||
|
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||||
|
const [selectedOffer, setSelectedOffer] = useState<any | null>(null);
|
||||||
|
const [userRating, setUserRating] = useState<number | null>(null);
|
||||||
|
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||||
|
const [isRating, setIsRating] = useState(false);
|
||||||
|
const [ratings, setRatings] = useState<Rating[]>([]);
|
||||||
|
const [reviewComment, setReviewComment] = useState('');
|
||||||
|
const [userRatingStatus, setUserRatingStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
|
||||||
|
const [loadingRatings, setLoadingRatings] = useState(false);
|
||||||
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = user && business && user.id === business.ownerId;
|
||||||
|
|
||||||
|
const contactRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [contactForm, setContactForm] = useState({
|
const [contactForm, setContactForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -25,13 +40,30 @@ const BusinessDetailPage = () => {
|
|||||||
});
|
});
|
||||||
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
||||||
|
|
||||||
|
const handleOrder = (offer: any) => {
|
||||||
|
setContactForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
message: `Bonjour, je souhaiterais commander ou avoir plus d'informations sur votre offre : "${offer.title}". Pouvez-vous me recontacter ?`
|
||||||
|
}));
|
||||||
|
setSelectedOffer(null);
|
||||||
|
|
||||||
|
// Scroll to contact form
|
||||||
|
contactRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
||||||
|
// Focus textarea after scroll
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.getElementById('contact-message');
|
||||||
|
if (textarea) textarea.focus();
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
toast.success("Message préparé ! Vous pouvez l'envoyer ci-dessous.");
|
||||||
|
};
|
||||||
|
|
||||||
const businessOffers = useMemo(() => {
|
const businessOffers = useMemo(() => {
|
||||||
if (!business) return [];
|
if (!business) return [];
|
||||||
// If the business has its own offers (from DB), use them
|
|
||||||
if (business.offers && business.offers.length > 0) return business.offers;
|
if (business.offers && business.offers.length > 0) return business.offers;
|
||||||
// Otherwise fallback to mock offers for mock businesses
|
return [];
|
||||||
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
|
}, [business]);
|
||||||
}, [business, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
@@ -39,13 +71,7 @@ const BusinessDetailPage = () => {
|
|||||||
const loadBusiness = async () => {
|
const loadBusiness = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 1. Try Mock Data First (Instant)
|
// Sync with DB directly
|
||||||
const mock = MOCK_BUSINESSES.find(b => b.id === id);
|
|
||||||
if (mock) {
|
|
||||||
setBusiness(mock);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try API (Database)
|
// 2. Try API (Database)
|
||||||
try {
|
try {
|
||||||
@@ -86,6 +112,37 @@ const BusinessDetailPage = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Fetch user rating if logged in
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
const ratingRes = await fetch(`/api/businesses/${id}/rating/me`, {
|
||||||
|
headers: { 'x-user-id': user.id }
|
||||||
|
});
|
||||||
|
const ratingData = await ratingRes.json();
|
||||||
|
if (ratingData.rating) {
|
||||||
|
setUserRating(ratingData.rating);
|
||||||
|
setReviewComment(ratingData.comment || '');
|
||||||
|
setUserRatingStatus(ratingData.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching user rating:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fetch all ratings
|
||||||
|
try {
|
||||||
|
setLoadingRatings(true);
|
||||||
|
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
|
||||||
|
if (ratingsRes.ok) {
|
||||||
|
const ratingsData = await ratingsRes.json();
|
||||||
|
setRatings(ratingsData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching ratings list:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingRatings(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (id) loadBusiness();
|
if (id) loadBusiness();
|
||||||
@@ -213,15 +270,151 @@ const BusinessDetailPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShare = (platform: string) => {
|
||||||
|
if (!business) return;
|
||||||
|
const url = encodeURIComponent(window.location.href);
|
||||||
|
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
|
||||||
|
|
||||||
|
let shareLink = '';
|
||||||
|
switch(platform) {
|
||||||
|
case 'facebook':
|
||||||
|
shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
|
||||||
|
break;
|
||||||
|
case 'twitter':
|
||||||
|
shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`;
|
||||||
|
break;
|
||||||
|
case 'linkedin':
|
||||||
|
shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
|
||||||
|
break;
|
||||||
|
case 'instagram':
|
||||||
|
toast("Pour partager sur Instagram, copiez le lien ou faites une capture d'écran.", { icon: '📸' });
|
||||||
|
setIsShareOpen(false);
|
||||||
|
return;
|
||||||
|
case 'copy':
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
toast.success("Lien copié dans le presse-papier !");
|
||||||
|
setIsShareOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareLink) {
|
||||||
|
window.open(shareLink, '_blank', 'width=600,height=400');
|
||||||
|
}
|
||||||
|
setIsShareOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRate = async (value: number) => {
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Connectez-vous pour voter');
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
toast.error("Vous ne pouvez pas noter votre propre entreprise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRating) return;
|
||||||
|
setIsRating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/businesses/${id}/rate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-id': user.id
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ value, comment: reviewComment })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.error) {
|
||||||
|
setUserRating(value);
|
||||||
|
setBusiness(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
rating: data.rating,
|
||||||
|
ratingCount: data.ratingCount
|
||||||
|
} : null);
|
||||||
|
|
||||||
|
toast.success('Merci pour votre vote !');
|
||||||
|
handleTrackEvent('BUSINESS_RATING_CAST', `Stars: ${value}`);
|
||||||
|
|
||||||
|
// Refresh ratings list
|
||||||
|
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
|
||||||
|
if (ratingsRes.ok) {
|
||||||
|
const ratingsData = await ratingsRes.json();
|
||||||
|
setRatings(ratingsData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur lors de l'enregistrement du vote");
|
||||||
|
} finally {
|
||||||
|
setIsRating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = async (ratingId: string) => {
|
||||||
|
if (!user || !isOwner) return;
|
||||||
|
if (!replyText.trim()) {
|
||||||
|
toast.error("La réponse ne peut pas être vide");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ratings/${ratingId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-user-id': user.id
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reply: replyText })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Réponse publiée !");
|
||||||
|
setReplyingTo(null);
|
||||||
|
setReplyText('');
|
||||||
|
|
||||||
|
// Refresh ratings
|
||||||
|
const ratingsRes = await fetch(`/api/businesses/${id}/ratings`);
|
||||||
|
if (ratingsRes.ok) {
|
||||||
|
const ratingsData = await ratingsRes.json();
|
||||||
|
setRatings(ratingsData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.error(data.error || "Erreur lors de la réponse");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Erreur de connexion");
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen pb-12">
|
<div className="bg-gray-50 min-h-screen pb-12">
|
||||||
{/* Header Banner */}
|
{/* Header Banner */}
|
||||||
<div className="h-48 md:h-64 bg-gradient-to-r from-gray-900 to-gray-800 relative overflow-hidden">
|
<div className="h-48 md:h-64 bg-gradient-to-r from-gray-900 to-gray-800 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div>
|
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
|
||||||
<Link href="/annuaire" className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit">
|
<button
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour
|
onClick={() => {
|
||||||
</Link>
|
if (window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push('/annuaire');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit group active:scale-95"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" /> Retour
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,10 +448,61 @@ const BusinessDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 relative">
|
||||||
<button className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsShareOpen(!isShareOpen)}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 transition-all active:scale-95"
|
||||||
|
>
|
||||||
<Share2 className="w-4 h-4 mr-2" /> Partager
|
<Share2 className="w-4 h-4 mr-2" /> Partager
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isShareOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-2xl py-3 border border-gray-100 z-50 animate-in fade-in zoom-in duration-200"
|
||||||
|
onMouseLeave={() => setIsShareOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2 text-xs font-bold text-gray-400 uppercase tracking-widest border-b border-gray-50 mb-1">Partager la fiche</div>
|
||||||
|
|
||||||
|
<button onClick={() => handleShare('facebook')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600 transition-colors">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3 text-blue-600">
|
||||||
|
<Facebook className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
Facebook
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => handleShare('twitter')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black transition-colors">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center mr-3 text-black">
|
||||||
|
<Twitter className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
X (Twitter)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => handleShare('linkedin')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-700 transition-colors">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3 text-blue-700">
|
||||||
|
<Linkedin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
LinkedIn
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => handleShare('instagram')} className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-pink-600 transition-colors text-gray-400">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-pink-50 flex items-center justify-center mr-3 text-pink-600">
|
||||||
|
<Instagram className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
Instagram
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100 my-1 mx-4"></div>
|
||||||
|
|
||||||
|
<button onClick={() => handleShare('copy')} className="w-full text-left px-4 py-2.5 text-sm font-medium text-brand-600 hover:bg-brand-50 flex items-center transition-colors">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3">
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
Copier le lien
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
|
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
|
||||||
Contacter
|
Contacter
|
||||||
</a>
|
</a>
|
||||||
@@ -270,10 +514,34 @@ const BusinessDetailPage = () => {
|
|||||||
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
|
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
|
||||||
{business.location}
|
{business.location}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 group/rating">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Star className="w-4 h-4 mr-1 text-yellow-400 fill-current" />
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<span className="font-bold text-gray-700 mr-1">{business.rating}</span>
|
<button
|
||||||
({business.viewCount} vues)
|
key={star}
|
||||||
|
disabled={isRating}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(null)}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
className={`p-0.5 transition-all duration-200 ${!user ? 'cursor-default' : 'hover:scale-125 cursor-pointer active:scale-95'}`}
|
||||||
|
title={user ? `Voter ${star} étoiles` : "Connectez-vous pour voter"}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-5 h-5 transition-colors ${
|
||||||
|
(hoverRating !== null ? star <= hoverRating : (userRating !== null ? star <= userRating : star <= Math.round(business.rating)))
|
||||||
|
? 'text-yellow-400 fill-current'
|
||||||
|
: 'text-gray-300'
|
||||||
|
} ${isRating ? 'animate-pulse' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<span className="font-bold text-gray-900 mr-1">{business.rating.toFixed(1)}</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
({(business as any).ratingCount || 0} votes • {business.viewCount} vues)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{business.tags.map(tag => (
|
{business.tags.map(tag => (
|
||||||
<span key={tag} className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">#{tag}</span>
|
<span key={tag} className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">#{tag}</span>
|
||||||
@@ -351,20 +619,24 @@ const BusinessDetailPage = () => {
|
|||||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
{businessOffers.map(offer => (
|
{businessOffers.map(offer => (
|
||||||
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
|
<div
|
||||||
<div className="h-40 bg-gray-100 relative">
|
key={offer.id}
|
||||||
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
|
onClick={() => setSelectedOffer(offer)}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-all cursor-pointer group hover:scale-[1.01]"
|
||||||
|
>
|
||||||
|
<div className="h-40 bg-gray-100 relative overflow-hidden">
|
||||||
|
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||||
<span className={`absolute top-2 right-2 px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
<span className={`absolute top-2 right-2 px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||||
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-bold text-gray-900 line-clamp-1">{offer.title}</h3>
|
<h3 className="font-bold text-gray-900 line-clamp-1 group-hover:text-brand-600 transition-colors">{offer.title}</h3>
|
||||||
<p className="text-brand-600 font-bold mt-1">
|
<p className="text-brand-600 font-bold mt-1">
|
||||||
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
|
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
|
||||||
</p>
|
</p>
|
||||||
<button className="mt-3 w-full block text-center bg-gray-50 text-gray-700 py-2 rounded text-sm font-medium hover:bg-gray-100 transition-colors">
|
<button className="mt-3 w-full block text-center bg-gray-50 text-gray-700 py-2 rounded text-sm font-medium hover:bg-brand-50 hover:text-brand-700 transition-colors">
|
||||||
Commander
|
Détails & Commander
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,12 +644,216 @@ const BusinessDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center gap-2">
|
||||||
|
<MessageCircle className="w-5 h-5 text-brand-600" />
|
||||||
|
Avis & Témoignages
|
||||||
|
<span className="text-sm font-normal text-gray-400 font-sans ml-2">
|
||||||
|
({ratings.length} avis)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex text-yellow-400">
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<Star key={s} className={`w-4 h-4 ${s <= Math.round(business.rating) ? 'fill-current' : 'text-gray-200'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900">{business.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leave a Review Form */}
|
||||||
|
<div className="mb-10 p-6 bg-brand-50/50 rounded-2xl border border-brand-100/50">
|
||||||
|
<h3 className="text-sm font-bold text-brand-900 mb-4 flex items-center gap-2">
|
||||||
|
{isOwner ? "Votre Fiche" : (userRating ? "Modifier votre avis" : "Laisser un avis")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isOwner ? (
|
||||||
|
<p className="text-sm text-brand-600 bg-white p-4 rounded-xl border border-brand-100 italic">
|
||||||
|
Vous consultez votre propre fiche. Vous pouvez répondre aux avis des clients ci-dessous.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Votre Note</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
disabled={isRating}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(null)}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
className="transition-all duration-200 hover:scale-125 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-8 h-8 transition-colors ${
|
||||||
|
(hoverRating !== null ? star <= hoverRating : (userRating !== null ? star <= userRating : false))
|
||||||
|
? 'text-yellow-400 fill-current'
|
||||||
|
: 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{userRating && <span className="ml-2 text-sm font-medium text-brand-600">Vous avez mis {userRating} étoiles</span>}
|
||||||
|
</div>
|
||||||
|
{userRatingStatus === 'PENDING' && (
|
||||||
|
<div className="flex items-center gap-2 bg-amber-50 text-amber-700 px-3 py-2 rounded-lg border border-amber-100 mt-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<p className="text-[11px] font-medium leading-tight">
|
||||||
|
Votre avis est reçu ! Il est actuellement <span className="font-bold underline">en cours de modération</span> par notre équipe pour assurer la qualité des retours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{userRating && userRatingStatus !== 'PENDING' && <p className="text-[10px] text-gray-400 italic">Conformément à nos règles, la note peut uniquement être réévaluée à la hausse.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Votre Commentaire</label>
|
||||||
|
<textarea
|
||||||
|
value={reviewComment}
|
||||||
|
onChange={(e) => setReviewComment(e.target.value)}
|
||||||
|
placeholder={ratings.some(r => r.userId === user?.id && r.comment) ? "Le commentaire ne peut plus être modifié une fois posté." : "Partagez votre expérience avec cet entrepreneur..."}
|
||||||
|
className={`w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm ${ratings.some(r => r.userId === user?.id && r.comment) ? 'opacity-60 cursor-not-allowed bg-gray-50' : ''}`}
|
||||||
|
rows={3}
|
||||||
|
disabled={!user || isRating || ratings.some(r => r.userId === user?.id && r.comment)}
|
||||||
|
/>
|
||||||
|
{ratings.some(r => r.userId === user?.id && r.comment) && (
|
||||||
|
<p className="text-[10px] text-brand-600 font-medium">Votre avis est désormais immuable. Seul le score peut changer.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{!user && (
|
||||||
|
<p className="text-xs text-brand-600 italic flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Connectez-vous pour laisser un commentaire
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => userRating ? handleRate(userRating) : toast.error("Veuillez d'abord sélectionner une note")}
|
||||||
|
disabled={isRating}
|
||||||
|
className="ml-auto bg-brand-600 text-white px-6 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-brand-700 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isRating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
{userRating ? "Mettre à jour la note" : "Publier l'avis"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews List */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loadingRatings ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500 mx-auto" />
|
||||||
|
<p className="text-gray-400 text-sm mt-2">Chargement des avis...</p>
|
||||||
|
</div>
|
||||||
|
) : ratings.length > 0 ? (
|
||||||
|
ratings.map((r: any) => (
|
||||||
|
<div key={r.id} className="group border-b border-gray-50 pb-6 last:border-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand-100 flex-shrink-0 flex items-center justify-center font-bold text-brand-700 overflow-hidden border border-brand-50 shadow-sm">
|
||||||
|
{r.user?.avatar ? (
|
||||||
|
<img src={r.user.avatar} alt={r.user.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
r.user?.name?.charAt(0) || '?'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h4 className="text-sm font-bold text-gray-900">{r.user?.name}</h4>
|
||||||
|
<span className="text-[10px] text-gray-400 uppercase tracking-tighter">
|
||||||
|
{new Date(r.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex text-yellow-400 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<Star key={s} className={`w-3 h-3 ${s <= r.value ? 'fill-current' : 'text-gray-200'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{r.comment ? (
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed bg-gray-50/50 p-3 rounded-lg border border-gray-50 group-hover:bg-white group-hover:shadow-sm transition-all">
|
||||||
|
{r.comment}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-xs italic">Note attribuée sans commentaire.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entrepreneur Reply */}
|
||||||
|
{r.reply ? (
|
||||||
|
<div className="mt-4 ml-4 pl-4 border-l-2 border-brand-200 bg-brand-50/30 p-3 rounded-r-lg">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] font-bold text-brand-700 uppercase tracking-widest">Réponse de l'entrepreneur</span>
|
||||||
|
<span className="text-[9px] text-gray-400 uppercase">
|
||||||
|
{new Date(r.replyAt!).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 italic">"{r.reply}"</p>
|
||||||
|
</div>
|
||||||
|
) : isOwner && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{replyingTo === r.id ? (
|
||||||
|
<div className="space-y-2 animate-in slide-in-from-top-2 duration-200">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Votre réponse..."
|
||||||
|
className="w-full px-3 py-2 bg-white border border-brand-200 rounded-lg text-sm focus:ring-1 focus:ring-brand-500 outline-none transition-all resize-none shadow-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setReplyingTo(null)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReply(r.id)}
|
||||||
|
disabled={isSubmittingReply || !replyText.trim()}
|
||||||
|
className="bg-brand-600 text-white px-3 py-1 rounded text-xs font-bold hover:bg-brand-700 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? "Envoi..." : "Répondre"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setReplyingTo(r.id)}
|
||||||
|
className="text-xs font-bold text-brand-600 hover:text-brand-700 flex items-center gap-1 group/btn"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-3 h-3 transition-transform group-hover/btn:scale-110" />
|
||||||
|
Répondre à cet avis
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center bg-gray-50/50 rounded-2xl border border-dashed border-gray-200">
|
||||||
|
<MessageCircle className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">Aucun avis pour le moment.</p>
|
||||||
|
<p className="text-gray-400 text-xs mt-1">Soyez le premier à partager votre expérience !</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sidebar */}
|
{/* Right Column: Sidebar */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Contact Card */}
|
{/* Contact Card */}
|
||||||
<div id="contact-form" className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
|
<div ref={contactRef} id="contact-form" className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Contact</h3>
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Contact</h3>
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
@@ -457,6 +933,7 @@ const BusinessDetailPage = () => {
|
|||||||
<form onSubmit={handleContactSubmit} className="space-y-4">
|
<form onSubmit={handleContactSubmit} className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
placeholder={user ? "Bonjour, j'aimerais en savoir plus sur..." : "Connectez-vous pour envoyer un message"}
|
placeholder={user ? "Bonjour, j'aimerais en savoir plus sur..." : "Connectez-vous pour envoyer un message"}
|
||||||
required
|
required
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -522,29 +999,80 @@ const BusinessDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Similar Businesses (Mock) */}
|
{/* Similar Businesses (Mock) */}
|
||||||
|
{/* Similar Businesses (Disabled - Mock data removed) */}
|
||||||
|
{false && (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Dans la même catégorie</h3>
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Dans la même catégorie</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{MOCK_BUSINESSES
|
|
||||||
.filter(b => b.category === business.category && b.id !== business.id)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(similar => (
|
|
||||||
<Link key={similar.id} href={`/annuaire/${similar.id}`} className="flex items-center group">
|
|
||||||
<div className="w-12 h-12 rounded bg-gray-100 overflow-hidden flex-shrink-0">
|
|
||||||
<img src={similar.logoUrl} alt={similar.name} className="w-full h-full object-cover" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 overflow-hidden">
|
|
||||||
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-brand-600 transition-colors">{similar.name}</p>
|
|
||||||
<p className="text-xs text-gray-500 truncate">{similar.location}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
)}
|
||||||
))
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Offer Detail Modal */}
|
||||||
|
{selectedOffer && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||||
|
onClick={() => setSelectedOffer(null)}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden relative z-10 animate-in zoom-in slide-in-from-bottom-4 duration-300">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedOffer(null)}
|
||||||
|
className="absolute top-4 right-4 p-2 bg-white/80 backdrop-blur rounded-full text-gray-500 hover:text-gray-900 shadow-md transition-all z-20"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row h-full">
|
||||||
|
<div className="md:w-1/2 h-64 md:h-auto relative">
|
||||||
|
<img src={selectedOffer.imageUrl} alt={selectedOffer.title} className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute top-4 left-4">
|
||||||
|
<span className={`px-3 py-1 text-xs font-bold rounded-full shadow-lg ${selectedOffer.type === OfferType.PRODUCT ? 'bg-blue-600 text-white' : 'bg-purple-600 text-white'}`}>
|
||||||
|
{selectedOffer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:w-1/2 p-6 md:p-8 flex flex-col">
|
||||||
|
<h2 className="text-2xl font-bold font-serif text-gray-900 mb-2">{selectedOffer.title}</h2>
|
||||||
|
<p className="text-2xl font-bold text-brand-600 mb-6">
|
||||||
|
{new Intl.NumberFormat('fr-FR').format(selectedOffer.price)} {selectedOffer.currency}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 mb-8">
|
||||||
|
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Description</h4>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
|
||||||
|
{selectedOffer.description || "Aucune description détaillée n'est disponible pour ce produit/service."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOrder(selectedOffer)}
|
||||||
|
className="w-full bg-brand-600 text-white font-bold py-3 px-6 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-100 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
Commander maintenant
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedOffer(null)}
|
||||||
|
className="w-full bg-gray-50 text-gray-500 font-medium py-2 px-6 rounded-lg hover:bg-gray-100 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,26 +4,44 @@
|
|||||||
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
import React, { useState, useEffect, useMemo, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { CATEGORIES, Business } from '../../types';
|
import { CATEGORIES, Business, Country } from '../../types';
|
||||||
import BusinessCard from '../../components/BusinessCard';
|
import BusinessCard from '../../components/BusinessCard';
|
||||||
import AnnuaireHero from '../../components/AnnuaireHero';
|
import AnnuaireHero from '../../components/AnnuaireHero';
|
||||||
|
|
||||||
const AnnuairePageContent = () => {
|
const AnnuairePageContent = () => {
|
||||||
const [businesses, setBusinesses] = useState<Business[]>([]);
|
const [businesses, setBusinesses] = useState<Business[]>([]);
|
||||||
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
|
const [featuredBusiness, setFeaturedBusiness] = useState<Business | null>(null);
|
||||||
|
const [countries, setCountries] = useState<Country[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterCategory, setFilterCategory] = useState('All');
|
const [filterCategory, setFilterCategory] = useState('All');
|
||||||
|
const [filterCountry, setFilterCountry] = useState('All');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// 1. Fetch Featured Headliner once
|
// 1. Fetch Countries
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCountries = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/countries');
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) setCountries(data);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
fetchCountries();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2. Fetch Featured Headliner once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchHeadliner = async () => {
|
const fetchHeadliner = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/businesses?featured=true');
|
const res = await fetch('/api/businesses?featured=true');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
setFeaturedBusiness(data[0]);
|
// Stable random based on the current date (YYYYMMDD)
|
||||||
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const seed = parseInt(today);
|
||||||
|
const randomIndex = seed % data.length;
|
||||||
|
setFeaturedBusiness(data[randomIndex]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching headliner:', error);
|
console.error('Error fetching headliner:', error);
|
||||||
@@ -32,13 +50,14 @@ const AnnuairePageContent = () => {
|
|||||||
fetchHeadliner();
|
fetchHeadliner();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 2. Fetch businesses from API
|
// 3. Fetch businesses from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchBusinesses = async () => {
|
const fetchBusinesses = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filterCategory !== 'All') params.append('category', filterCategory);
|
if (filterCategory !== 'All') params.append('category', filterCategory);
|
||||||
|
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
||||||
if (searchQuery) params.append('q', searchQuery);
|
if (searchQuery) params.append('q', searchQuery);
|
||||||
|
|
||||||
const res = await fetch(`/api/businesses?${params.toString()}`);
|
const res = await fetch(`/api/businesses?${params.toString()}`);
|
||||||
@@ -55,11 +74,17 @@ const AnnuairePageContent = () => {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filterCategory, searchQuery]);
|
}, [filterCategory, searchQuery, filterCountry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
if (q) setSearchQuery(q);
|
if (q) setSearchQuery(q);
|
||||||
|
|
||||||
|
const cat = searchParams.get('category');
|
||||||
|
if (cat) setFilterCategory(cat);
|
||||||
|
|
||||||
|
const country = searchParams.get('country');
|
||||||
|
if (country) setFilterCountry(country);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const filteredBusinesses = businesses;
|
const filteredBusinesses = businesses;
|
||||||
@@ -87,9 +112,23 @@ const AnnuairePageContent = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
||||||
|
<select
|
||||||
|
value={filterCountry}
|
||||||
|
onChange={(e) => setFilterCountry(e.target.value)}
|
||||||
|
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-brand-500 focus:border-brand-500 sm:text-sm p-2 border"
|
||||||
|
>
|
||||||
|
<option value="All">Tous les pays</option>
|
||||||
|
{countries.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.flag} {c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Catégorie</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Catégorie</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
id="cat-all"
|
id="cat-all"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/admin/reports — List all reports for moderation
|
// GET /api/admin/reports — List all reports for moderation
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
@@ -37,14 +37,57 @@ export async function GET(
|
|||||||
// 3. Get monthly stats (views per day for last 30 days)
|
// 3. Get monthly stats (views per day for last 30 days)
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// Note: Raw query might be needed for group by date in some DBs,
|
const recentEvents = await prisma.analyticsEvent.findMany({
|
||||||
// but for now we'll just return the totals for simplicity.
|
where: {
|
||||||
|
createdAt: { gte: thirtyDaysAgo },
|
||||||
|
metadata: {
|
||||||
|
path: ['businessId'],
|
||||||
|
equals: id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Process events into daily stats
|
||||||
|
const statsMap = new Map<string, { date: string, views: number, clicks: number }>();
|
||||||
|
|
||||||
|
// Initialize last 30 days with 0
|
||||||
|
for (let i = 0; i <= 30; i++) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const displayDate = date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||||
|
statsMap.set(dateStr, { date: displayDate, views: 0, clicks: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
recentEvents.forEach(event => {
|
||||||
|
const dateStr = event.createdAt.toISOString().split('T')[0];
|
||||||
|
if (statsMap.has(dateStr)) {
|
||||||
|
const dayStat = statsMap.get(dateStr)!;
|
||||||
|
if (event.type === 'BUSINESS_VIEW') {
|
||||||
|
dayStat.views++;
|
||||||
|
} else if (event.type === 'BUSINESS_CONTACT_CLICK') {
|
||||||
|
dayStat.clicks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert Map to sorted array
|
||||||
|
const dailyStats = Array.from(statsMap.values()).reverse(); // Older to newer
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
businessId: id,
|
businessId: id,
|
||||||
totalViews,
|
totalViews,
|
||||||
contactClicks,
|
contactClicks,
|
||||||
|
dailyStats,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getGeoInfo } from '@/lib/geo';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
console.log('>>> ANALYTICS API HIT');
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
console.log('>>> PAYLOAD:', JSON.stringify(body));
|
|
||||||
const { type, path, label, value, metadata } = body;
|
const { type, path, label, value, metadata } = body;
|
||||||
|
|
||||||
if (!type || !path) {
|
if (!type || !path) {
|
||||||
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
|
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract detailed info from headers
|
||||||
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
|
const referrer = request.headers.get('referer') || '';
|
||||||
|
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';
|
||||||
|
|
||||||
|
// Basic User-Agent parsing
|
||||||
|
const isMobile = /mobile/i.test(userAgent);
|
||||||
|
const device = isMobile ? 'Mobile' : 'Desktop';
|
||||||
|
|
||||||
|
let browser = 'Other';
|
||||||
|
if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
|
||||||
|
else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
|
||||||
|
else if (/safari/i.test(userAgent)) browser = 'Safari';
|
||||||
|
else if (/edge/i.test(userAgent)) browser = 'Edge';
|
||||||
|
|
||||||
|
let os = 'Other';
|
||||||
|
if (/windows/i.test(userAgent)) os = 'Windows';
|
||||||
|
else if (/macintosh/i.test(userAgent)) os = 'macOS';
|
||||||
|
else if (/android/i.test(userAgent)) os = 'Android';
|
||||||
|
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||||
|
|
||||||
|
// Simple Country detection
|
||||||
|
const { country, city } = await getGeoInfo(ip, request.headers);
|
||||||
|
|
||||||
const event = await prisma.analyticsEvent.create({
|
const event = await prisma.analyticsEvent.create({
|
||||||
data: {
|
data: {
|
||||||
type,
|
type,
|
||||||
@@ -19,6 +42,13 @@ export async function POST(request: NextRequest) {
|
|||||||
label: label || null,
|
label: label || null,
|
||||||
value: value || null,
|
value: value || null,
|
||||||
metadata: metadata || {},
|
metadata: metadata || {},
|
||||||
|
ip,
|
||||||
|
country,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
referrer,
|
||||||
|
userId: metadata?.userId || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../../lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../../lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/blog
|
// GET /api/blog
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
115
app/api/businesses/[id]/rate/route.ts
Normal file
115
app/api/businesses/[id]/rate/route.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id: paramId } = await context.params;
|
||||||
|
const userId = req.headers.get('x-user-id');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { value, comment } = await req.json();
|
||||||
|
|
||||||
|
if (!value || value < 1 || value > 5) {
|
||||||
|
return NextResponse.json({ error: 'Valeur de note invalide (1-5)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve actual Business UUID if paramId is a slug
|
||||||
|
const business = await prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: paramId },
|
||||||
|
{ slug: paramId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ error: "Les données de démonstration ne peuvent pas recevoir d'avis. Veuillez créer votre propre entreprise pour tester." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = business.id;
|
||||||
|
const ownerId = (business as any).ownerId;
|
||||||
|
|
||||||
|
// 2. Security: Don't allow owner to rate themselves
|
||||||
|
if (userId === ownerId) {
|
||||||
|
return NextResponse.json({ error: "Vous ne pouvez pas noter votre propre entreprise." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch existing rating to check rules
|
||||||
|
const existingRating = await prisma.rating.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_businessId: {
|
||||||
|
userId,
|
||||||
|
businessId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRating) {
|
||||||
|
// Rule: Score can only be re-evaluated UPWARDS
|
||||||
|
if (value < existingRating.value) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `La note ne peut être réévaluée qu'à la hausse. Votre note actuelle est de ${existingRating.value} étoiles.`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: Comment is IMMUTABLE once set
|
||||||
|
// We only update the value. If the comment was empty, we can set it once.
|
||||||
|
await prisma.rating.update({
|
||||||
|
where: { id: existingRating.id },
|
||||||
|
data: {
|
||||||
|
value,
|
||||||
|
comment: existingRating.comment ? undefined : comment, // Only set if it was null
|
||||||
|
status: 'PENDING' // Reset to pending for re-moderation
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 4. Create new rating
|
||||||
|
await prisma.rating.create({
|
||||||
|
data: {
|
||||||
|
value,
|
||||||
|
comment,
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
status: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Aggregate all ratings for this business
|
||||||
|
const stats = await prisma.rating.aggregate({
|
||||||
|
where: { businessId },
|
||||||
|
_avg: { value: true },
|
||||||
|
_count: { value: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRating = stats._avg.value || 0;
|
||||||
|
const newRatingCount = stats._count.value || 0;
|
||||||
|
|
||||||
|
// 4. Update the business
|
||||||
|
const updatedBusiness = await prisma.business.update({
|
||||||
|
where: { id: businessId },
|
||||||
|
data: {
|
||||||
|
rating: newRating,
|
||||||
|
ratingCount: newRatingCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rating: updatedBusiness.rating,
|
||||||
|
ratingCount: updatedBusiness.ratingCount,
|
||||||
|
message: 'Vote enregistré avec succès'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error rating business:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur lors du vote' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/businesses/[id]/rating/me/route.ts
Normal file
49
app/api/businesses/[id]/rating/me/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id: paramId } = await context.params;
|
||||||
|
const userId = req.headers.get('x-user-id');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ rating: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve actual Business UUID if paramId is a slug
|
||||||
|
const business = await prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: paramId },
|
||||||
|
{ slug: paramId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ rating: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rating = await prisma.rating.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_businessId: {
|
||||||
|
userId,
|
||||||
|
businessId: business.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rating: rating ? rating.value : null,
|
||||||
|
status: rating ? rating.status : null,
|
||||||
|
comment: rating ? rating.comment : null
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching user rating:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur lors de la récupération de votre note' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/api/businesses/[id]/ratings/route.ts
Normal file
55
app/api/businesses/[id]/ratings/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id: paramId } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Resolve actual Business UUID if paramId is a slug
|
||||||
|
const business = await prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: paramId },
|
||||||
|
{ slug: paramId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
// Return empty array for mock/missing businesses instead of error
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
// 2. Fetch all approved ratings with user info
|
||||||
|
const ratings = await prisma.rating.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
status: 'APPROVED'
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
avatar: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(ratings);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching ratings:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur lors de la récupération des avis' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> }
|
type Params = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
@@ -49,12 +49,48 @@ export async function PATCH(
|
|||||||
|
|
||||||
// DELETE /api/businesses/[id]
|
// DELETE /api/businesses/[id]
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
|
const headerUserId = request.headers.get('x-user-id')
|
||||||
|
|
||||||
|
// 1. Find business to check owner
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { ownerId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ error: 'Boutique non trouvée' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Auth check (Owner or Admin)
|
||||||
|
const user = headerUserId ? await prisma.user.findUnique({ where: { id: headerUserId } }) : null
|
||||||
|
const isAdmin = user?.role === 'ADMIN'
|
||||||
|
const isOwner = business.ownerId === headerUserId
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete business
|
||||||
await prisma.business.delete({ where: { id } })
|
await prisma.business.delete({ where: { id } })
|
||||||
|
|
||||||
|
// 4. Update user role if they don't have other businesses
|
||||||
|
if (headerUserId && !isAdmin) {
|
||||||
|
const otherBusinesses = await prisma.business.findFirst({
|
||||||
|
where: { ownerId: headerUserId }
|
||||||
|
})
|
||||||
|
if (!otherBusinesses) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: headerUserId },
|
||||||
|
data: { role: 'VISITOR' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('DELETE /api/businesses/[id] error:', error)
|
console.error('DELETE /api/businesses/[id] error:', error)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Force refresh for Next.js 16 params fix
|
// Force refresh for Next.js 16 params fix
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../../../lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -13,10 +13,27 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
|
return NextResponse.json({ error: 'ID requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to increment in DB
|
// 1. Resolve actual Business UUID if id is a slug
|
||||||
// Note: We don't care about mock businesses here as they are static
|
const businessFound = await prisma.business.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id },
|
||||||
|
{ slug: id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!businessFound) {
|
||||||
|
// If business not found in DB (might be mock), just return success but no update
|
||||||
|
return NextResponse.json({ success: true, message: 'Mock business, skipping view count' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = businessFound.id;
|
||||||
|
|
||||||
|
// 2. Attempt to increment in DB
|
||||||
const business = await prisma.business.update({
|
const business = await prisma.business.update({
|
||||||
where: { id },
|
where: { id: businessId },
|
||||||
data: {
|
data: {
|
||||||
viewCount: {
|
viewCount: {
|
||||||
increment: 1,
|
increment: 1,
|
||||||
@@ -26,8 +43,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, viewCount: business.viewCount });
|
return NextResponse.json({ success: true, viewCount: business.viewCount });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If business not found in DB (might be mock), just return success but no update
|
|
||||||
console.error('Error incrementing view count:', error);
|
console.error('Error incrementing view count:', error);
|
||||||
return NextResponse.json({ success: false, error: 'Business not found or error' }, { status: 404 });
|
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/businesses/me — Fetch business for the current user
|
// GET /api/businesses/me — Fetch business for the current user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { MOCK_BUSINESSES } from '../../../lib/mockData'
|
|
||||||
|
|
||||||
// GET /api/businesses — List all businesses (Mock + DB)
|
// GET /api/businesses — List all businesses (Mock + DB)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -9,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const q = searchParams.get('q')?.toLowerCase()
|
const q = searchParams.get('q')?.toLowerCase()
|
||||||
const featured = searchParams.get('featured')
|
const featured = searchParams.get('featured')
|
||||||
|
const countryId = searchParams.get('countryId')
|
||||||
|
|
||||||
// 1. Fetch from Database
|
// 1. Fetch from Database
|
||||||
const where: any = {
|
const where: any = {
|
||||||
@@ -20,6 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
if (category && category !== 'All') where.category = category
|
if (category && category !== 'All') where.category = category
|
||||||
if (featured === 'true') where.isFeatured = true
|
if (featured === 'true') where.isFeatured = true
|
||||||
|
if (countryId) where.countryId = countryId
|
||||||
if (q) {
|
if (q) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
@@ -35,31 +36,8 @@ export async function GET(request: NextRequest) {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Filter Mock Businesses
|
// 2. Return DB results
|
||||||
const filteredMocks = MOCK_BUSINESSES.filter(biz => {
|
return NextResponse.json(dbBusinesses)
|
||||||
// Category filter
|
|
||||||
if (category && category !== 'All' && biz.category !== category) return false;
|
|
||||||
|
|
||||||
// Featured filter
|
|
||||||
if (featured === 'true' && !biz.isFeatured) return false;
|
|
||||||
|
|
||||||
// Search query filter
|
|
||||||
if (q) {
|
|
||||||
const matches =
|
|
||||||
biz.name.toLowerCase().includes(q) ||
|
|
||||||
biz.description.toLowerCase().includes(q) ||
|
|
||||||
biz.tags.some(t => t.toLowerCase().includes(q));
|
|
||||||
if (!matches) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Combine and return
|
|
||||||
// Prioritize DB entries over mocks to show user-created data first
|
|
||||||
const combined = [...dbBusinesses, ...filteredMocks];
|
|
||||||
|
|
||||||
return NextResponse.json(combined)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET /api/businesses error:', error)
|
console.error('GET /api/businesses error:', error)
|
||||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||||
@@ -84,6 +62,8 @@ export async function POST(request: NextRequest) {
|
|||||||
slug: data.slug || null,
|
slug: data.slug || null,
|
||||||
category: data.category || "Autre",
|
category: data.category || "Autre",
|
||||||
location: data.location || "",
|
location: data.location || "",
|
||||||
|
countryId: data.countryId || null,
|
||||||
|
city: data.city || "",
|
||||||
description: data.description || "",
|
description: data.description || "",
|
||||||
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
|
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
|
||||||
videoUrl: data.videoUrl || null,
|
videoUrl: data.videoUrl || null,
|
||||||
@@ -107,7 +87,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// 1b. Calculate isActive based on mandatory fields
|
// 1b. Calculate isActive based on mandatory fields
|
||||||
const isNameOk = cleanData.name && cleanData.name !== "Nouvelle Entreprise";
|
const isNameOk = cleanData.name && cleanData.name !== "Nouvelle Entreprise";
|
||||||
const isDescOk = cleanData.description && cleanData.description.length >= 20;
|
const isDescOk = cleanData.description && cleanData.description.length >= 20;
|
||||||
const isLocOk = cleanData.location && cleanData.location !== "Ma Ville";
|
const isLocOk = (cleanData.countryId && cleanData.city) || (cleanData.location && cleanData.location !== "Ma Ville");
|
||||||
const isEmailOk = !!cleanData.contactEmail;
|
const isEmailOk = !!cleanData.contactEmail;
|
||||||
const isPhoneOk = !!cleanData.contactPhone;
|
const isPhoneOk = !!cleanData.contactPhone;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/conversations — List all conversations for the user
|
// GET /api/conversations — List all conversations for the user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
15
app/api/countries/route.ts
Normal file
15
app/api/countries/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const countries = await prisma.country.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
return NextResponse.json(countries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/countries error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (action === 'description') {
|
if (action === 'description') {
|
||||||
const prompt = `
|
const prompt = `
|
||||||
Tu es un expert en copywriting marketing pour Afropreunariat.
|
Tu es un expert en copywriting marketing pour Afrohub.
|
||||||
Rédige une description professionnelle, attrayante et optimisée SEO pour une entreprise.
|
Rédige une description professionnelle, attrayante et optimisée SEO pour une entreprise.
|
||||||
|
|
||||||
Nom de l'entreprise : ${name}
|
Nom de l'entreprise : ${name}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/interviews
|
// GET /api/interviews
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// POST /api/messages — Send a message or start a conversation
|
// POST /api/messages — Send a message or start a conversation
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/offers — optionally filter by businessId
|
// GET /api/offers — optionally filter by businessId
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -26,6 +26,44 @@ export async function GET(request: NextRequest) {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
const { businessId } = body
|
||||||
|
|
||||||
|
if (!businessId) {
|
||||||
|
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch business and their plan's limit
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
include: {
|
||||||
|
_count: { select: { offers: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the specific limit for this tier from the database
|
||||||
|
const planDetails = await prisma.pricingPlan.findUnique({
|
||||||
|
where: { tier: business.plan }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!planDetails) {
|
||||||
|
return NextResponse.json({ error: 'Configuration du forfait introuvable' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enforce limits
|
||||||
|
const currentCount = business._count.offers
|
||||||
|
const offerLimit = planDetails.offerLimit
|
||||||
|
|
||||||
|
if (currentCount >= offerLimit) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `Limite d'offres atteinte (${offerLimit} max pour le plan ${planDetails.name}). Veuillez passer au plan supérieur pour publier plus d'offres.`
|
||||||
|
}, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create offer
|
||||||
const offer = await prisma.offer.create({
|
const offer = await prisma.offer.create({
|
||||||
data: body,
|
data: body,
|
||||||
include: { business: true },
|
include: { business: true },
|
||||||
|
|||||||
14
app/api/plans/route.ts
Normal file
14
app/api/plans/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const plans = await prisma.pricingPlan.findMany({
|
||||||
|
orderBy: { offerLimit: 'asc' }
|
||||||
|
});
|
||||||
|
return NextResponse.json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/plans error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur lors du chargement des plans' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/ratings/[id]/reply/route.ts
Normal file
62
app/api/ratings/[id]/reply/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id: ratingId } = await context.params;
|
||||||
|
const userId = req.headers.get('x-user-id');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { reply } = await req.json();
|
||||||
|
|
||||||
|
if (!reply || reply.trim() === '') {
|
||||||
|
return NextResponse.json({ error: 'La réponse ne peut pas être vide.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch the rating to identify the business
|
||||||
|
const rating = await prisma.rating.findUnique({
|
||||||
|
where: { id: ratingId },
|
||||||
|
include: {
|
||||||
|
business: {
|
||||||
|
select: {
|
||||||
|
ownerId: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rating) {
|
||||||
|
return NextResponse.json({ error: 'Avis introuvable.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Security: Only the owner of the business can reply
|
||||||
|
if (rating.business.ownerId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Action non autorisée. Seul l\'entrepreneur concerné peut répondre.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update the rating with the reply
|
||||||
|
const updatedRating = await prisma.rating.update({
|
||||||
|
where: { id: ratingId },
|
||||||
|
data: {
|
||||||
|
reply,
|
||||||
|
replyAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
reply: updatedRating.reply,
|
||||||
|
replyAt: updatedRating.replyAt
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error replying to rating:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur lors de la réponse.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// PATCH /api/users/me — Update personal user profile
|
// PATCH /api/users/me — Update personal user profile
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../../lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/users
|
// GET /api/users
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { prisma } from '../../lib/prisma';
|
import { prisma } from '../../lib/prisma';
|
||||||
import { MOCK_BLOG_POSTS } from '../../lib/mockData';
|
|
||||||
|
|
||||||
export const revalidate = 3600; // Revalidate every hour
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
@@ -14,11 +13,8 @@ export default async function BlogPage() {
|
|||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Database connection failed on blog page, using mock data.");
|
console.error("Database connection failed on blog page.");
|
||||||
posts = MOCK_BLOG_POSTS.map(p => ({
|
posts = [];
|
||||||
...p,
|
|
||||||
createdAt: new Date(p.date), // Map mock date to createdAt to satisfy frontend
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,75 +1,36 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { getLegalDocument } from '@/lib/legal';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Conditions Générales d\'Utilisation - Afropreunariat',
|
title: 'Conditions Générales d\'Utilisation - Afrohub',
|
||||||
description: 'Consultez les conditions générales d\'utilisation de la plateforme Afropreunariat.',
|
description: 'Consultez les conditions générales d\'utilisation de la plateforme Afrohub.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CGUPage() {
|
export default async function CGUPage() {
|
||||||
|
const doc = await getLegalDocument('CGU');
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen py-16 px-4 flex items-center justify-center">
|
||||||
|
<p className="text-gray-500">Document non disponible.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
|
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
|
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
|
||||||
Conditions Générales d'Utilisation (CGU)
|
{doc.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="prose prose-orange max-w-none text-gray-600 space-y-6">
|
<div
|
||||||
<p className="text-sm italic text-gray-500 mb-8">Dernière mise à jour : 12 avril 2026</p>
|
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: doc.content }}
|
||||||
|
/>
|
||||||
|
|
||||||
<section>
|
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">1. Présentation du site</h2>
|
Dernière mise à jour : {new Date(doc.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
<p>
|
|
||||||
En vertu de l'article 6 de la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique, il est précisé aux utilisateurs du site Afropreunariat l'identité des différents intervenants dans le cadre de sa réalisation et de son suivi.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">2. Conditions générales d’utilisation du site et des services proposés</h2>
|
|
||||||
<p>
|
|
||||||
L’utilisation du site Afropreunariat implique l’acceptation pleine et entière des conditions générales d’utilisation ci-après décrites. Ces conditions d’utilisation sont susceptibles d’être modifiées ou complétées à tout moment, les utilisateurs du site sont donc invités à les consulter de manière régulière.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">3. Description des services fournis</h2>
|
|
||||||
<p>
|
|
||||||
Le site Afropreunariat a pour objet de fournir une plateforme de mise en relation entre entrepreneurs africains et clients, ainsi qu'un annuaire d'entreprises. Afropreunariat s’efforce de fournir sur le site des informations aussi précises que possible. Toutefois, il ne pourra être tenue responsable des omissions, des inexactitudes et des carences dans la mise à jour, qu’elles soient de son fait ou du fait des tiers partenaires qui lui fournissent ces informations.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">4. Limitations contractuelles sur les données techniques</h2>
|
|
||||||
<p>
|
|
||||||
Le site utilise la technologie JavaScript (Next.js). Le site Internet ne pourra être tenu responsable de dommages matériels liés à l’utilisation du site. De plus, l’utilisateur du site s’engage à accéder au site en utilisant un matériel récent, ne contenant pas de virus et avec un navigateur de dernière génération mis à jour.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">5. Propriété intellectuelle et contrefaçons</h2>
|
|
||||||
<p>
|
|
||||||
Afropreunariat est propriétaire des droits de propriété intellectuelle ou détient les droits d’usage sur tous les éléments accessibles sur le site, notamment les textes, images, graphismes, logo, icônes, sons, logiciels. Toute reproduction, représentation, modification, publication, adaptation de tout ou partie des éléments du site, quel que soit le moyen ou le procédé utilisé, est interdite, sauf autorisation écrite préalable.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">6. Limitations de responsabilité</h2>
|
|
||||||
<p>
|
|
||||||
Afropreunariat ne pourra être tenue responsable des dommages directs et indirects causés au matériel de l’utilisateur, lors de l’accès au site Afropreunariat. Un espace interactif (possibilité de poser des questions dans l’espace contact) est à la disposition des utilisateurs. Afropreunariat se réserve le droit de supprimer, sans mise en demeure préalable, tout contenu déposé dans cet espace qui contreviendrait à la législation applicable en France (incluant RGPD).
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">7. Gestion des données personnelles</h2>
|
|
||||||
<p>
|
|
||||||
En France, les données personnelles sont notamment protégées par la loi n° 78-87 du 6 janvier 1978, la loi n° 2004-801 du 6 août 2004, l'article L. 226-13 du Code pénal et le Règlement Européen sur la Protection des Données (RGPD). À l'occasion de l'utilisation du site, peuvent être recueillies : l'URL des liens par l'intermédiaire desquels l'utilisateur a accédé au site, le fournisseur d'accès de l'utilisateur, l'adresse de protocole Internet (IP) de l'utilisateur.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 p-6 rounded-lg border border-gray-100 mt-12">
|
|
||||||
<p className="text-xs text-center text-gray-500 italic">
|
|
||||||
Note : Ce document est un modèle simplifié. Pour toute activité commerciale réelle, il est fortement recommandé de consulter un avocat pour adapter ce document à votre situation spécifique.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,75 +1,36 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { getLegalDocument } from '@/lib/legal';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Conditions Générales de Vente - Afropreunariat',
|
title: 'Conditions Générales de Vente - Afrohub',
|
||||||
description: 'Consultez les conditions générales de vente des services Afropreunariat.',
|
description: 'Consultez les conditions générales de vente des services Afrohub.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CGVPage() {
|
export default async function CGVPage() {
|
||||||
|
const doc = await getLegalDocument('CGV');
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen py-16 px-4 flex items-center justify-center">
|
||||||
|
<p className="text-gray-500">Document non disponible.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
|
<div className="bg-white min-h-screen py-16 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
|
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-8 border-b pb-4">
|
||||||
Conditions Générales de Vente (CGV)
|
{doc.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="prose prose-orange max-w-none text-gray-600 space-y-6">
|
<div
|
||||||
<p className="text-sm italic text-gray-500 mb-8">Dernière mise à jour : 12 avril 2026</p>
|
className="prose prose-orange max-w-none text-gray-600 space-y-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: doc.content }}
|
||||||
|
/>
|
||||||
|
|
||||||
<section>
|
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">1. Objet</h2>
|
Dernière mise à jour : {new Date(doc.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
<p>
|
|
||||||
Les présentes conditions générales de vente (CGV) régissent les relations contractuelles entre la plateforme Afropreunariat et tout utilisateur professionnel (l'Entrepreneur) souscrivant à des services payants sur le site.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">2. Description des services payants</h2>
|
|
||||||
<p>
|
|
||||||
Afropreunariat propose divers services payants destinés aux entrepreneurs, incluant mais ne se limitant pas à : l'inscription premium dans l'annuaire, la mise en avant de fiches entreprises, et des outils de communication avancés. Le contenu et les tarifs de ces services sont détaillés sur la page de tarification.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">3. Tarifs et paiement</h2>
|
|
||||||
<p>
|
|
||||||
Les prix de nos services sont indiqués en euros (ou monnaie locale) toutes taxes comprises. Afropreunariat se réserve le droit de modifier ses prix à tout moment, étant toutefois entendu que le prix figurant sur le site au jour de la commande sera le seul applicable à l'Entrepreneur. Le paiement est exigible immédiatement à la commande.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">4. Droit de rétractation</h2>
|
|
||||||
<p>
|
|
||||||
Conformément à l'article L221-28 du Code de la consommation, le droit de rétractation ne peut être exercé pour les services pleinement exécutés avant la fin du délai de rétractation ou dont l'exécution a commencé avec votre accord préalable exprès et renoncement exprès à votre droit de rétractation. Les abonnements numériques et services de mise en avant immédiate entrent dans ce cadre.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">5. Durée et résiliation</h2>
|
|
||||||
<p>
|
|
||||||
Les services de type "Abonnement" sont conclus pour la durée choisie lors de la souscription (mensuelle ou annuelle). Sauf mention contraire, ils sont reconduits tacitement. L'Entrepreneur peut résilier son abonnement à tout moment depuis son tableau de bord, la résiliation prenant effet à la fin de la période en cours.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">6. Responsabilité</h2>
|
|
||||||
<p>
|
|
||||||
La plateforme Afropreunariat n'est tenue que par une obligation de moyens. Elle ne saurait être tenue pour responsable des dommages résultant d'une mauvaise utilisation du service ou de l'absence de retours commerciaux suite à une mise en avant sur la plateforme.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-3">7. Règlement des litiges</h2>
|
|
||||||
<p>
|
|
||||||
Les présentes conditions de vente sont soumises à la loi française. En cas de litige, compétence est attribuée aux tribunaux compétents, nonobstant pluralité de défendeurs ou appel en garantie.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 p-6 rounded-lg border border-gray-100 mt-12">
|
|
||||||
<p className="text-xs text-center text-gray-500 italic">
|
|
||||||
Note : Ce document est un modèle simplifié. Pour toute activité commerciale réelle, il est fortement recommandé de consulter un avocat pour adapter ce document à votre situation spécifique.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Link from 'next/link';
|
|||||||
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert } from 'lucide-react';
|
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye, MessageSquare, User as UserIcon, Rocket, ShieldAlert } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import { MOCK_BUSINESSES } from '../../lib/mockData';
|
|
||||||
import DashboardOverview from '../../components/dashboard/DashboardOverview';
|
import DashboardOverview from '../../components/dashboard/DashboardOverview';
|
||||||
import DashboardProfile from '../../components/dashboard/DashboardProfile';
|
import DashboardProfile from '../../components/dashboard/DashboardProfile';
|
||||||
import DashboardProfileClient from '../../components/dashboard/DashboardProfileClient';
|
import DashboardProfileClient from '../../components/dashboard/DashboardProfileClient';
|
||||||
@@ -23,7 +22,7 @@ const DashboardContent = () => {
|
|||||||
|
|
||||||
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile'>('overview');
|
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription' | 'messages' | 'personal-profile'>('overview');
|
||||||
const [business, setBusiness] = useState<any>(null);
|
const [business, setBusiness] = useState<any>(null);
|
||||||
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number } | null>(null);
|
const [stats, setStats] = useState<{ totalViews: number, contactClicks: number, dailyStats: any[] } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
@@ -113,9 +112,9 @@ const DashboardContent = () => {
|
|||||||
// Update document title with unread count
|
// Update document title with unread count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (unreadCount > 0) {
|
if (unreadCount > 0) {
|
||||||
document.title = `(${unreadCount}) Messages - Afropreunariat`;
|
document.title = `(${unreadCount}) Messages - Afrohub`;
|
||||||
} else {
|
} else {
|
||||||
document.title = 'Tableau de Bord - Afropreunariat';
|
document.title = 'Tableau de Bord - Afrohub';
|
||||||
}
|
}
|
||||||
}, [unreadCount]);
|
}, [unreadCount]);
|
||||||
|
|
||||||
@@ -153,6 +152,10 @@ const DashboardContent = () => {
|
|||||||
slug: "",
|
slug: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
socialLinks: {},
|
socialLinks: {},
|
||||||
|
showEmail: true,
|
||||||
|
showPhone: true,
|
||||||
|
showSocials: true,
|
||||||
|
plan: 'STARTER',
|
||||||
verified: false,
|
verified: false,
|
||||||
viewCount: 0,
|
viewCount: 0,
|
||||||
rating: 0,
|
rating: 0,
|
||||||
@@ -166,7 +169,7 @@ const DashboardContent = () => {
|
|||||||
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
|
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
|
||||||
<span className="font-serif font-bold text-lg text-gray-900">Afropreunariat</span>
|
<span className="font-serif font-bold text-lg text-gray-900">Afrohub</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">
|
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">
|
||||||
@@ -297,7 +300,7 @@ const DashboardContent = () => {
|
|||||||
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
|
{currentView === 'messages' && <DashboardMessages onMessagesRead={fetchUnreadCount} initialConversationId={chatId} />}
|
||||||
{currentView === 'personal-profile' && <DashboardProfileClient />}
|
{currentView === 'personal-profile' && <DashboardProfileClient />}
|
||||||
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
|
{currentView === 'profile' && <DashboardProfile business={displayBusiness} setBusiness={setBusiness} />}
|
||||||
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} />}
|
{currentView === 'offers' && <DashboardOffers businessId={displayBusiness.id} plan={displayBusiness.plan} />}
|
||||||
{currentView === 'subscription' && (
|
{currentView === 'subscription' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white shadow rounded-lg p-6 flex justify-between items-center">
|
<div className="bg-white shadow rounded-lg p-6 flex justify-between items-center">
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { Toaster } from 'react-hot-toast';
|
|||||||
import { getSiteSettings } from '../lib/settings';
|
import { getSiteSettings } from '../lib/settings';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Afropreunariat - L\'Annuaire 2.0',
|
title: 'Afrohub - L\'Annuaire 2.0',
|
||||||
description: 'Annuaire Afropreunariat',
|
description: 'Annuaire Afrohub',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -78,12 +78,16 @@ const HomePage = () => {
|
|||||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 font-serif">Secteurs en vedette</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-8 font-serif">Secteurs en vedette</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{CATEGORIES.slice(0, 4).map((cat, idx) => (
|
{CATEGORIES.slice(0, 4).map((cat, idx) => (
|
||||||
<div key={idx} className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center">
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={`/annuaire?category=${encodeURIComponent(cat)}`}
|
||||||
|
className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center"
|
||||||
|
>
|
||||||
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
|
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
|
||||||
<Briefcase className="w-6 h-6" />
|
<Briefcase className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-gray-900">{cat}</h3>
|
<h3 className="font-semibold text-gray-900">{cat}</h3>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ const RegisterPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
|
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
|
||||||
Afropreunariat © 2026
|
Afrohub © 2026
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function SuspendedPage() {
|
|||||||
<Mail className="w-5 h-5 text-indigo-600" />
|
<Mail className="w-5 h-5 text-indigo-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold text-indigo-900 uppercase">Support Direct</p>
|
<p className="text-xs font-bold text-indigo-900 uppercase">Support Direct</p>
|
||||||
<p className="text-sm text-indigo-700">moderation@afropreunariat.com</p>
|
<p className="text-sm text-indigo-700">moderation@afrohub.com</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,12 +31,24 @@ export default function AnalyticsTracker() {
|
|||||||
|
|
||||||
// 1. Track Page Views
|
// 1. Track Page Views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Attempt to get userId from localStorage (common in this app's auth setup)
|
||||||
|
let userId = null;
|
||||||
|
try {
|
||||||
|
const userStr = localStorage.getItem('user');
|
||||||
|
if (userStr) {
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
userId = user.id;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: 'PAGE_VIEW',
|
type: 'PAGE_VIEW',
|
||||||
path: pathname,
|
path: pathname,
|
||||||
metadata: {
|
metadata: {
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
screen: `${window.innerWidth}x${window.innerHeight}`,
|
screen: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
referrer: document.referrer,
|
||||||
|
userId: userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
|
|||||||
|
|
||||||
const handleShare = (platform: string) => {
|
const handleShare = (platform: string) => {
|
||||||
const url = encodeURIComponent(`${window.location.origin}/annuaire/${business.id}`);
|
const url = encodeURIComponent(`${window.location.origin}/annuaire/${business.id}`);
|
||||||
const text = encodeURIComponent(`Découvrez ${business.name} sur Afropreunariat`);
|
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
|
||||||
|
|
||||||
let shareLink = '';
|
let shareLink = '';
|
||||||
switch(platform) {
|
switch(platform) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const CookieBanner = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold mb-1">Nous respectons votre vie privée</h3>
|
<h3 className="text-lg font-bold mb-1">Nous respectons votre vie privée</h3>
|
||||||
<p className="text-sm text-gray-400 leading-relaxed">
|
<p className="text-sm text-gray-400 leading-relaxed">
|
||||||
Afropreunariat utilise des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu.
|
Afrohub utilise des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu.
|
||||||
Certains sont essentiels, d'autres nous aident à grandir avec vous.
|
Certains sont essentiels, d'autres nous aident à grandir avec vous.
|
||||||
Consultez nos <Link href="/cgu" className="text-brand-500 hover:underline">CGU</Link> pour en savoir plus.
|
Consultez nos <Link href="/cgu" className="text-brand-500 hover:underline">CGU</Link> pour en savoir plus.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Facebook, Twitter, Instagram, Linkedin } from 'lucide-react';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
settings?: any;
|
settings?: any;
|
||||||
@@ -14,23 +15,52 @@ const Footer = ({ settings }: FooterProps) => {
|
|||||||
if (pathname.startsWith('/dashboard')) return null;
|
if (pathname.startsWith('/dashboard')) return null;
|
||||||
|
|
||||||
const siteInfo = settings || {
|
const siteInfo = settings || {
|
||||||
siteName: "Afropreunariat",
|
siteName: "Afrohub",
|
||||||
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
||||||
contactEmail: "support@afropreunariat.com",
|
contactEmail: "support@afrohub.com",
|
||||||
contactPhone: "+225 00 00 00 00 00",
|
|
||||||
address: "Abidjan, Côte d'Ivoire / Paris, France",
|
address: "Abidjan, Côte d'Ivoire / Paris, France",
|
||||||
footerText: "© 2025 Afropreunariat. Tous droits réservés."
|
footerText: "© 2025 Afrohub. Tous droits réservés.",
|
||||||
|
facebookUrl: "#",
|
||||||
|
twitterUrl: "#",
|
||||||
|
instagramUrl: "#",
|
||||||
|
linkedinUrl: "#"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ icon: Facebook, url: siteInfo.facebookUrl, label: "Facebook" },
|
||||||
|
{ icon: Twitter, url: siteInfo.twitterUrl, label: "Twitter" },
|
||||||
|
{ icon: Instagram, url: siteInfo.instagramUrl, label: "Instagram" },
|
||||||
|
{ icon: Linkedin, url: siteInfo.linkedinUrl, label: "LinkedIn" },
|
||||||
|
].filter(link => link.url && link.url.trim() !== "" && link.url !== "#");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-dark-900 text-white">
|
<footer className="bg-dark-900 text-white border-t border-gray-800">
|
||||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8 text-center sm:text-left">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-serif font-bold text-brand-500 mb-4">{siteInfo.siteName}</h3>
|
<h3 className="text-2xl font-serif font-bold text-brand-500 mb-4 tracking-tight uppercase">{siteInfo.siteName}</h3>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm mb-6 max-w-sm">
|
||||||
{siteInfo.siteSlogan}
|
{siteInfo.siteSlogan}
|
||||||
</p>
|
</p>
|
||||||
|
{socialLinks.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-bold text-xs uppercase tracking-widest text-gray-500">Suivez-nous</h4>
|
||||||
|
<div className="flex space-x-4 justify-center sm:justify-start">
|
||||||
|
{socialLinks.map((social, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={social.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-full border border-gray-800 text-gray-400 hover:text-brand-500 hover:border-brand-500/50 hover:bg-white/5 transition-all"
|
||||||
|
aria-label={social.label}
|
||||||
|
>
|
||||||
|
<social.icon size={18} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-4">Liens Rapides</h4>
|
<h4 className="font-bold text-lg mb-4">Liens Rapides</h4>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Navbar = () => {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link href="/" className="flex-shrink-0 flex items-center gap-2">
|
<Link href="/" className="flex-shrink-0 flex items-center gap-2">
|
||||||
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
|
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
|
||||||
<span className="font-serif font-bold text-xl text-gray-900">Afropreunariat</span>
|
<span className="font-serif font-bold text-xl text-gray-900">Afrohub</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
||||||
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||||
|
|||||||
@@ -1,80 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Check, X, Smartphone, CreditCard, Loader, ShieldCheck } from 'lucide-react';
|
import { Check, X, Smartphone, CreditCard, Loader, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: string;
|
id: string;
|
||||||
|
tier: string;
|
||||||
name: string;
|
name: string;
|
||||||
priceXOF: string;
|
priceXOF: string;
|
||||||
priceEUR: string;
|
priceEUR: string;
|
||||||
description: string;
|
description: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
recommended?: boolean;
|
recommended: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
|
offerLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLANS: Plan[] = [
|
|
||||||
{
|
|
||||||
id: 'starter',
|
|
||||||
name: 'Starter',
|
|
||||||
priceXOF: 'Gratuit',
|
|
||||||
priceEUR: '0€',
|
|
||||||
description: 'Pour démarrer votre présence en ligne.',
|
|
||||||
features: [
|
|
||||||
'Fiche entreprise basique',
|
|
||||||
'Visible dans la recherche',
|
|
||||||
'1 Offre produit/service',
|
|
||||||
'Support par email'
|
|
||||||
],
|
|
||||||
color: 'gray'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'booster',
|
|
||||||
name: 'Booster',
|
|
||||||
priceXOF: '5.000 FCFA',
|
|
||||||
priceEUR: '8€',
|
|
||||||
description: 'L\'indispensable pour les entreprises en croissance.',
|
|
||||||
recommended: true,
|
|
||||||
features: [
|
|
||||||
'Tout du plan Starter',
|
|
||||||
'Badge "Vérifié" ✅',
|
|
||||||
'Jusqu\'à 10 Offres produits',
|
|
||||||
'Lien vers réseaux sociaux & Site Web',
|
|
||||||
'Statistiques de base (Vues)'
|
|
||||||
],
|
|
||||||
color: 'brand'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'empire',
|
|
||||||
name: 'Empire',
|
|
||||||
priceXOF: '15.000 FCFA',
|
|
||||||
priceEUR: '23€',
|
|
||||||
description: 'Dominez votre marché avec une visibilité maximale.',
|
|
||||||
features: [
|
|
||||||
'Tout du plan Booster',
|
|
||||||
'Badge "Recommandé" 🏆',
|
|
||||||
'Offres illimitées',
|
|
||||||
'Intégration vidéo Youtube',
|
|
||||||
'Interview écrite sur le Blog',
|
|
||||||
'Support prioritaire WhatsApp'
|
|
||||||
],
|
|
||||||
color: 'gray'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const PricingSection = () => {
|
const PricingSection = () => {
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/plans');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) {
|
||||||
|
setPlans(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching plans:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingPlans(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Payment State
|
// Payment State
|
||||||
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
|
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
|
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
|
||||||
|
|
||||||
const handleSelectPlan = (plan: Plan) => {
|
const handleSelectPlan = (plan: Plan) => {
|
||||||
if (plan.id === 'starter') return; // Free plan, no payment
|
if (plan.tier === 'STARTER') return; // Free plan, no payment
|
||||||
setSelectedPlan(plan);
|
setSelectedPlan(plan);
|
||||||
setPaymentStep('method');
|
setPaymentStep('method');
|
||||||
setIsPaymentModalOpen(true);
|
setIsPaymentModalOpen(true);
|
||||||
@@ -130,7 +102,21 @@ const PricingSection = () => {
|
|||||||
|
|
||||||
{/* Plans Grid */}
|
{/* Plans Grid */}
|
||||||
<div className="mt-12 space-y-4 sm:mt-16 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0 xl:grid-cols-3">
|
<div className="mt-12 space-y-4 sm:mt-16 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0 xl:grid-cols-3">
|
||||||
{PLANS.map((plan) => (
|
{loadingPlans ? (
|
||||||
|
[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="rounded-2xl shadow-xl bg-white border border-gray-100 p-8 h-96 animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4 mb-10"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded mb-4"></div>
|
||||||
|
<div className="space-y-3 mt-10">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
plans.map((plan) => (
|
||||||
<div key={plan.id} className={`rounded-2xl shadow-xl bg-white border-2 flex flex-col ${plan.recommended ? 'border-brand-500 ring-4 ring-brand-50 relative transform scale-105 z-10' : 'border-gray-100'}`}>
|
<div key={plan.id} className={`rounded-2xl shadow-xl bg-white border-2 flex flex-col ${plan.recommended ? 'border-brand-500 ring-4 ring-brand-50 relative transform scale-105 z-10' : 'border-gray-100'}`}>
|
||||||
{plan.recommended && (
|
{plan.recommended && (
|
||||||
<div className="absolute top-0 inset-x-0 -mt-4 flex justify-center">
|
<div className="absolute top-0 inset-x-0 -mt-4 flex justify-center">
|
||||||
@@ -144,7 +130,7 @@ const PricingSection = () => {
|
|||||||
<p className="mt-4 text-sm text-gray-500">{plan.description}</p>
|
<p className="mt-4 text-sm text-gray-500">{plan.description}</p>
|
||||||
<div className="mt-8 flex items-baseline">
|
<div className="mt-8 flex items-baseline">
|
||||||
<span className="text-4xl font-extrabold text-gray-900 tracking-tight">
|
<span className="text-4xl font-extrabold text-gray-900 tracking-tight">
|
||||||
{plan.priceXOF === 'Gratuit' ? 'Gratuit' : (billingCycle === 'yearly' && plan.id !== 'starter' ? 'Sur Devis' : plan.priceXOF)}
|
{plan.priceXOF === 'Gratuit' ? 'Gratuit' : (billingCycle === 'yearly' && plan.tier !== 'STARTER' ? 'Sur Devis' : plan.priceXOF)}
|
||||||
</span>
|
</span>
|
||||||
{plan.priceXOF !== 'Gratuit' && <span className="ml-1 text-xl font-medium text-gray-500">/mois</span>}
|
{plan.priceXOF !== 'Gratuit' && <span className="ml-1 text-xl font-medium text-gray-500">/mois</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,16 +153,17 @@ const PricingSection = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectPlan(plan)}
|
onClick={() => handleSelectPlan(plan)}
|
||||||
className={`w-full block text-center rounded-lg border border-transparent px-6 py-3 text-base font-medium transition-colors ${
|
className={`w-full block text-center rounded-lg border border-transparent px-6 py-3 text-base font-medium transition-colors ${
|
||||||
plan.id === 'starter'
|
plan.tier === 'STARTER'
|
||||||
? 'text-brand-700 bg-brand-100 hover:bg-brand-200'
|
? 'text-brand-700 bg-brand-100 hover:bg-brand-200'
|
||||||
: 'text-white bg-brand-600 hover:bg-brand-700 shadow-md hover:shadow-lg'
|
: 'text-white bg-brand-600 hover:bg-brand-700 shadow-md hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.id === 'starter' ? 'Commencer Gratuitement' : `Choisir ${plan.name}`}
|
{plan.tier === 'STARTER' ? 'Commencer Gratuitement' : `Choisir ${plan.name}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2 } from 'lucide-react';
|
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { Offer, OfferType } from '../../types';
|
import { Offer, OfferType } from '../../types';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
interface DashboardOffersProps {
|
interface DashboardOffersProps {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
|
plan?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
|
const DashboardOffers = ({ businessId, plan = 'STARTER' }: DashboardOffersProps) => {
|
||||||
const [offers, setOffers] = useState<Offer[]>([]);
|
const [offers, setOffers] = useState<Offer[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -155,19 +156,42 @@ const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offerLimit = plan === 'BOOSTER' ? 10 : (plan === 'EMPIRE' ? 1000 : 1);
|
||||||
|
const isLimitReached = offers.length >= offerLimit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col">
|
||||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
|
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Utilisation : <span className="font-bold">{offers.length} / {offerLimit === 1000 ? 'Illimité' : offerLimit}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openAddModal}
|
onClick={openAddModal}
|
||||||
className="flex items-center bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm transition-colors active:scale-95"
|
disabled={isLimitReached}
|
||||||
|
className={`flex items-center px-4 py-2 rounded-md font-medium text-sm shadow-sm transition-colors active:scale-95 ${isLimitReached ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-brand-600 text-white hover:bg-brand-700'}`}
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4 mr-2" />
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
Ajouter une offre
|
Ajouter une offre
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLimitReached && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
Vous avez atteint la limite de votre plan <strong>{plan}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="text-xs font-bold text-brand-600 hover:text-brand-700 uppercase tracking-tight">
|
||||||
|
Passer au plan supérieur →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* List View */}
|
{/* List View */}
|
||||||
{offers.length > 0 ? (
|
{offers.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
|||||||
@@ -3,61 +3,100 @@ import React from 'react';
|
|||||||
import { Eye, Star } from 'lucide-react';
|
import { Eye, Star } from 'lucide-react';
|
||||||
import { Business } from '../../types';
|
import { Business } from '../../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
const MousePointerClick = ({className}: {className?:string}) => (
|
const MousePointerClick = ({className}: {className?:string}) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||||
<path d="M14 4.1 12 6" /><path d="m5.1 8-2.9-.8" /><path d="m6 12-1.9 2" /><path d="M7.2 2.2 8 5.1" /><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
|
<path d="M14 4.1 12 6" /><path d="m5.1 8-2.9-.8" /><path d="m6 12-1.9 2" /><path d="M7.2 2.2 8 5.1" /><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DashboardOverview = ({ business, stats }: { business: Business, stats: { totalViews: number, contactClicks: number } | null }) => (
|
interface DayStat {
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
clicks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, color }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 shadow-xl border border-gray-100 rounded-lg">
|
||||||
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">{label}</p>
|
||||||
|
<p className="text-lg font-bold" style={{ color }}>
|
||||||
|
{payload[0].value} {payload[0].name === 'views' ? 'Vues' : 'Clics'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardOverview = ({ business, stats }: {
|
||||||
|
business: Business,
|
||||||
|
stats: {
|
||||||
|
totalViews: number,
|
||||||
|
contactClicks: number,
|
||||||
|
dailyStats: DayStat[]
|
||||||
|
} | null
|
||||||
|
}) => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Tableau de bord</h2>
|
<h2 className="text-2xl font-bold font-serif text-gray-900">Tableau de bord</h2>
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 bg-brand-100 rounded-md p-3">
|
<div className="flex-shrink-0 bg-brand-50 rounded-lg p-3">
|
||||||
<Eye className="h-6 w-6 text-brand-600" />
|
<Eye className="h-6 w-6 text-brand-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Vues de la fiche</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">Vues de la fiche</dt>
|
||||||
<dd>
|
<dd className="flex items-baseline">
|
||||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
|
<div className="text-2xl font-bold text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 bg-blue-100 rounded-md p-3">
|
<div className="flex-shrink-0 bg-blue-50 rounded-lg p-3">
|
||||||
<MousePointerClick className="h-6 w-6 text-blue-600" />
|
<MousePointerClick className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Clics Contact</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">Clics Contact</dt>
|
||||||
<dd>
|
<dd className="flex items-baseline">
|
||||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.contactClicks : '...'}</div>
|
<div className="text-2xl font-bold text-gray-900">{stats ? stats.contactClicks : '...'}</div>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 bg-yellow-100 rounded-md p-3">
|
<div className="flex-shrink-0 bg-yellow-50 rounded-lg p-3">
|
||||||
<Star className="h-6 w-6 text-yellow-600" />
|
<Star className="h-6 w-6 text-yellow-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Note moyenne</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">Note moyenne</dt>
|
||||||
<dd>
|
<dd className="flex items-baseline">
|
||||||
<div className="text-lg font-medium text-gray-900">{business.rating}/5</div>
|
<div className="text-2xl font-bold text-gray-900">{business.rating}/5</div>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,10 +105,89 @@ const DashboardOverview = ({ business, stats }: { business: Business, stats: { t
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Performances 30 derniers jours</h3>
|
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
|
||||||
<div className="h-64 bg-gray-50 rounded border border-dashed border-gray-200 flex items-center justify-center text-gray-400">
|
<div className="flex items-center justify-between mb-6">
|
||||||
Graphique des visites (À venir en Phase 2)
|
<h3 className="text-lg font-bold text-gray-900">Historique des Visites</h3>
|
||||||
|
<div className="px-2 py-1 bg-brand-50 text-brand-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={stats?.dailyStats || []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorViews" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#d97706" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#d97706" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||||
|
interval={6}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip color="#d97706" />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="views"
|
||||||
|
name="views"
|
||||||
|
stroke="#d97706"
|
||||||
|
strokeWidth={3}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorViews)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Interactions Contact</h3>
|
||||||
|
<div className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={stats?.dailyStats || []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||||
|
interval={6}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip color="#2563eb" />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="clicks"
|
||||||
|
name="clicks"
|
||||||
|
stroke="#2563eb"
|
||||||
|
strokeWidth={3}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorClicks)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import { Business, CATEGORIES } from '../../types';
|
import { Business, CATEGORIES, Country } from '../../types';
|
||||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useUser } from '../UserProvider';
|
import { useUser } from '../UserProvider';
|
||||||
@@ -15,17 +15,56 @@ const getYouTubeId = (url: string) => {
|
|||||||
return (match && match[2].length === 11) ? match[2] : null;
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeBusinessData = (b: Business): Business => ({
|
||||||
|
...b,
|
||||||
|
name: b.name || 'Nouvelle Entreprise',
|
||||||
|
category: b.category || CATEGORIES[0],
|
||||||
|
description: b.description || '',
|
||||||
|
location: b.location || '',
|
||||||
|
countryId: b.countryId || '',
|
||||||
|
city: b.city || '',
|
||||||
|
contactEmail: b.contactEmail || '',
|
||||||
|
showEmail: b.showEmail ?? true,
|
||||||
|
showPhone: b.showPhone ?? true,
|
||||||
|
showSocials: b.showSocials ?? true,
|
||||||
|
slug: b.slug || '',
|
||||||
|
videoUrl: b.videoUrl || '',
|
||||||
|
contactPhone: b.contactPhone || '',
|
||||||
|
websiteUrl: b.websiteUrl || '',
|
||||||
|
socialLinks: {
|
||||||
|
facebook: b.socialLinks?.facebook || '',
|
||||||
|
linkedin: b.socialLinks?.linkedin || '',
|
||||||
|
instagram: b.socialLinks?.instagram || '',
|
||||||
|
website: b.socialLinks?.website || '',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
|
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
|
||||||
const { login } = useUser();
|
const { login } = useUser();
|
||||||
const [formData, setFormData] = useState(business);
|
const [formData, setFormData] = useState<Business>(normalizeBusinessData(business));
|
||||||
|
const [countries, setCountries] = useState<Country[]>([]);
|
||||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// Fetch countries
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchCountries = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/countries');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error) setCountries(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching countries:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCountries();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync formData when business prop changes (initial fetch)
|
// Sync formData when business prop changes (initial fetch)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setFormData(business);
|
setFormData(normalizeBusinessData(business));
|
||||||
setVideoInput(business.videoUrl || '');
|
setVideoInput(business.videoUrl || '');
|
||||||
setVideoPreviewId(getYouTubeId(business.videoUrl || ''));
|
setVideoPreviewId(getYouTubeId(business.videoUrl || ''));
|
||||||
}, [business]);
|
}, [business]);
|
||||||
@@ -91,6 +130,38 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm("⚠️ ATTENTION : Êtes-vous sûr de vouloir supprimer définitivement votre boutique ? Cette action supprimera également vos offres, vos messages et vos statistiques. Cette action est irréversible.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/businesses/${business.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'x-user-id': business.ownerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
toast.success('Boutique supprimée avec succès.');
|
||||||
|
// Upgrade local storage user if role changed
|
||||||
|
const localUser = JSON.parse(localStorage.getItem('afro_user') || '{}');
|
||||||
|
if (localUser.id === business.ownerId) {
|
||||||
|
localUser.role = 'VISITOR';
|
||||||
|
localStorage.setItem('afro_user', JSON.stringify(localUser));
|
||||||
|
if (login) login(localUser);
|
||||||
|
}
|
||||||
|
setTimeout(() => window.location.href = '/dashboard', 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || 'Erreur lors de la suppression');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error);
|
||||||
|
toast.error('Erreur réseau lors de la suppression');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
|
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
|
||||||
const isDescOk = formData.description && formData.description.length >= 20;
|
const isDescOk = formData.description && formData.description.length >= 20;
|
||||||
const isLocOk = formData.location && formData.location !== "Ma Ville";
|
const isLocOk = formData.location && formData.location !== "Ma Ville";
|
||||||
@@ -254,17 +325,35 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Globe className="w-5 h-5 mr-2 text-blue-500" /> Coordonnées & Réseaux</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Globe className="w-5 h-5 mr-2 text-blue-500" /> Coordonnées & Réseaux</h3>
|
||||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||||
<div className="sm:col-span-6">
|
<div className="sm:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Localisation (Ville, Pays)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Pays</label>
|
||||||
|
<select
|
||||||
|
name="countryId"
|
||||||
|
value={formData.countryId || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un pays</option>
|
||||||
|
{countries.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.flag} {c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ville</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="location"
|
name="city"
|
||||||
value={formData.location}
|
value={formData.city || ''}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
placeholder="Ex: Abidjan"
|
||||||
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sm:col-span-6 hidden">
|
||||||
|
{/* Keep legacy field for compatibility if needed, but hide it */}
|
||||||
|
<input type="hidden" name="location" value={`${formData.city || ''}, ${countries.find(c => c.id === formData.countryId)?.name || ''}`} />
|
||||||
|
</div>
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
||||||
@@ -335,6 +424,29 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* D. Zone de Danger */}
|
||||||
|
<div className="bg-red-50 border border-red-200 shadow-sm rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-red-800">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<h3 className="text-lg font-bold">Zone de Danger</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-red-900">Supprimer définitivement la boutique</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1">
|
||||||
|
Une fois supprimée, votre boutique ne sera plus visible dans l'annuaire.
|
||||||
|
Toutes vos données (offres, messages, avis) seront perdues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 font-bold text-sm shadow-sm transition-all active:scale-95 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Supprimer ma boutique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
container_name: afropreunariat_db
|
container_name: afrohub_db
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: admin
|
POSTGRES_USER: admin
|
||||||
POSTGRES_PASSWORD: adminpassword
|
POSTGRES_PASSWORD: adminpassword
|
||||||
POSTGRES_DB: afropreunariat_dev
|
POSTGRES_DB: afrohub_dev
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U admin -d afropreunariat_dev"]
|
test: ["CMD-SHELL", "pg_isready -U admin -d afrohub_dev"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: afropreunariat_app
|
container_name: afrohub_app
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./admin
|
context: ./admin
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: afropreunariat_admin
|
container_name: afrohub_admin
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|||||||
44
lib/geo.ts
Normal file
44
lib/geo.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export async function getGeoInfo(ip: string, headers: Headers) {
|
||||||
|
// 1. Try platform-specific headers first (Vercel, Cloudflare, etc.)
|
||||||
|
const headerCountry =
|
||||||
|
headers.get('x-vercel-ip-country') ||
|
||||||
|
headers.get('cf-ipcountry') ||
|
||||||
|
headers.get('x-country-code');
|
||||||
|
|
||||||
|
if (headerCountry && headerCountry !== 'XX') {
|
||||||
|
// If we have a country code, we might still want to get the name
|
||||||
|
// For now, let's keep it simple or use a lookup map
|
||||||
|
return {
|
||||||
|
country: headerCountry,
|
||||||
|
city: headers.get('x-vercel-ip-city') || 'Unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle localhost
|
||||||
|
if (ip === '127.0.0.1' || ip === '::1' || ip.includes('127.0.0.1')) {
|
||||||
|
return { country: 'Local / Dev', city: 'Localhost' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to external API
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
||||||
|
|
||||||
|
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'success') {
|
||||||
|
return {
|
||||||
|
country: data.country || 'Unknown',
|
||||||
|
city: data.city || 'Unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GeoIP lookup error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { country: 'Unknown', city: 'Unknown' };
|
||||||
|
}
|
||||||
12
lib/legal.ts
Normal file
12
lib/legal.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,15 @@ import { prisma } from './prisma';
|
|||||||
export async function getSiteSettings() {
|
export async function getSiteSettings() {
|
||||||
// Default fallback values used during build or if DB is down
|
// Default fallback values used during build or if DB is down
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
siteName: "Afropreunariat",
|
siteName: "Afrohub",
|
||||||
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
||||||
contactEmail: "support@afropreunariat.com",
|
contactEmail: "support@afrohub.com",
|
||||||
contactPhone: "+225 00 00 00 00 00",
|
|
||||||
address: "Abidjan, Côte d'Ivoire / Paris, France",
|
address: "Abidjan, Côte d'Ivoire / Paris, France",
|
||||||
facebookUrl: "#",
|
facebookUrl: "#",
|
||||||
twitterUrl: "#",
|
twitterUrl: "#",
|
||||||
instagramUrl: "#",
|
instagramUrl: "#",
|
||||||
linkedinUrl: "#",
|
linkedinUrl: "#",
|
||||||
footerText: "© 2025 Afropreunariat. Tous droits réservés."
|
footerText: "© 2025 Afrohub. Tous droits réservés."
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
524
package-lock.json
generated
524
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "afropreunariat",
|
"name": "afrohub",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -18,17 +18,18 @@
|
|||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
"@prisma/adapter-pg": "^7.4.1",
|
"@prisma/adapter-pg": "^7.4.1",
|
||||||
"@prisma/client": "^7.4.1",
|
"@prisma/client": "^7.4.1",
|
||||||
|
"@prisma/config": "^7.4.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
|
"prisma": "^7.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"@prisma/config": "^7.4.1",
|
"recharts": "^3.8.1",
|
||||||
"prisma": "^7.4.1",
|
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
// Load .env then override with .env.local for Next.js compatibility
|
||||||
|
config();
|
||||||
|
config({ path: '.env.local', override: true });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma 7 Configuration
|
* Prisma 7 Configuration
|
||||||
* In standalone Docker mode, devDependencies are missing.
|
|
||||||
* This file uses a plain object to avoid 'Cannot find module' errors.
|
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
datasource: {
|
datasource: {
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ model User {
|
|||||||
phone String?
|
phone String?
|
||||||
isSuspended Boolean @default(false)
|
isSuspended Boolean @default(false)
|
||||||
suspensionReason String?
|
suspensionReason String?
|
||||||
|
countryId String?
|
||||||
businesses Business?
|
businesses Business?
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations ConversationParticipant[]
|
conversations ConversationParticipant[]
|
||||||
sentMessages Message[]
|
sentMessages Message[]
|
||||||
reports MessageReport[]
|
reports MessageReport[]
|
||||||
|
ratings Rating[]
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
@@ -42,6 +45,7 @@ model Business {
|
|||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
viewCount Int @default(0)
|
viewCount Int @default(0)
|
||||||
rating Float @default(0.0)
|
rating Float @default(0.0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
tags String[]
|
tags String[]
|
||||||
isFeatured Boolean @default(false)
|
isFeatured Boolean @default(false)
|
||||||
founderName String?
|
founderName String?
|
||||||
@@ -58,10 +62,41 @@ model Business {
|
|||||||
websiteUrl String?
|
websiteUrl String?
|
||||||
isSuspended Boolean @default(false)
|
isSuspended Boolean @default(false)
|
||||||
suspensionReason String?
|
suspensionReason String?
|
||||||
|
plan Plan @default(STARTER)
|
||||||
|
city String?
|
||||||
|
countryId String?
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations Conversation[]
|
conversations Conversation[]
|
||||||
offers Offer[]
|
offers Offer[]
|
||||||
|
ratings Rating[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Country {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
code String @unique
|
||||||
|
flag String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
businesses Business[]
|
||||||
|
users User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
model Offer {
|
||||||
@@ -127,6 +162,31 @@ model AnalyticsEvent {
|
|||||||
value Float?
|
value Float?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
browser String?
|
||||||
|
city String?
|
||||||
|
country String?
|
||||||
|
device String?
|
||||||
|
ip String?
|
||||||
|
os String?
|
||||||
|
referrer String?
|
||||||
|
userId String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Rating {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
value Int
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
comment String?
|
||||||
|
reply String?
|
||||||
|
replyAt DateTime?
|
||||||
|
status RatingStatus @default(PENDING)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Conversation {
|
model Conversation {
|
||||||
@@ -173,6 +233,33 @@ model MessageReport {
|
|||||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Plan {
|
||||||
|
STARTER
|
||||||
|
BOOSTER
|
||||||
|
EMPIRE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RatingStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
enum ReportStatus {
|
enum ReportStatus {
|
||||||
PENDING
|
PENDING
|
||||||
RESOLVED
|
RESOLVED
|
||||||
@@ -194,18 +281,10 @@ enum InterviewType {
|
|||||||
VIDEO
|
VIDEO
|
||||||
ARTICLE
|
ARTICLE
|
||||||
}
|
}
|
||||||
|
model LegalDocument {
|
||||||
model SiteSetting {
|
id String @id @default(uuid())
|
||||||
id String @id @default("singleton")
|
type String @unique // 'CGU' or 'CGV'
|
||||||
siteName String @default("Afropreunariat")
|
title String
|
||||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
content String @db.Text
|
||||||
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
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
123
prisma/seed-30.ts
Normal file
123
prisma/seed-30.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
dotenv.config(); // Fallback to .env for other vars if needed
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
console.log(`Connecting to: ${connectionString.split('@')[1] ? '***@' + connectionString.split('@')[1] : connectionString}`);
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
'Technologie & IT', 'Restauration & Alimentation', 'Mode & Textile', 'Artisanat & Déco',
|
||||||
|
'Santé & Bien-être', 'Éducation & Formation', 'Services aux entreprises', 'Construction & BTP',
|
||||||
|
'Agriculture & Agrobusiness', 'Tourisme & Transport'
|
||||||
|
];
|
||||||
|
|
||||||
|
const CITIES = ['Abidjan', 'Dakar', 'Lagos', 'Douala', 'Kinshasa', 'Cotonou', 'Lomé', 'Paris', 'Lyon', 'Bruxelles'];
|
||||||
|
|
||||||
|
const NAMES = [
|
||||||
|
'Moussa Diarra', 'Fatou Sow', 'Bakary Koné', 'Awa Traoré', 'Koffi Adjoumani',
|
||||||
|
'Cheick Tidiane', 'Bintu Camara', 'Ousmane Diallo', 'Yasmine Touré', 'Ibrahim Sy',
|
||||||
|
'Mariam Keita', 'Lamine Ndiaye', 'Aminata Faye', 'Idrissa Gueye', 'Sékou Condé',
|
||||||
|
'Zainab Bello', 'Kwame Nkrumah', 'Chidi Okafor', 'Adama Barrow', 'Fatimata Sylla',
|
||||||
|
'Jean-Pierre Mbeki', 'Grace Akoto', 'Samuel Eto\'o', 'Didier Drogba', 'Blaise Diagne',
|
||||||
|
'Léopold Senghor', 'Thomas Sankara', 'Miriam Makeba', 'Angélique Kidjo', 'Alpha Blondy'
|
||||||
|
];
|
||||||
|
|
||||||
|
const BIZ_NAMES = [
|
||||||
|
'Digital Africa', 'Saveurs du Sahel', 'Wax & Modernity', 'Artisans du Delta', 'Zenith Wellness',
|
||||||
|
'Afro-EdTech', 'Bizi Solutions', 'Sahara Build', 'Agro-Innov', 'Trans-Africa Express',
|
||||||
|
'Cyber-Sénégal', 'Miam-Miam Abidjan', 'Élégance Bamako', 'Poterie de Kati', 'Savon de Guinée',
|
||||||
|
'Code Academy', 'Compta Facile', 'Solar Power West', 'Cocoa Direct', 'Taxi-Plus',
|
||||||
|
'Cloud Kinshasa', 'Épices de Douala', 'Tissage d\'Accra', 'Bambou Design', 'Bio-Cosmetiques',
|
||||||
|
'E-Learning Africa', 'Logistique 225', 'Refuge Solaire', 'Ananas Export', 'Linga-Linga Travel'
|
||||||
|
];
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('--- Démarrage du seeding robuste (30 entrepreneurs) ---');
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('afrohub2025', 10);
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const name = NAMES[i % NAMES.length] + (i > 29 ? ` ${i}` : '');
|
||||||
|
const email = `entrepreneur${i + 1}@afrohub-test.com`;
|
||||||
|
const bizName = BIZ_NAMES[i % BIZ_NAMES.length];
|
||||||
|
const category = CATEGORIES[i % CATEGORIES.length];
|
||||||
|
const location = CITIES[i % CITIES.length];
|
||||||
|
const slug = bizName.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-]/g, '') + `-${i + 1}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create or Update User
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: { role: 'ENTREPRENEUR' },
|
||||||
|
create: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: 'ENTREPRENEUR',
|
||||||
|
location,
|
||||||
|
avatar: `https://i.pravatar.cc/150?u=${email}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create or Update Business
|
||||||
|
await prisma.business.upsert({
|
||||||
|
where: { ownerId: user.id },
|
||||||
|
update: {
|
||||||
|
isActive: true,
|
||||||
|
verified: true,
|
||||||
|
isFeatured: i < 8
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: bizName,
|
||||||
|
category,
|
||||||
|
location,
|
||||||
|
description: `Société leader dans le domaine ${category}. Basés à ${location}, nous œuvrons pour le rayonnement de l'expertise africaine.`,
|
||||||
|
logoUrl: `https://picsum.photos/200/200?random=${i + 200}`,
|
||||||
|
contactEmail: email,
|
||||||
|
contactPhone: `+225 00 ${i}${i} ${i}${i} ${i}${i}`,
|
||||||
|
slug,
|
||||||
|
isActive: true,
|
||||||
|
verified: true,
|
||||||
|
isFeatured: i < 8,
|
||||||
|
ownerId: user.id,
|
||||||
|
tags: [category.split(' ')[0], 'Expertise', 'Continent'],
|
||||||
|
viewCount: Math.floor(Math.random() * 500)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
process.stdout.write(`\rProgress: ${successCount}/30`);
|
||||||
|
|
||||||
|
await wait(100);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`\n❌ Échec pour ${email}:`);
|
||||||
|
console.error('Message:', e.message);
|
||||||
|
if (e.code) console.error('Code:', e.code);
|
||||||
|
if (e.meta) console.error('Meta:', e.meta);
|
||||||
|
await wait(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\n✅ Mission accomplie : ${successCount}/30 boutiques actives.`);
|
||||||
|
console.log(`Identifiants : entrepreneurX@afrohub-test.com / afrohub2025`);
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch(async (err) => {
|
||||||
|
console.error("Erreur fatale lors du seeding:", err);
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
97
prisma/seed-plans.ts
Normal file
97
prisma/seed-plans.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import pkg from '@prisma/client';
|
||||||
|
const { PrismaClient } = pkg;
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const Plan = {
|
||||||
|
STARTER: 'STARTER',
|
||||||
|
BOOSTER: 'BOOSTER',
|
||||||
|
EMPIRE: 'EMPIRE'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding pricing plans...');
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
tier: Plan.STARTER,
|
||||||
|
name: 'Starter',
|
||||||
|
priceXOF: 'Gratuit',
|
||||||
|
priceEUR: '0€',
|
||||||
|
description: 'Pour démarrer votre présence en ligne.',
|
||||||
|
features: [
|
||||||
|
'Fiche entreprise basique',
|
||||||
|
'Visible dans la recherche',
|
||||||
|
'1 Offre produit/service',
|
||||||
|
'Support par email'
|
||||||
|
],
|
||||||
|
offerLimit: 1,
|
||||||
|
recommended: false,
|
||||||
|
color: 'gray'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: Plan.BOOSTER,
|
||||||
|
name: 'Booster',
|
||||||
|
priceXOF: '5.000 FCFA',
|
||||||
|
priceEUR: '8€',
|
||||||
|
description: "L'indispensable pour les entreprises en croissance.",
|
||||||
|
features: [
|
||||||
|
'Tout du plan Starter',
|
||||||
|
'Badge "Vérifié" ✅',
|
||||||
|
'Jusqu\'à 10 Offres produits',
|
||||||
|
'Lien vers réseaux sociaux & Site Web',
|
||||||
|
'Statistiques de base (Vues)'
|
||||||
|
],
|
||||||
|
offerLimit: 10,
|
||||||
|
recommended: true,
|
||||||
|
color: 'brand'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: Plan.EMPIRE,
|
||||||
|
name: 'Empire',
|
||||||
|
priceXOF: '15.000 FCFA',
|
||||||
|
priceEUR: '23€',
|
||||||
|
description: 'Dominez votre marché avec une visibilité maximale.',
|
||||||
|
features: [
|
||||||
|
'Tout du plan Booster',
|
||||||
|
'Badge "Recommandé" 🏆',
|
||||||
|
'Offres illimitées',
|
||||||
|
'Intégration vidéo Youtube',
|
||||||
|
'Interview écrite sur le Blog',
|
||||||
|
'Support prioritaire WhatsApp'
|
||||||
|
],
|
||||||
|
offerLimit: 1000,
|
||||||
|
recommended: false,
|
||||||
|
color: 'gray'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const planData of plans) {
|
||||||
|
await prisma.pricingPlan.upsert({
|
||||||
|
where: { tier: planData.tier as any },
|
||||||
|
update: planData,
|
||||||
|
create: planData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Pricing plans seeded successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
@@ -29,8 +29,8 @@ async function main() {
|
|||||||
// Create Main Admin
|
// Create Main Admin
|
||||||
const admin = await prisma.user.create({
|
const admin = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'Admin Afropreunariat',
|
name: 'Admin Afrohub',
|
||||||
email: 'admin@afropreunariat.com',
|
email: 'admin@afrohub.com',
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
},
|
},
|
||||||
|
|||||||
23
scratch/check_db.ts
Normal file
23
scratch/check_db.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const featured = await prisma.business.findMany({
|
||||||
|
where: { isFeatured: true },
|
||||||
|
});
|
||||||
|
console.log('--- ALL Featured Businesses in DB ---');
|
||||||
|
console.log(JSON.stringify(featured, null, 2));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
53
scratch/fix_db.ts
Normal file
53
scratch/fix_db.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function fix() {
|
||||||
|
console.log('--- Nettoyage et stabilisation des données vedettes ---');
|
||||||
|
|
||||||
|
// 1. Désactiver 'isFeatured' pour tout le monde par défaut
|
||||||
|
await prisma.business.updateMany({
|
||||||
|
data: { isFeatured: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Activer isFeatured pour les 8 premiers (comme prévu dans le seed)
|
||||||
|
// On va chercher les 8 premières entreprises créées
|
||||||
|
const firstEight = await prisma.business.findMany({
|
||||||
|
take: 8,
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const biz of firstEight) {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id: biz.id },
|
||||||
|
data: {
|
||||||
|
isFeatured: true,
|
||||||
|
// On s'assure que le keyMetric est propre
|
||||||
|
keyMetric: biz.keyMetric === 'susu land' ? '+50 Projets Livrés' : biz.keyMetric
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fix spécifique pour Ananas Export s'il n'est pas dans les 8
|
||||||
|
await prisma.business.updateMany({
|
||||||
|
where: { name: 'Ananas Export' },
|
||||||
|
data: {
|
||||||
|
keyMetric: 'Leader Export Fruits',
|
||||||
|
isFeatured: true // On le garde en vedette car l'utilisateur l'aime bien apparemment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Données stabilisées.');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
fix();
|
||||||
66
scratch/seed_legal.ts
Normal file
66
scratch/seed_legal.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
dotenv.config({ path: '.env' });
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding initial legal documents...');
|
||||||
|
|
||||||
|
const cguContent = `
|
||||||
|
<h2>1. Présentation du site</h2>
|
||||||
|
<p>En vertu de l'article 6 de la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique...</p>
|
||||||
|
<h2>2. Conditions générales d’utilisation du site et des services proposés</h2>
|
||||||
|
<p>L’utilisation du site Afrohub implique l’acceptation pleine et entière des conditions générales d’utilisation ci-après décrites.</p>
|
||||||
|
<h2>3. Description des services fournis</h2>
|
||||||
|
<p>Le site Afrohub a pour objet de fournir une plateforme de mise en relation entre entrepreneurs africains et clients.</p>
|
||||||
|
<h2>4. Limitations contractuelles sur les données techniques</h2>
|
||||||
|
<p>Le site utilise la technologie JavaScript (Next.js).</p>
|
||||||
|
<h2>5. Propriété intellectuelle et contrefaçons</h2>
|
||||||
|
<p>Afrohub est propriétaire des droits de propriété intellectuelle sur tous les éléments accessibles sur le site.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cgvContent = `
|
||||||
|
<h2>1. Objet</h2>
|
||||||
|
<p>Les présentes conditions générales de vente (CGV) régissent les relations contractuelles entre la plateforme Afrohub et les inscrits.</p>
|
||||||
|
<h2>2. Description des services payants</h2>
|
||||||
|
<p>Afrohub propose divers services payants destinés aux entrepreneurs (Pack Booster, Empire).</p>
|
||||||
|
<h2>3. Tarifs et paiement</h2>
|
||||||
|
<p>Les prix de nos services sont indiqués en euros (ou monnaie locale) toutes taxes comprises.</p>
|
||||||
|
<h2>4. Droit de rétractation</h2>
|
||||||
|
<p>Conformément à la loi, le droit de rétractation ne s'applique pas aux services numériques immédiatement exécutés.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await prisma.legalDocument.upsert({
|
||||||
|
where: { type: 'CGU' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
type: 'CGU',
|
||||||
|
title: "Conditions Générales d'Utilisation",
|
||||||
|
content: cguContent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.legalDocument.upsert({
|
||||||
|
where: { type: 'CGV' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
type: 'CGV',
|
||||||
|
title: "Conditions Générales de Vente",
|
||||||
|
content: cgvContent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Legal documents seeded!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
71
scratch/seed_stats.ts
Normal file
71
scratch/seed_stats.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const business = await prisma.business.findFirst();
|
||||||
|
if (!business) {
|
||||||
|
console.log("No business found to seed stats for.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seeding stats for business: ${business.name} (${business.id})`);
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const typeViews = 'BUSINESS_VIEW';
|
||||||
|
const typeClicks = 'BUSINESS_CONTACT_CLICK';
|
||||||
|
|
||||||
|
// Seed last 30 days
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
|
||||||
|
// Random number of views (5 to 50)
|
||||||
|
const viewCount = Math.floor(Math.random() * 45) + 5;
|
||||||
|
for (let j = 0; j < viewCount; j++) {
|
||||||
|
events.push({
|
||||||
|
type: typeViews,
|
||||||
|
path: `/annuaire/${business.id}`,
|
||||||
|
label: business.name,
|
||||||
|
metadata: { businessId: business.id },
|
||||||
|
createdAt: new Date(date.getTime() + Math.random() * 3600000 * 24) // Random time in that day
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random number of clicks (0 to 10)
|
||||||
|
const clickCount = Math.floor(Math.random() * 11);
|
||||||
|
for (let k = 0; k < clickCount; k++) {
|
||||||
|
events.push({
|
||||||
|
type: typeClicks,
|
||||||
|
path: `/annuaire/${business.id}`,
|
||||||
|
label: business.name,
|
||||||
|
metadata: { businessId: business.id },
|
||||||
|
createdAt: new Date(date.getTime() + Math.random() * 3600000 * 24)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert
|
||||||
|
await prisma.analyticsEvent.createMany({
|
||||||
|
data: events
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Successfully seeded ${events.length} events.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
22
scratch/test-geoip.ts
Normal file
22
scratch/test-geoip.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Run with npx ts-node scratch/test-geoip.ts
|
||||||
|
import { getGeoInfo } from '../lib/geo';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const testIps = [
|
||||||
|
'8.8.8.8', // Google (US)
|
||||||
|
'41.207.160.0', // Orange (CI)
|
||||||
|
'1.1.1.1', // Cloudflare (AU/US)
|
||||||
|
'102.64.170.0', // Maroc Telecom (MA)
|
||||||
|
'127.0.0.1' // Local
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Testing GeoIP Detection...\n');
|
||||||
|
|
||||||
|
for (const ip of testIps) {
|
||||||
|
const mockHeaders = new Headers();
|
||||||
|
const result = await getGeoInfo(ip, mockHeaders);
|
||||||
|
console.log(`IP: ${ip.padEnd(15)} => Country: ${result.country.padEnd(20)} City: ${result.city}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
44
scratch/update_settings.ts
Normal file
44
scratch/update_settings.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('DATABASE_URL is not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
console.log('🔄 Updating Site Settings in database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: {
|
||||||
|
siteName: 'Afrohub',
|
||||||
|
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
||||||
|
contactEmail: 'support@afrohub.com',
|
||||||
|
footerText: '© 2025 Afrohub. Tous droits réservés.',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 'singleton',
|
||||||
|
siteName: 'Afrohub',
|
||||||
|
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
||||||
|
contactEmail: 'support@afrohub.com',
|
||||||
|
footerText: '© 2025 Afrohub. Tous droits réservés.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Settings updated:', settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error updating settings:', error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
async function testGeoIP(ip) {
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
console.log('Attempting to create an AnalyticsEvent...');
|
|
||||||
try {
|
try {
|
||||||
const event = await prisma.analyticsEvent.create({
|
const response = await fetch(`http://ip-api.com/json/${ip}`);
|
||||||
data: {
|
const data = await response.json();
|
||||||
type: 'TEST_MANUAL',
|
console.log(`IP: ${ip} => Country: ${data.country}, City: ${data.city}`);
|
||||||
path: '/test-script',
|
|
||||||
label: 'Script Test',
|
|
||||||
metadata: { source: 'script' }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('SUCCESS! Event created:', event.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('FAILURE! Prisma Error:');
|
console.error(`Error for ${ip}:`, error.message);
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test();
|
async function run() {
|
||||||
|
const ips = ['8.8.8.8', '41.207.160.0', '1.1.1.1'];
|
||||||
|
for (const ip of ips) {
|
||||||
|
await testGeoIP(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
|||||||
27
types.ts
27
types.ts
@@ -14,6 +14,8 @@ export interface User {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
countryId?: string;
|
||||||
|
country?: Country;
|
||||||
isSuspended?: boolean;
|
isSuspended?: boolean;
|
||||||
suspensionReason?: string;
|
suspensionReason?: string;
|
||||||
}
|
}
|
||||||
@@ -25,13 +27,24 @@ export interface SocialLinks {
|
|||||||
website?: string;
|
website?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Country {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
flag?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Business {
|
export interface Business {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
category: string;
|
category: string;
|
||||||
location: string; // City, Country
|
location: string; // Legacy: City, Country
|
||||||
|
countryId?: string;
|
||||||
|
country?: Country;
|
||||||
|
city?: string;
|
||||||
description: string;
|
description: string;
|
||||||
logoUrl: string;
|
logoUrl: string;
|
||||||
videoUrl?: string; // YouTube/Vimeo link
|
videoUrl?: string; // YouTube/Vimeo link
|
||||||
@@ -105,6 +118,18 @@ export interface Interview {
|
|||||||
duration?: string; // e.g. "15 min" or "5 min de lecture"
|
duration?: string; // e.g. "15 min" or "5 min de lecture"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Rating {
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
comment?: string;
|
||||||
|
reply?: string;
|
||||||
|
replyAt?: string;
|
||||||
|
userId: string;
|
||||||
|
businessId: string;
|
||||||
|
user?: User;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = [
|
||||||
"Technologie & IT",
|
"Technologie & IT",
|
||||||
"Agriculture & Agrobusiness",
|
"Agriculture & Agrobusiness",
|
||||||
|
|||||||
Reference in New Issue
Block a user