Compare commits
127 Commits
a4f85b0b7b
...
mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4db8271ecf | ||
|
|
e6d6770c26 | ||
| 371ed17867 | |||
| f6e3c1bb8c | |||
| 6acd5ebe2e | |||
| 6a42c52b58 | |||
| 72c24e0cc6 | |||
| 67df2d95e9 | |||
| 4aedcdb45a | |||
| bac36efb94 | |||
| d1b32cd401 | |||
| 2148f7281e | |||
| 7b015cb758 | |||
| bb464cf1c5 | |||
| 19b405d9f2 | |||
| a37b34f758 | |||
| 13c88dd4f7 | |||
| 52a9d47c30 | |||
| f93d70c447 | |||
| c73763be8a | |||
| 268e14f369 | |||
| e1aa0f076a | |||
| 3d7cb5f4d1 | |||
| 2d9d707251 | |||
| 5720661f8d | |||
| 1b6d409319 | |||
| 75ae9c5f3d | |||
| 63b5af4868 | |||
| a23c3bf780 | |||
| 16d66c0456 | |||
| 5f421b418e | |||
| 0e72867d4c | |||
| 2122075801 | |||
| 0f526c851a | |||
| 79a9cbffe9 | |||
| f450f8ee03 | |||
| 9c003d1b7d | |||
| 4e881bcbe6 | |||
| 81b068e5c9 | |||
| b55b15a2aa | |||
| 159f1c1709 | |||
| a814c7b577 | |||
| 38caf36bee | |||
|
|
ef7ac122f8 | ||
|
|
7900c9d5be | ||
| b89dc62408 | |||
| 792a951e02 | |||
| 27b35104b9 | |||
| d932186727 | |||
| 281fb764c4 | |||
|
|
31fa2fcde3 | ||
|
|
a551fe6bcc | ||
|
|
668b6bd8b8 | ||
|
|
d1e551bcca | ||
| 3c58e60b33 | |||
| 11678e0b33 | |||
| 1a66c46389 | |||
| e6310f30de | |||
| 88e4c13b9f | |||
| 33ec11d6db | |||
| 12e48a738c | |||
| 0460a1586f | |||
| 2e7cb65a29 | |||
| b01b2f6476 | |||
| d1d17de342 | |||
| f4bd78ac44 | |||
| 68c396ea9a | |||
| 144bf2133f | |||
| 654a33c3a9 | |||
| c26315bfdc | |||
| bf1e118de3 | |||
| 40da981642 | |||
| 040a6a0ce4 | |||
| dd047c5aa1 | |||
| cb5cee44f4 | |||
| 5ba6feeb99 | |||
| 098c38f921 | |||
| bcbb004d87 | |||
| 3958f84e03 | |||
| f97399e4fd | |||
| 270c4055dc | |||
| ce5245299c | |||
| c47d8821f6 | |||
| 46175b32f5 | |||
| 0a5e2b4cdc | |||
| a28a75bc48 | |||
| 69d0ef08e9 | |||
| afb1853c59 | |||
| f1ea12792a | |||
| f1462b909c | |||
| 3fab15a93c | |||
| 3e2063e4fa | |||
| 6ec1a3ae84 | |||
| 57ac75377b | |||
| d33ae2469f | |||
| b2aad10734 | |||
| fab7b88882 | |||
| 93a4c1df5e | |||
| 889e70bfc0 | |||
| a38c5d1638 | |||
| cadbc5afe1 | |||
| ef04b5a093 | |||
| fae94ed8b1 | |||
| d8a6d43ae1 | |||
| 09ecf8a6b9 | |||
| dd4b35690c | |||
| 18954d0ef3 | |||
| 9917e19409 | |||
| b1846f558b | |||
| 803ff43909 | |||
| 3cb07b8478 | |||
| daac641e53 | |||
| fb5528337b | |||
| 15644403de | |||
| 7a648e8182 | |||
| c543e7e8fb | |||
| 98010643e2 | |||
| 41373e97f3 | |||
| c26c3b9485 | |||
| a5ccf2047b | |||
| 887030ee47 | |||
| b73939d381 | |||
| 10ca8c3e8c | |||
| b227657f19 | |||
| d88ba7c53c | |||
| bb10407e66 | |||
| bf7462c9d5 |
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
dist
|
||||||
|
build
|
||||||
8
.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Connexion locale via le Docker Compose fourni :
|
||||||
|
#DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
||||||
|
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
|
# Environnement : "development" = accents rouges, "production" = accents normaux
|
||||||
|
NEXT_PUBLIC_APP_ENV=development
|
||||||
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Configuration Base de données (Dokploy injection)
|
||||||
|
DATABASE_URL="postgresql://user:password@host:port/dbname?schema=public"
|
||||||
|
|
||||||
|
# Configuration Application
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Autres variables (ex: Google AI, etc.)
|
||||||
|
# GOOGLE_GENERATIVE_AI_API_KEY="votre_cle_ici"
|
||||||
|
# NEXTAUTH_SECRET="votre_secret_ici"
|
||||||
5
.gitignore
vendored
@@ -6,11 +6,14 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
.next
|
||||||
|
.next/*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
60
App.tsx
@@ -1,60 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
|
||||||
import { User } from './types';
|
|
||||||
import { MOCK_USER } from './services/mockData';
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
import Navbar from './components/Navbar';
|
|
||||||
import Footer from './components/Footer';
|
|
||||||
|
|
||||||
// Pages
|
|
||||||
import HomePage from './pages/HomePage';
|
|
||||||
import DirectoryPage from './pages/DirectoryPage';
|
|
||||||
import BlogPage from './pages/BlogPage';
|
|
||||||
import BlogPostPage from './pages/BlogPostPage';
|
|
||||||
import BusinessDetailPage from './pages/BusinessDetailPage';
|
|
||||||
import LoginPage from './pages/LoginPage';
|
|
||||||
import DashboardPage from './pages/DashboardPage';
|
|
||||||
import AfroLifePage from './pages/AfroLifePage';
|
|
||||||
import InterviewDetailPage from './pages/InterviewDetailPage';
|
|
||||||
import SubscriptionPage from './pages/SubscriptionPage';
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
setUser(MOCK_USER);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
setUser(null);
|
|
||||||
// Redirect handled by rendering logic
|
|
||||||
window.location.hash = '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HashRouter>
|
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50 font-sans">
|
|
||||||
<Navbar user={user} onLogout={handleLogout} />
|
|
||||||
<main className="flex-grow">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/directory" element={<DirectoryPage />} />
|
|
||||||
<Route path="/directory/:id" element={<BusinessDetailPage />} />
|
|
||||||
<Route path="/blog" element={<BlogPage />} />
|
|
||||||
<Route path="/blog/:id" element={<BlogPostPage />} />
|
|
||||||
<Route path="/afrolife" element={<AfroLifePage />} />
|
|
||||||
<Route path="/afrolife/:id" element={<InterviewDetailPage />} />
|
|
||||||
<Route path="/subscription" element={<SubscriptionPage />} />
|
|
||||||
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
|
|
||||||
<Route path="/dashboard" element={user ? <DashboardPage user={user} onLogout={handleLogout} /> : <LoginPage onLogin={handleLogin} />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</HashRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PATH /app/node_modules/.bin:$PATH
|
||||||
|
|
||||||
|
# On copie tout, on installe tout, on build tout au même endroit
|
||||||
|
COPY . .
|
||||||
|
RUN npm ci
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# On lance directement via npm
|
||||||
|
CMD ["npm", "start"]
|
||||||
41
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
5
admin/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
1
admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
22
admin/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PATH /app/node_modules/.bin:$PATH
|
||||||
|
|
||||||
|
# Copie et installation
|
||||||
|
COPY . .
|
||||||
|
RUN npm install
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# On lance directement via npm (avec push de la BD si besoin, ou juste start)
|
||||||
|
CMD ["sh", "-c", "npx prisma db push --schema=prisma/schema.prisma --accept-data-loss && npm start"]
|
||||||
36
admin/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
12
admin/check-koliko.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const business = await prisma.business.findFirst({
|
||||||
|
where: { name: { contains: 'Koliko', mode: 'insensitive' } },
|
||||||
|
include: { owner: true }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(business, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
18
admin/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
12
admin/next.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
output: 'standalone',
|
||||||
|
turbopack: {
|
||||||
|
root: path.join(__dirname, ".."),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
13
admin/nixpacks.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[phases.setup]
|
||||||
|
# Ce hash force Nixpacks à utiliser la version contenant Node 22.13.1
|
||||||
|
nixpkgsArchive = "51ad838b03a05b1de6f9f2a0fffecee64a9788ee"
|
||||||
|
nixPkgs = ["nodejs_22", "openssl"]
|
||||||
|
|
||||||
|
[phases.install]
|
||||||
|
cmds = ["npm ci"]
|
||||||
|
|
||||||
|
[phases.build]
|
||||||
|
cmds = ["npx prisma generate", "npm run build"]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "npx prisma db push --accept-data-loss && npm start"
|
||||||
7333
admin/package-lock.json
generated
Normal file
38
admin/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "6.19.3",
|
||||||
|
"@prisma/client": "6.19.3",
|
||||||
|
"@prisma/config": "6.19.3",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"dotenv": "^17.4.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "16.2.3",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"prisma": "6.19.3",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-quill-new": "^3.8.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
admin/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
admin/prisma/migrations/0_init/migration.sql
Normal file
459
admin/prisma/migrations/20260427214502_init/migration.sql
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RatingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('VISITOR', 'ENTREPRENEUR', 'ADMIN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Plan" AS ENUM ('STARTER', 'BOOSTER', 'EMPIRE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OfferType" AS ENUM ('PRODUCT', 'SERVICE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InterviewType" AS ENUM ('VIDEO', 'ARTICLE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'VISITOR',
|
||||||
|
"avatar" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"bio" TEXT,
|
||||||
|
"location" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suspensionReason" TEXT,
|
||||||
|
"countryId" TEXT,
|
||||||
|
"resetToken" TEXT,
|
||||||
|
"resetTokenExpiry" TIMESTAMP(3),
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"verificationToken" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Business" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"logoUrl" TEXT NOT NULL,
|
||||||
|
"videoUrl" TEXT,
|
||||||
|
"contactEmail" TEXT NOT NULL,
|
||||||
|
"contactPhone" TEXT,
|
||||||
|
"socialLinks" JSONB,
|
||||||
|
"verified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
"ratingCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"founderName" TEXT,
|
||||||
|
"founderImageUrl" TEXT,
|
||||||
|
"keyMetric" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"slug" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"showEmail" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showPhone" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showSocials" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"websiteUrl" TEXT,
|
||||||
|
"isSuspended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suspensionReason" TEXT,
|
||||||
|
"plan" "Plan" NOT NULL DEFAULT 'STARTER',
|
||||||
|
"city" TEXT,
|
||||||
|
"countryId" TEXT,
|
||||||
|
"coverUrl" TEXT,
|
||||||
|
"coverPosition" TEXT DEFAULT '50% 50%',
|
||||||
|
"coverZoom" DOUBLE PRECISION DEFAULT 1.0,
|
||||||
|
"suggestedCategory" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BusinessCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"icon" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BusinessCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Country" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"flag" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PricingPlan" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tier" "Plan" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"priceXOF" TEXT NOT NULL,
|
||||||
|
"priceEUR" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"features" TEXT[],
|
||||||
|
"offerLimit" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"recommended" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'gray',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"yearlyPriceEUR" TEXT,
|
||||||
|
"yearlyPriceXOF" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PricingPlan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Offer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"type" "OfferType" NOT NULL,
|
||||||
|
"price" DOUBLE PRECISION NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'XOF',
|
||||||
|
"description" TEXT,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BlogPost" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Interview" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"guestName" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"type" "InterviewType" NOT NULL,
|
||||||
|
"thumbnailUrl" TEXT NOT NULL,
|
||||||
|
"videoUrl" TEXT,
|
||||||
|
"content" TEXT,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"duration" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "Interview_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AnalyticsEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"value" DOUBLE PRECISION,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"browser" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"country" TEXT,
|
||||||
|
"device" TEXT,
|
||||||
|
"ip" TEXT,
|
||||||
|
"os" TEXT,
|
||||||
|
"referrer" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Rating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"reply" TEXT,
|
||||||
|
"replyAt" TIMESTAMP(3),
|
||||||
|
"status" "RatingStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
|
||||||
|
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Conversation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationParticipant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"conversationId" TEXT NOT NULL,
|
||||||
|
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Message" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"conversationId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MessageReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"reporterId" TEXT NOT NULL,
|
||||||
|
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MessageReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SiteSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"siteName" TEXT NOT NULL DEFAULT 'Afrohub',
|
||||||
|
"siteSlogan" TEXT NOT NULL DEFAULT 'La plateforme de référence pour l''entrepreneuriat africain.',
|
||||||
|
"contactEmail" TEXT NOT NULL DEFAULT 'support@afrohub.com',
|
||||||
|
"contactPhone" TEXT DEFAULT '+225 00 00 00 00 00',
|
||||||
|
"address" TEXT DEFAULT 'Abidjan, Côte d''Ivoire',
|
||||||
|
"facebookUrl" TEXT,
|
||||||
|
"twitterUrl" TEXT,
|
||||||
|
"instagramUrl" TEXT,
|
||||||
|
"linkedinUrl" TEXT,
|
||||||
|
"footerText" TEXT DEFAULT '© 2025 Afrohub. Tous droits réservés.',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"homeBlogCount" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"homeBlogShow" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"homeBlogSubtitle" TEXT NOT NULL DEFAULT 'Toutes les actualités de l''entrepreneuriat africain.',
|
||||||
|
"homeBlogTitle" TEXT NOT NULL DEFAULT 'Derniers Articles',
|
||||||
|
"homeBlogCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"homeBlogIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"homeBlogSelection" TEXT NOT NULL DEFAULT 'latest',
|
||||||
|
"homeCategories" TEXT[] DEFAULT ARRAY['Technologie & IT', 'Agriculture & Agrobusiness', 'Mode & Textile', 'Cosmétique & Beauté']::TEXT[],
|
||||||
|
"resetPasswordSubject" TEXT NOT NULL DEFAULT 'Réinitialisation de votre mot de passe - Afrohub',
|
||||||
|
"resetPasswordTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style="text-align: center; margin: 30px 0;"><a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n''avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p></div>',
|
||||||
|
"verifyEmailSubject" TEXT NOT NULL DEFAULT 'Vérification de votre compte - Afrohub',
|
||||||
|
"verifyEmailTemplate" TEXT NOT NULL DEFAULT '<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"><h2 style="color: #0d9488; text-align: center;">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style="text-align: center; margin: 30px 0;"><a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a></div><p>Si vous n''avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;"><p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p></div>',
|
||||||
|
|
||||||
|
CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LegalDocument" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Event" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"thumbnailUrl" TEXT NOT NULL,
|
||||||
|
"link" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Favorite" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Business_ownerId_key" ON "Business"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BusinessCategory_name_key" ON "BusinessCategory"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BusinessCategory_slug_key" ON "BusinessCategory"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Country_name_key" ON "Country"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Country_code_key" ON "Country"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PricingPlan_tier_key" ON "PricingPlan"("tier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Interview_slug_key" ON "Interview"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Rating_userId_businessId_key" ON "Rating"("userId", "businessId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationParticipant_userId_conversationId_key" ON "ConversationParticipant"("userId", "conversationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LegalDocument_type_key" ON "LegalDocument"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Favorite_userId_businessId_key" ON "Favorite"("userId", "businessId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Business" ADD CONSTRAINT "Business_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "BusinessCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Business" ADD CONSTRAINT "Business_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Business" ADD CONSTRAINT "Business_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MessageReport" ADD CONSTRAINT "MessageReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
3
admin/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
379
admin/prisma/schema.prisma
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
role UserRole @default(VISITOR)
|
||||||
|
avatar String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
bio String?
|
||||||
|
location String?
|
||||||
|
phone String?
|
||||||
|
isSuspended Boolean @default(false)
|
||||||
|
suspensionReason String?
|
||||||
|
countryId String?
|
||||||
|
resetToken String? @unique
|
||||||
|
resetTokenExpiry DateTime?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
verificationToken String? @unique
|
||||||
|
businesses Business?
|
||||||
|
comments Comment[]
|
||||||
|
conversations ConversationParticipant[]
|
||||||
|
sentMessages Message[]
|
||||||
|
reports MessageReport[]
|
||||||
|
ratings Rating[]
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
favorites Favorite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Business {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
category String
|
||||||
|
location String
|
||||||
|
description String
|
||||||
|
logoUrl String
|
||||||
|
videoUrl String?
|
||||||
|
contactEmail String
|
||||||
|
contactPhone String?
|
||||||
|
socialLinks Json?
|
||||||
|
verified Boolean @default(false)
|
||||||
|
viewCount Int @default(0)
|
||||||
|
rating Float @default(0.0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
|
tags String[]
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
founderName String?
|
||||||
|
founderImageUrl String?
|
||||||
|
keyMetric String?
|
||||||
|
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?
|
||||||
|
metaTitle String?
|
||||||
|
metaDescription String?
|
||||||
|
plan Plan @default(STARTER)
|
||||||
|
city String?
|
||||||
|
countryId String?
|
||||||
|
coverUrl String?
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
|
suggestedCategory String?
|
||||||
|
categoryId String?
|
||||||
|
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
||||||
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
comments Comment[]
|
||||||
|
conversations Conversation[]
|
||||||
|
offers Offer[]
|
||||||
|
ratings Rating[]
|
||||||
|
favorites Favorite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
icon String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
businesses Business[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
description String?
|
||||||
|
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
|
||||||
|
yearlyPriceEUR String?
|
||||||
|
yearlyPriceXOF String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Offer {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
type OfferType
|
||||||
|
price Float
|
||||||
|
currency String @default("XOF")
|
||||||
|
description String?
|
||||||
|
imageUrl String
|
||||||
|
active Boolean @default(true)
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model BlogPost {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
excerpt String
|
||||||
|
content String
|
||||||
|
author String
|
||||||
|
date DateTime @default(now())
|
||||||
|
imageUrl String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Interview {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
guestName String
|
||||||
|
companyName String
|
||||||
|
role String
|
||||||
|
type InterviewType
|
||||||
|
thumbnailUrl String
|
||||||
|
videoUrl String?
|
||||||
|
content String?
|
||||||
|
excerpt String
|
||||||
|
date DateTime @default(now())
|
||||||
|
duration String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
content String
|
||||||
|
authorId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model AnalyticsEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String
|
||||||
|
path String
|
||||||
|
label String?
|
||||||
|
value Float?
|
||||||
|
metadata Json?
|
||||||
|
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 {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
participants ConversationParticipant[]
|
||||||
|
messages Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConversationParticipant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
conversationId String
|
||||||
|
isArchived Boolean @default(false)
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, conversationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
content String
|
||||||
|
senderId String
|
||||||
|
conversationId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
read Boolean @default(false)
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
reports MessageReport[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MessageReport {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
reason String?
|
||||||
|
messageId String
|
||||||
|
reporterId String
|
||||||
|
status ReportStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
|
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
homeBlogCount Int @default(3)
|
||||||
|
homeBlogShow Boolean @default(true)
|
||||||
|
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
||||||
|
homeBlogTitle String @default("Derniers Articles")
|
||||||
|
homeBlogCategories String[] @default([])
|
||||||
|
homeBlogIds String[] @default([])
|
||||||
|
homeBlogSelection String @default("latest")
|
||||||
|
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
||||||
|
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
||||||
|
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>")
|
||||||
|
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
|
||||||
|
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LegalDocument {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String @unique
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
date DateTime
|
||||||
|
location String
|
||||||
|
thumbnailUrl String
|
||||||
|
link String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, businessId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RatingStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportStatus {
|
||||||
|
PENDING
|
||||||
|
RESOLVED
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
VISITOR
|
||||||
|
ENTREPRENEUR
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Plan {
|
||||||
|
STARTER
|
||||||
|
BOOSTER
|
||||||
|
EMPIRE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OfferType {
|
||||||
|
PRODUCT
|
||||||
|
SERVICE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InterviewType {
|
||||||
|
VIDEO
|
||||||
|
ARTICLE
|
||||||
|
}
|
||||||
1
admin/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
admin/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
admin/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
admin/public/uploads/1777833997337-98dkw92.gif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
admin/public/uploads/1777834233872-lz35qcr.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
1
admin/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
admin/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
95
admin/src/app/actions/blog.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function createBlogPost(data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true, id: post.id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create blog post:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'article" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBlogPost(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const post = await prisma.blogPost.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true, id: post.id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update blog post:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBlogPost(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.blogPost.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete blog post:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlogPosts() {
|
||||||
|
try {
|
||||||
|
return await prisma.blogPost.findMany({
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch blog posts:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
101
admin/src/app/actions/business.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function toggleVerification(id: string, currentStatus: boolean) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: { verified: !currentStatus },
|
||||||
|
});
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle verification:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFeaturedBusiness(id: string, data: {
|
||||||
|
founderName: string;
|
||||||
|
founderImageUrl: string;
|
||||||
|
keyMetric: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// 1. Reset all others (we only want one entrepreneur of the month)
|
||||||
|
await prisma.business.updateMany({
|
||||||
|
where: { isFeatured: true },
|
||||||
|
data: { isFeatured: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Set the new one
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isFeatured: true,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to set featured business:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour de l'Afroshine" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFeaturedBusiness(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isFeatured: false },
|
||||||
|
});
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to remove featured status:", error);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function updateBusinessSeo(id: string, data: {
|
||||||
|
metaTitle: string;
|
||||||
|
metaDescription: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metaTitle: data.metaTitle,
|
||||||
|
metaDescription: data.metaDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
|
revalidatePath(`/annuaire/${id}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update business SEO:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour SEO" };
|
||||||
|
}
|
||||||
|
}
|
||||||
111
admin/src/app/actions/categories.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getCategories() {
|
||||||
|
try {
|
||||||
|
return await prisma.businessCategory.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch categories:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
||||||
|
try {
|
||||||
|
const { suggestionId, ...categoryData } = data;
|
||||||
|
|
||||||
|
const category = await prisma.businessCategory.create({
|
||||||
|
data: categoryData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si c'est une conversion de suggestion, on lie automatiquement l'entreprise concernée
|
||||||
|
if (suggestionId) {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id: suggestionId },
|
||||||
|
data: {
|
||||||
|
category: category.name,
|
||||||
|
categoryId: category.id,
|
||||||
|
suggestedCategory: null // On nettoie le champ tampon car la suggestion est validée
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
return { success: true, data: category };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de la catégorie" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
|
||||||
|
try {
|
||||||
|
// 1. Récupérer l'ancien nom pour la migration
|
||||||
|
const oldCategory = await prisma.businessCategory.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { name: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Mettre à jour la catégorie
|
||||||
|
const category = await prisma.businessCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Si le nom a changé, mettre à jour tous les entrepreneurs qui l'utilisaient
|
||||||
|
if (oldCategory && oldCategory.name !== data.name) {
|
||||||
|
await prisma.business.updateMany({
|
||||||
|
where: { category: oldCategory.name },
|
||||||
|
data: { category: data.name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
return { success: true, data: category };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.businessCategory.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/categories");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete category:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSuggestedCategories() {
|
||||||
|
try {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
suggestedCategory: { not: null },
|
||||||
|
category: 'Autre'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
suggestedCategory: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch suggested categories:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
admin/src/app/actions/comment.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function deleteComment(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.comment.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
revalidatePath("/comments");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete comment:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
75
admin/src/app/actions/countries.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"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, description?: string }) {
|
||||||
|
try {
|
||||||
|
const country = await prisma.country.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
code: data.code.toUpperCase(),
|
||||||
|
flag: data.flag || null,
|
||||||
|
description: data.description || 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCountryDescription(id: string, description: string) {
|
||||||
|
try {
|
||||||
|
await prisma.country.update({
|
||||||
|
where: { id },
|
||||||
|
data: { description }
|
||||||
|
});
|
||||||
|
revalidatePath('/countries');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating country description:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
89
admin/src/app/actions/event.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function createEvent(data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
description: string;
|
||||||
|
date: Date;
|
||||||
|
location: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
link?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
revalidatePath("/afrolife");
|
||||||
|
return { success: true, data: event };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'événement" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
description: string;
|
||||||
|
date: Date;
|
||||||
|
location: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
link?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
revalidatePath("/afrolife");
|
||||||
|
return { success: true, data: event };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
revalidatePath("/afrolife");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete event:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
94
admin/src/app/actions/interview.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||||
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
|
||||||
|
export async function createInterview(data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
guestName: string;
|
||||||
|
companyName: string;
|
||||||
|
role: string;
|
||||||
|
type: InterviewType;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt: string;
|
||||||
|
duration?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const interview = await prisma.interview.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true, data: interview };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create interview:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la création de l'interview" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInterview(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
guestName: string;
|
||||||
|
companyName: string;
|
||||||
|
role: string;
|
||||||
|
type: InterviewType;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
content?: string;
|
||||||
|
excerpt: string;
|
||||||
|
duration?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
|
const interview = await prisma.interview.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
tags: data.tags || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true, data: interview };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update interview:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInterview(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.interview.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
revalidatePath("/blog");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete interview:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suppression" };
|
||||||
|
}
|
||||||
|
}
|
||||||
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
80
admin/src/app/actions/moderation.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getReports() {
|
||||||
|
return await prisma.messageReport.findMany({
|
||||||
|
include: {
|
||||||
|
message: {
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true, email: true, isSuspended: true }
|
||||||
|
},
|
||||||
|
conversation: {
|
||||||
|
include: {
|
||||||
|
business: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
|
||||||
|
try {
|
||||||
|
await prisma.messageReport.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status }
|
||||||
|
});
|
||||||
|
revalidatePath('/moderation');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
56
admin/src/app/actions/plans.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"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,
|
||||||
|
yearlyPriceXOF: data.yearlyPriceXOF || null,
|
||||||
|
priceEUR: data.priceEUR,
|
||||||
|
yearlyPriceEUR: data.yearlyPriceEUR || null,
|
||||||
|
description: data.description,
|
||||||
|
features: data.features, // Expected to be string[]
|
||||||
|
offerLimit: parseInt(data.offerLimit),
|
||||||
|
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.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
85
admin/src/app/actions/settings.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getSiteSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
where: { id: 'singleton' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default fallback values if not initialized
|
||||||
|
const defaultSettings = {
|
||||||
|
siteName: "Afrohub",
|
||||||
|
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.",
|
||||||
|
contactEmail: "support@afrohub.com",
|
||||||
|
contactPhone: "+225 00 00 00 00 00",
|
||||||
|
address: "Abidjan, Côte d'Ivoire",
|
||||||
|
facebookUrl: "",
|
||||||
|
twitterUrl: "",
|
||||||
|
instagramUrl: "",
|
||||||
|
linkedinUrl: "",
|
||||||
|
footerText: "© 2025 Afrohub. Tous droits réservés.",
|
||||||
|
homeBlogShow: true,
|
||||||
|
homeBlogTitle: "Derniers Articles",
|
||||||
|
homeBlogSubtitle: "Toutes les actualités de l'entrepreneuriat africain.",
|
||||||
|
homeBlogCount: 3,
|
||||||
|
homeBlogSelection: "latest",
|
||||||
|
homeBlogIds: [],
|
||||||
|
homeBlogCategories: [],
|
||||||
|
homeCategories: ["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"],
|
||||||
|
resetPasswordSubject: "Réinitialisation de votre mot de passe - Afrohub",
|
||||||
|
resetPasswordTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
|
</div>
|
||||||
|
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`,
|
||||||
|
verifyEmailSubject: "Vérification de votre compte - Afrohub",
|
||||||
|
verifyEmailTemplate: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0d9488; text-align: center;">{siteName}</h2>
|
||||||
|
<p>Bonjour {name},</p>
|
||||||
|
<p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
|
</div>
|
||||||
|
<p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 {siteName}. Tous droits réservés.</p>
|
||||||
|
</div>`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings) return defaultSettings;
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch settings:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSiteSettings(data: any) {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSetting.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: data,
|
||||||
|
create: {
|
||||||
|
id: 'singleton',
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/settings');
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
|
||||||
|
return { success: true, settings };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update settings:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour des paramètres" };
|
||||||
|
}
|
||||||
|
}
|
||||||
125
admin/src/app/actions/suspension.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend a user account
|
||||||
|
*/
|
||||||
|
export async function suspendUser(userId: string, reason: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: reason,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
businesses: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the user is an entrepreneur with a business, we also suspend the business by default
|
||||||
|
// to hide it from the site during the user's suspension.
|
||||||
|
if (user.businesses) {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id: user.businesses.id },
|
||||||
|
data: {
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: `Compte propriétaire suspendu : ${reason}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/clients");
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
revalidatePath("/moderation");
|
||||||
|
|
||||||
|
return { success: true, data: user };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to suspend user:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suspension" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsuspend a user account
|
||||||
|
*/
|
||||||
|
export async function unsuspendUser(userId: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
businesses: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also unsuspend their business if they had one
|
||||||
|
if (user.businesses) {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id: user.businesses.id },
|
||||||
|
data: {
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/clients");
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
revalidatePath("/moderation");
|
||||||
|
|
||||||
|
return { success: true, data: user };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to unsuspend user:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la réactivation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend a specific business (without suspending the whole user account)
|
||||||
|
*/
|
||||||
|
export async function suspendBusiness(businessId: string, reason: string) {
|
||||||
|
try {
|
||||||
|
const business = await prisma.business.update({
|
||||||
|
where: { id: businessId },
|
||||||
|
data: {
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
|
||||||
|
return { success: true, data: business };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to suspend business:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la suspension de la boutique" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsuspend a specific business
|
||||||
|
*/
|
||||||
|
export async function unsuspendBusiness(businessId: string) {
|
||||||
|
try {
|
||||||
|
const business = await prisma.business.update({
|
||||||
|
where: { id: businessId },
|
||||||
|
data: {
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/entrepreneurs");
|
||||||
|
|
||||||
|
return { success: true, data: business };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to unsuspend business:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la réactivation de la boutique" };
|
||||||
|
}
|
||||||
|
}
|
||||||
138
admin/src/app/actions/taxonomies.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getAllTags() {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||||
|
prisma.interview.findMany({ select: { tags: true } }),
|
||||||
|
prisma.event.findMany({ select: { tags: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
|
||||||
|
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
|
||||||
|
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
|
||||||
|
|
||||||
|
return Array.from(allTags).sort();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTagGlobally(tagName: string) {
|
||||||
|
try {
|
||||||
|
// This is expensive as we need to update all records
|
||||||
|
// In a real app, a Tag model would be better
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la suppression du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||||
|
try {
|
||||||
|
const cleanNewName = newName.trim().toLowerCase();
|
||||||
|
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors du renommage du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagUsage(tagName: string) {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, author: true }
|
||||||
|
}),
|
||||||
|
prisma.interview.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, guestName: true }
|
||||||
|
}),
|
||||||
|
prisma.event.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, location: true }
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
blogPosts: blogPosts.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
type: 'blog'
|
||||||
|
})),
|
||||||
|
interviews: interviews.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
type: 'interview'
|
||||||
|
})),
|
||||||
|
events: events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
status: e.status,
|
||||||
|
type: 'event'
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tag usage:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
|
||||||
|
}
|
||||||
|
}
|
||||||
42
admin/src/app/actions/upload.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
// Create a unique filename
|
||||||
|
const fileExtension = file.name.split('.').pop();
|
||||||
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||||
|
|
||||||
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
|
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||||
|
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
|
||||||
|
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
|
||||||
|
|
||||||
|
// Ensure both directories exist
|
||||||
|
await mkdir(rootPublicDir, { recursive: true });
|
||||||
|
await mkdir(adminPublicDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save to both
|
||||||
|
await writeFile(join(rootPublicDir, fileName), buffer);
|
||||||
|
await writeFile(join(adminPublicDir, fileName), buffer);
|
||||||
|
|
||||||
|
// Return the relative URL
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: `/uploads/${fileName}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during image upload:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
|
||||||
|
}
|
||||||
|
}
|
||||||
43
admin/src/app/actions/user.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getClients() {
|
||||||
|
try {
|
||||||
|
const clients = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: { not: "ADMIN" },
|
||||||
|
OR: [
|
||||||
|
{ businesses: null },
|
||||||
|
{ businesses: { isActive: false } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
businesses: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true, data: clients };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch clients:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la récupération des clients" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyUserEmail(userId: string) {
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to verify user email:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
admin/src/app/blog/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import BlogForm from "@/components/BlogForm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function EditBlogPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await prisma.blogPost.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BlogForm initialData={post} />;
|
||||||
|
}
|
||||||
5
admin/src/app/blog/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import BlogForm from "@/components/BlogForm";
|
||||||
|
|
||||||
|
export default function NewBlogPage() {
|
||||||
|
return <BlogForm />;
|
||||||
|
}
|
||||||
195
admin/src/app/blog/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react';
|
||||||
|
import DeleteBlogButton from '@/components/DeleteBlogButton';
|
||||||
|
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
|
||||||
|
import DeleteEventButton from '@/components/DeleteEventButton';
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
|
const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
|
const events = await prisma.event.findMany({ orderBy: { date: 'desc' } });
|
||||||
|
return { posts, interviews, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
||||||
|
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
||||||
|
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogCMSPage() {
|
||||||
|
const { posts, interviews, events } = await getData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog, Interviews & Événements</h1>
|
||||||
|
<p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href="/blog/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nouvel Article
|
||||||
|
</Link>
|
||||||
|
<Link href="/interview/new" className="btn-verify bg-emerald-600 flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nouvelle Interview
|
||||||
|
</Link>
|
||||||
|
<Link href="/event/new" className="btn-verify bg-amber-600 flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nouvel Événement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8">
|
||||||
|
{/* Blog Posts Section */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<BookOpen className="text-indigo-400 w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4">Titre</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
|
<th className="text-left py-3 px-4">Auteur</th>
|
||||||
|
<th className="text-left py-3 px-4">Date</th>
|
||||||
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="font-medium text-white">{post.title}</div>
|
||||||
|
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(post.status)}</td>
|
||||||
|
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
||||||
|
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
||||||
|
<td className="py-4 px-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<DeleteBlogButton id={post.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interviews Section */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Mic2 className="text-emerald-400 w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4">Interviewé</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
|
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
||||||
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{interviews.map((interview) => (
|
||||||
|
<tr key={interview.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="font-medium text-white">{interview.guestName}</div>
|
||||||
|
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
||||||
|
<div className="text-xs text-slate-500">{interview.role}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] font-bold ${interview.type === 'VIDEO' ? 'bg-indigo-500/10 text-indigo-400' : 'bg-emerald-500/10 text-emerald-400'}`}>
|
||||||
|
{interview.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<DeleteInterviewButton id={interview.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Section */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Calendar className="text-amber-400 w-5 h-5" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Événements ({events.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4">Événement</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
|
<th className="text-left py-3 px-4">Date & Lieu</th>
|
||||||
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{events.map((event) => (
|
||||||
|
<tr key={event.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="font-medium text-white">{event.title}</div>
|
||||||
|
<div className="text-xs text-slate-500 truncate max-w-sm">
|
||||||
|
{event.link ? 'Lien activé' : 'Pas de lien'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<div className="flex items-center text-sm text-slate-300 gap-2">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
{new Date(event.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs text-slate-500 gap-2 mt-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-amber-400">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<DeleteEventButton id={event.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
admin/src/app/comments/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import DeleteCommentButton from '@/components/DeleteCommentButton';
|
||||||
|
|
||||||
|
async function getComments() {
|
||||||
|
return await prisma.comment.findMany({
|
||||||
|
include: {
|
||||||
|
author: true,
|
||||||
|
business: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CommentsPage() {
|
||||||
|
const comments = await getComments();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Modération des Commentaires</h1>
|
||||||
|
<p className="text-slate-400">Surveiller et supprimer les commentaires inappropriés.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Auteur</th>
|
||||||
|
<th>Entreprise</th>
|
||||||
|
<th>Contenu</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-10 text-slate-500">
|
||||||
|
Aucun commentaire trouvé.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<tr key={comment.id}>
|
||||||
|
<td>
|
||||||
|
<div className="font-medium text-white">{comment.author.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{comment.author.email}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm text-indigo-400">{comment.business.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-md">
|
||||||
|
<p className="text-sm text-slate-300 truncate" title={comment.content}>
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
{new Date(comment.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<DeleteCommentButton id={comment.id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
admin/src/app/countries/page.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { getCountries, addCountry, deleteCountry, toggleCountryStatus, updateCountryDescription } from '@/app/actions/countries';
|
||||||
|
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag, Edit2, Check, X } 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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
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;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await addCountry({ name, code, flag, description });
|
||||||
|
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) {
|
||||||
|
toast.success(!current ? "Pays activé" : "Pays désactivé");
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDescription = async (id: string) => {
|
||||||
|
const result = await updateCountryDescription(id, editDesc);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Description mise à jour");
|
||||||
|
setEditingId(null);
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error("Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
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="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Note / Description (interne)</label>
|
||||||
|
<textarea name="description" placeholder="Ajouter un commentaire sur ce pays..." rows={2} className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-xl font-bold transition-all">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button disabled={isPending} type="submit" className="bg-emerald-600 hover:bg-emerald-700 text-white px-10 py-3 rounded-xl font-bold transition-all disabled:opacity-50">
|
||||||
|
{isPending ? 'Chargement...' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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">Description / Notes</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={5} 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">
|
||||||
|
{editingId === country.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500 w-full"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => handleSaveDescription(country.id)} className="text-emerald-500 hover:text-emerald-400">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-slate-500 hover:text-slate-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group flex items-center gap-2 max-w-xs">
|
||||||
|
<span className="text-slate-400 text-sm truncate">
|
||||||
|
{country.description || <span className="italic text-slate-600">Pas de note</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(country.id);
|
||||||
|
setEditDesc(country.description || "");
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-white transition-opacity"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(country.id, country.isActive)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
|
country.isActive ? 'bg-indigo-600' : 'bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
country.isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className={`ml-3 text-xs font-bold ${country.isActive ? 'text-emerald-500' : 'text-slate-500'}`}>
|
||||||
|
{country.isActive ? 'ACTIF' : 'INACTIF'}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Store,
|
||||||
|
Clock,
|
||||||
|
MessageCircle,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
usersCount: number;
|
||||||
|
businessCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
totalViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardClient({ stats }: { stats: Stats }) {
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
|
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||||
|
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
||||||
|
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
||||||
|
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
||||||
|
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
||||||
|
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="card">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
||||||
|
<card.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
admin/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import DashboardClient from './DashboardClient';
|
||||||
|
|
||||||
|
async function getStats() {
|
||||||
|
const usersCount = await prisma.user.count();
|
||||||
|
const businessCount = await prisma.business.count();
|
||||||
|
const pendingCount = await prisma.business.count({ where: { verified: false } });
|
||||||
|
const commentsCount = await prisma.comment.count();
|
||||||
|
|
||||||
|
// Aggregate views
|
||||||
|
const aggregateResult = await prisma.business.aggregate({
|
||||||
|
_sum: {
|
||||||
|
viewCount: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const totalViews = aggregateResult._sum.viewCount || 0;
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const stats = await getStats();
|
||||||
|
return <DashboardClient stats={stats} />;
|
||||||
|
}
|
||||||
20
admin/src/app/event/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import EventForm from "@/components/EventForm";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditEventPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EventForm initialData={event} />;
|
||||||
|
}
|
||||||
5
admin/src/app/event/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EventForm from "@/components/EventForm";
|
||||||
|
|
||||||
|
export default function NewEventPage() {
|
||||||
|
return <EventForm />;
|
||||||
|
}
|
||||||
BIN
admin/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
301
admin/src/app/globals.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0f172a;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--primary: #6366f1;
|
||||||
|
--secondary: #1e293b;
|
||||||
|
--accent: #10b981;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: #334155;
|
||||||
|
--card: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background-color: var(--card);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-verify {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-verify:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-unverify {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-unverify:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-verified {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background-color: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-star:hover, .btn-star.active {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quill Dark Theme Overrides */
|
||||||
|
.quill-dark-wrapper .ql-toolbar.ql-snow {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
border-color: var(--border);
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-container.ql-snow {
|
||||||
|
background-color: var(--background);
|
||||||
|
border-color: var(--border);
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
min-height: 300px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-stroke {
|
||||||
|
stroke: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-fill {
|
||||||
|
fill: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-picker {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-picker-options {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-picker-item:hover,
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-picker-label:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-active {
|
||||||
|
color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow .ql-active .ql-stroke {
|
||||||
|
stroke: var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor.ql-blank::before {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor {
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
word-break: normal;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for the Divider (HR) */
|
||||||
|
.quill-dark-wrapper .ql-editor hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider {
|
||||||
|
width: 32px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
16
admin/src/app/interview/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import InterviewForm from "@/components/InterviewForm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function EditInterviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const interview = await prisma.interview.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!interview) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InterviewForm initialData={interview} />;
|
||||||
|
}
|
||||||
5
admin/src/app/interview/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import InterviewForm from "@/components/InterviewForm";
|
||||||
|
|
||||||
|
export default function NewInterviewPage() {
|
||||||
|
return <InterviewForm />;
|
||||||
|
}
|
||||||
33
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "AfroAdmin - Administration Afrohub",
|
||||||
|
description: "Panneau d'administration pour la plateforme Afrohub",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="admin-content flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
449
admin/src/app/metrics/page.tsx
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
MousePointer2,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
ArrowUpRight,
|
||||||
|
TrendingUp,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
Search,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Calendar
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
|
||||||
|
if (value === null) return null;
|
||||||
|
const isPositive = value > 0;
|
||||||
|
const isZero = Math.abs(value) < 0.1;
|
||||||
|
|
||||||
|
if (isZero) return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
|
||||||
|
= 0%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||||
|
isPositive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'
|
||||||
|
}`}>
|
||||||
|
{isPositive ? <ArrowUp className="w-2 h-2 mr-0.5" /> : <ArrowDown className="w-2 h-2 mr-0.5" />}
|
||||||
|
{Math.abs(value).toFixed(1)}{isPercent ? '%' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date = new Date();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 1. Determine Current Range
|
||||||
|
if (from && to) {
|
||||||
|
startDate = new Date(from);
|
||||||
|
endDate = new Date(to);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
} else {
|
||||||
|
if (range === 'day') startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
else if (range === 'month') startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
else if (range === 'all') startDate = new Date(0); // All time
|
||||||
|
else startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Default Week
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWhere = { createdAt: { gte: startDate, lte: endDate } };
|
||||||
|
|
||||||
|
// 2. Determine Previous Range (for comparison)
|
||||||
|
let prevWhere: any = null;
|
||||||
|
if (range !== 'all') {
|
||||||
|
const duration = endDate.getTime() - startDate.getTime();
|
||||||
|
const prevEndDate = new Date(startDate.getTime() - 1);
|
||||||
|
const prevStartDate = new Date(startDate.getTime() - duration);
|
||||||
|
prevWhere = { createdAt: { gte: prevStartDate, lte: prevEndDate } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch Current Data
|
||||||
|
const topPages = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['path'],
|
||||||
|
where: { type: 'PAGE_VIEW', ...currentWhere },
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const topCountries = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['country'],
|
||||||
|
where: currentWhere,
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['device'],
|
||||||
|
where: currentWhere,
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const browsers = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['browser'],
|
||||||
|
where: currentWhere,
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEvents = await prisma.analyticsEvent.count({ where: currentWhere });
|
||||||
|
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['ip'],
|
||||||
|
where: currentWhere
|
||||||
|
});
|
||||||
|
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||||
|
|
||||||
|
// 4. Fetch Previous Data (for Deltas)
|
||||||
|
let prevTotalEvents = 0;
|
||||||
|
let prevUniqueVisitors = 0;
|
||||||
|
let prevMobileCount = 0;
|
||||||
|
|
||||||
|
if (prevWhere) {
|
||||||
|
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
|
||||||
|
const prevVisitorsRes = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['ip'],
|
||||||
|
where: prevWhere
|
||||||
|
});
|
||||||
|
prevUniqueVisitors = prevVisitorsRes.length;
|
||||||
|
|
||||||
|
const prevDevices = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['device'],
|
||||||
|
where: prevWhere,
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Recent Unique IPs
|
||||||
|
const recentUniqueIPs = await prisma.analyticsEvent.groupBy({
|
||||||
|
by: ['ip'],
|
||||||
|
where: currentWhere,
|
||||||
|
_max: { createdAt: true },
|
||||||
|
orderBy: { _max: { createdAt: 'desc' } },
|
||||||
|
take: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentIPs = await Promise.all(
|
||||||
|
recentUniqueIPs.map(async (item) => {
|
||||||
|
if (!item.ip || !item._max.createdAt) return null;
|
||||||
|
return await prisma.analyticsEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
ip: item.ip,
|
||||||
|
createdAt: item._max.createdAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
topPages,
|
||||||
|
topCountries,
|
||||||
|
devices,
|
||||||
|
browsers,
|
||||||
|
totalEvents,
|
||||||
|
uniqueVisitors,
|
||||||
|
recentIPs,
|
||||||
|
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, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||||
|
|
||||||
|
const rangeLabels: Record<string, string> = {
|
||||||
|
day: 'Dernières 24h',
|
||||||
|
week: '7 derniers jours',
|
||||||
|
month: '30 derniers jours',
|
||||||
|
all: 'Depuis le début'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
|
||||||
|
<p className="text-slate-400">Comportement des utilisateurs et engagement géographique en temps réel.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
|
||||||
|
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls: Shortcuts + Custom Calendar */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||||
|
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
{Object.entries(rangeLabels).map(([id, label]) => (
|
||||||
|
<Link
|
||||||
|
key={id}
|
||||||
|
href={`/metrics?range=${id}`}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
range === id && !searchParams.from
|
||||||
|
? 'bg-indigo-600 text-white shadow-lg'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/metrics" className="flex items-center gap-2 bg-slate-900/50 p-2 rounded-xl border border-slate-800">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-500 ml-2" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="from"
|
||||||
|
defaultValue={searchParams.from}
|
||||||
|
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-600">→</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="to"
|
||||||
|
defaultValue={searchParams.to}
|
||||||
|
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] uppercase font-bold px-3 py-1.5 rounded-lg transition-colors">
|
||||||
|
Filtrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="card border-t-4 border-indigo-500">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
||||||
|
<Users className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
|
||||||
|
<ComparisonBadge value={deltas.visitors} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
|
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card border-t-4 border-emerald-500">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Eye className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<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 text-sm font-medium">Événements enregistrés</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||||
|
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card border-t-4 border-amber-500">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
||||||
|
<Zap className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}%
|
||||||
|
</div>
|
||||||
|
<ComparisonBadge value={deltas.mobile} />
|
||||||
|
</div>
|
||||||
|
</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 className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Top Countries Table */}
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<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 (Restored) */}
|
||||||
|
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-5 h-5 text-rose-400" /> Pages les plus visitées
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">URL / Chemin</th>
|
||||||
|
<th className="px-6 py-4 text-right">Vues</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{topPages.map((page) => (
|
||||||
|
<tr key={page.path} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="px-6 py-4 text-sm font-mono text-slate-300">{page.path}</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
||||||
|
{page._count.id}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent IP Connections 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">
|
||||||
|
<Users className="w-5 h-5 text-indigo-400" /> Dernières Connexions (IP)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<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">Adresse IP</th>
|
||||||
|
<th className="px-6 py-4">Localisation</th>
|
||||||
|
<th className="px-6 py-4 text-center">Appareil / OS</th>
|
||||||
|
<th className="px-6 py-4">Navigateur</th>
|
||||||
|
<th className="px-6 py-4">Dernière Page</th>
|
||||||
|
<th className="px-6 py-4 text-right">Date / Heure</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{recentIPs.map((e, idx) => e && (
|
||||||
|
<tr key={`${e.ip}-${idx}`} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-mono text-xs text-indigo-400 bg-indigo-400/5 px-2 py-1 rounded border border-indigo-400/10 whitespace-nowrap">
|
||||||
|
{e.ip || '---.---.---.---'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm text-slate-200 font-bold">{e.country || 'Inconnu'}</span>
|
||||||
|
<span className="text-[10px] text-slate-500">{e.city || '---'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="inline-flex flex-col items-center">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-bold mb-1 ${
|
||||||
|
e.device === 'Mobile' ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
|
||||||
|
}`}>
|
||||||
|
{e.device}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">{e.os}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-300">
|
||||||
|
{e.browser}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-xs font-mono text-slate-400 truncate max-w-[150px] block" title={e.path}>
|
||||||
|
{e.path}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-xs whitespace-nowrap">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-300">{new Date(e.createdAt).toLocaleDateString('fr-FR')}</span>
|
||||||
|
<span className="text-slate-500">{new Date(e.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
admin/src/app/moderation/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { getReports, getPendingRatings } from '@/app/actions/moderation';
|
||||||
|
import ReportActionButtons from '@/components/ReportActionButtons';
|
||||||
|
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
|
||||||
|
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({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
|
const { tab = 'messages' } = await searchParams;
|
||||||
|
const reports = await getReports();
|
||||||
|
const pendingRatings = await getPendingRatings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
|
||||||
|
<Flag className="text-red-500" />
|
||||||
|
Centre de Modération
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">Gérer les signalements et valider les nouveaux avis.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<Link
|
||||||
|
href="/moderation?tab=messages"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'messages' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
Messages ({reports.filter(r => r.status === 'PENDING').length})
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/moderation?tab=avis"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'avis' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Avis ({pendingRatings.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{tab === 'messages' && (
|
||||||
|
<>
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<div className="card p-10 text-center text-slate-500">
|
||||||
|
Aucun signalement en attente.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
reports.map((report) => (
|
||||||
|
<div key={report.id} className={`card border-l-4 ${report.status === 'PENDING' ? 'border-red-500' : report.status === 'RESOLVED' ? 'border-green-500' : 'border-slate-600'}`}>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="md:w-1/3 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{new Date(report.createdAt).toLocaleString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-xs uppercase">{report.reporter.name.charAt(0)}</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{report.reporter.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{report.reporter.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison</div>
|
||||||
|
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2">
|
||||||
|
<span className={`badge ${
|
||||||
|
report.status === 'PENDING' ? 'badge-pending' : 'badge-verified'
|
||||||
|
}`}>
|
||||||
|
{report.status === 'PENDING' ? 'En attente' : 'Terminé'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 p-4">
|
||||||
|
<Link
|
||||||
|
href={`/messages/${report.message.conversationId}`}
|
||||||
|
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
Voir la conversation complète →
|
||||||
|
</Link>
|
||||||
|
<div className="p-3 bg-slate-800/80 rounded-lg border border-red-500/20 mb-4">
|
||||||
|
<div className="text-[10px] text-red-400 font-bold uppercase mb-1">Message Signalé</div>
|
||||||
|
<div className="text-sm text-white">"{report.message.content}"</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-1">Auteur: {report.message.sender.name}</div>
|
||||||
|
</div>
|
||||||
|
{report.status === 'PENDING' && (
|
||||||
|
<div className="flex flex-col gap-4 mt-6 pt-6 border-t border-slate-700">
|
||||||
|
<ReportActionButtons reportId={report.id} />
|
||||||
|
<ModerationSuspensionButton
|
||||||
|
userId={report.message.senderId}
|
||||||
|
userName={report.message.sender.name}
|
||||||
|
isSuspended={!!report.message.sender.isSuspended}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
admin/src/app/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LayoutDashboard, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
|
||||||
|
<span className="text-4xl font-bold text-indigo-400">404</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
|
||||||
|
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a été déplacée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
|
Retour au Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
Page précédente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
admin/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
134
admin/src/app/plans/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"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);
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
|
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="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2 font-serif">Gestion des Tarifs & Forfaits</h1>
|
||||||
|
<p className="text-slate-400">Modifiez les noms, prix et limites d'offres affichés sur le site.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Billing */}
|
||||||
|
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'monthly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Mensuel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all ${billingCycle === 'yearly' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Annuel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{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 font-serif">{plan.name}</h3>
|
||||||
|
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price & Limit */}
|
||||||
|
<div className="p-6 bg-slate-950/30 flex-1">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-3xl font-bold text-white mb-1">
|
||||||
|
{billingCycle === 'yearly'
|
||||||
|
? (plan.yearlyPriceXOF || plan.priceXOF)
|
||||||
|
: plan.priceXOF
|
||||||
|
}
|
||||||
|
<span className="text-xs text-slate-500 font-normal ml-2">
|
||||||
|
/ {billingCycle === 'yearly' ? 'an' : 'mois'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{billingCycle === 'yearly' && plan.yearlyPriceXOF && (
|
||||||
|
<div className="text-xs text-emerald-500 font-bold mb-2">PRIX ANNUEL ACTIF</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-8">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
|
||||||
|
{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}
|
||||||
|
currentCycle={billingCycle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
529
admin/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { Save, Globe, Mail, Phone, MapPin, Share2, Link as LinkIcon, Loader2, Newspaper, Briefcase } from 'lucide-react';
|
||||||
|
import { getSiteSettings, updateSiteSettings } from '@/app/actions/settings';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { getBlogPosts } from '@/app/actions/blog';
|
||||||
|
import RichTextEditor from '@/components/RichTextEditor';
|
||||||
|
|
||||||
|
const BUSINESS_CATEGORIES = [
|
||||||
|
"Technologie & IT",
|
||||||
|
"Agriculture & Agrobusiness",
|
||||||
|
"Mode & Textile",
|
||||||
|
"Cosmétique & Beauté",
|
||||||
|
"Services aux entreprises",
|
||||||
|
"Restauration & Alimentation",
|
||||||
|
"Construction & BTP",
|
||||||
|
"Éducation & Formation",
|
||||||
|
"Santé & Bien-être",
|
||||||
|
"Artisanat & Déco",
|
||||||
|
"Tourisme & Loisirs",
|
||||||
|
"Finance & Assurance",
|
||||||
|
"Immobilier",
|
||||||
|
"Transport & Logistique",
|
||||||
|
"Média & Communication",
|
||||||
|
"Autre"
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOG_CATEGORIES = [
|
||||||
|
"Actualité",
|
||||||
|
"Conseils",
|
||||||
|
"Success Story",
|
||||||
|
"Événement",
|
||||||
|
"Formation"
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [settings, setSettings] = useState<any>(null);
|
||||||
|
const [allPosts, setAllPosts] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||||
|
const [selectedHomeCategories, setSelectedHomeCategories] = useState<string[]>([]);
|
||||||
|
const [selectedBlogCategories, setSelectedBlogCategories] = useState<string[]>([]);
|
||||||
|
const [resetPasswordTemplate, setResetPasswordTemplate] = useState('');
|
||||||
|
const [verifyEmailTemplate, setVerifyEmailTemplate] = useState('');
|
||||||
|
|
||||||
|
// Use a state for the tab if we want it to be fully client-side reactive without full reload
|
||||||
|
// But since the user wants it "like moderation center", I'll use URL search params
|
||||||
|
const [currentTab, setCurrentTab] = useState('general');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
const [settingsData, postsData] = await Promise.all([
|
||||||
|
getSiteSettings(),
|
||||||
|
getBlogPosts()
|
||||||
|
]);
|
||||||
|
setSettings(settingsData);
|
||||||
|
setAllPosts(postsData);
|
||||||
|
setSelectedPostIds(settingsData?.homeBlogIds || []);
|
||||||
|
setSelectedHomeCategories(settingsData?.homeCategories || []);
|
||||||
|
setSelectedBlogCategories(settingsData?.homeBlogCategories || []);
|
||||||
|
setResetPasswordTemplate(settingsData?.resetPasswordTemplate || '');
|
||||||
|
setVerifyEmailTemplate(settingsData?.verifyEmailTemplate || '');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const data = {
|
||||||
|
siteName: formData.get('siteName'),
|
||||||
|
siteSlogan: formData.get('siteSlogan'),
|
||||||
|
contactEmail: formData.get('contactEmail'),
|
||||||
|
contactPhone: formData.get('contactPhone'),
|
||||||
|
address: formData.get('address'),
|
||||||
|
facebookUrl: formData.get('facebookUrl'),
|
||||||
|
twitterUrl: formData.get('twitterUrl'),
|
||||||
|
instagramUrl: formData.get('instagramUrl'),
|
||||||
|
linkedinUrl: formData.get('linkedinUrl'),
|
||||||
|
footerText: formData.get('footerText'),
|
||||||
|
homeBlogShow: formData.get('homeBlogShow') === 'on',
|
||||||
|
homeBlogTitle: formData.get('homeBlogTitle'),
|
||||||
|
homeBlogSubtitle: formData.get('homeBlogSubtitle'),
|
||||||
|
homeBlogCount: parseInt(formData.get('homeBlogCount') as string || '3'),
|
||||||
|
homeBlogSelection: formData.get('homeBlogSelection'),
|
||||||
|
homeBlogIds: selectedPostIds,
|
||||||
|
homeBlogCategories: selectedBlogCategories,
|
||||||
|
homeCategories: selectedHomeCategories,
|
||||||
|
resetPasswordSubject: formData.get('resetPasswordSubject'),
|
||||||
|
resetPasswordTemplate: resetPasswordTemplate,
|
||||||
|
verifyEmailSubject: formData.get('verifyEmailSubject'),
|
||||||
|
verifyEmailTemplate: verifyEmailTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateSiteSettings(data);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Paramètres mis à jour avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error("Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePostId = (id: string) => {
|
||||||
|
setSelectedPostIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHomeCategory = (cat: string) => {
|
||||||
|
setSelectedHomeCategories(prev =>
|
||||||
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBlogCategory = (cat: string) => {
|
||||||
|
setSelectedBlogCategories(prev =>
|
||||||
|
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Configuration du Site</h1>
|
||||||
|
<p className="text-slate-400">Gérez les informations globales, les sections de l'accueil et les modèles d'emails.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Switcher */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('general')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'general' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Général
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('emails')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${currentTab === 'emails' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
Configuration Mail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{currentTab === 'general' && (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{/* Section Générale */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-indigo-400" />
|
||||||
|
<h2 className="font-semibold text-white">Informations Générales</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nom du site</label>
|
||||||
|
<input
|
||||||
|
name="siteName"
|
||||||
|
defaultValue={settings?.siteName}
|
||||||
|
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">Slogan du site</label>
|
||||||
|
<input
|
||||||
|
name="siteSlogan"
|
||||||
|
defaultValue={settings?.siteSlogan}
|
||||||
|
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="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Texte du pied de page (Footer)</label>
|
||||||
|
<input
|
||||||
|
name="footerText"
|
||||||
|
defaultValue={settings?.footerText}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Page d'Accueil - Secteurs */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Briefcase className="w-5 h-5 text-yellow-400" />
|
||||||
|
<h2 className="font-semibold text-white">Secteurs en Vedette (Home)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-slate-400 mb-4">Sélectionnez les secteurs d'activité à afficher sur la page d'accueil.</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{BUSINESS_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleHomeCategory(cat)}
|
||||||
|
className={`text-left p-2 rounded-lg border text-xs transition-all ${
|
||||||
|
selectedHomeCategories.includes(cat)
|
||||||
|
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||||
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Page d'Accueil - Blog */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Newspaper className="w-5 h-5 text-purple-400" />
|
||||||
|
<h2 className="font-semibold text-white">Section Blog sur l'Accueil</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-indigo-600/10 border border-indigo-500/20 rounded-xl">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="homeBlogShow"
|
||||||
|
id="homeBlogShow"
|
||||||
|
defaultChecked={settings?.homeBlogShow}
|
||||||
|
className="w-5 h-5 rounded border-slate-700 bg-slate-950 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="homeBlogShow" className="text-sm font-medium text-slate-300 font-bold uppercase tracking-wider">Activer cette section</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre de la section</label>
|
||||||
|
<input
|
||||||
|
name="homeBlogTitle"
|
||||||
|
defaultValue={settings?.homeBlogTitle}
|
||||||
|
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">Sous-titre</label>
|
||||||
|
<input
|
||||||
|
name="homeBlogSubtitle"
|
||||||
|
defaultValue={settings?.homeBlogSubtitle}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 pt-6">
|
||||||
|
<label className="text-sm font-medium text-slate-400 block mb-3">Mode de sélection des articles</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'latest' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="latest" defaultChecked={settings?.homeBlogSelection === 'latest'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Les plus récents</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Affiche automatiquement les X derniers articles.</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'manual' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="manual" defaultChecked={settings?.homeBlogSelection === 'manual'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Sélection manuelle</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Choisissez précisément les articles à mettre en avant.</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex flex-col p-4 rounded-xl border cursor-pointer transition-all ${settings?.homeBlogSelection === 'category' ? 'bg-indigo-600/10 border-indigo-500' : 'bg-slate-950 border-slate-700 hover:border-slate-500'}`}>
|
||||||
|
<input type="radio" name="homeBlogSelection" value="category" defaultChecked={settings?.homeBlogSelection === 'category'} className="sr-only" />
|
||||||
|
<span className="text-white font-bold text-sm">Par catégorie</span>
|
||||||
|
<span className="text-xs text-slate-500 mt-1">Affiche les articles appartenant à certaines catégories.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nombre maximum d'articles</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="homeBlogCount"
|
||||||
|
defaultValue={settings?.homeBlogCount}
|
||||||
|
min="1" max="12"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Selection UI */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Articles sélectionnés (si mode manuel)</label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 max-h-60 overflow-y-auto p-2 bg-slate-950 rounded-xl border border-slate-700">
|
||||||
|
{allPosts.map(post => (
|
||||||
|
<button
|
||||||
|
key={post.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePostId(post.id)}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg text-sm transition-all ${
|
||||||
|
selectedPostIds.includes(post.id)
|
||||||
|
? 'bg-indigo-600/20 border-indigo-500/50 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{post.title}</span>
|
||||||
|
{selectedPostIds.includes(post.id) && <span className="text-indigo-400 font-bold text-xs">SÉLECTIONNÉ</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Selection UI */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Catégories d'articles (si mode catégorie)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BLOG_CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleBlogCategory(cat)}
|
||||||
|
className={`px-4 py-2 rounded-full border text-xs transition-all ${
|
||||||
|
selectedBlogCategories.includes(cat)
|
||||||
|
? 'bg-purple-600 border-purple-500 text-white'
|
||||||
|
: 'bg-slate-950 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Contact */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-emerald-400" />
|
||||||
|
<h2 className="font-semibold text-white">Coordonnées de Contact</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Email de support</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="contactEmail"
|
||||||
|
defaultValue={settings?.contactEmail}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Téléphone de contact</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="contactPhone"
|
||||||
|
defaultValue={settings?.contactPhone}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Adresse physique</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="address"
|
||||||
|
defaultValue={settings?.address}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Réseaux Sociaux */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Share2 className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="font-semibold text-white">Réseaux Sociaux</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Facebook URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="facebookUrl"
|
||||||
|
defaultValue={settings?.facebookUrl}
|
||||||
|
placeholder="https://facebook.com/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Twitter (X) URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="twitterUrl"
|
||||||
|
defaultValue={settings?.twitterUrl}
|
||||||
|
placeholder="https://twitter.com/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Instagram URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="instagramUrl"
|
||||||
|
defaultValue={settings?.instagramUrl}
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">LinkedIn URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Share2 className="absolute left-3 top-3.5 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="linkedinUrl"
|
||||||
|
defaultValue={settings?.linkedinUrl}
|
||||||
|
placeholder="https://linkedin.com/in/..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'emails' && (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{/* Section Mail */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-orange-400" />
|
||||||
|
<h2 className="font-semibold text-white">Gestion des Emails</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Password Reset */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-wider">Réinitialisation de mot de passe</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="resetPasswordSubject"
|
||||||
|
defaultValue={settings?.resetPasswordSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={resetPasswordTemplate}
|
||||||
|
onChange={setResetPasswordTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{resetUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800"></div>
|
||||||
|
|
||||||
|
{/* Account Verification */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider">Vérification de compte</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Objet de l'email</label>
|
||||||
|
<input
|
||||||
|
name="verifyEmailSubject"
|
||||||
|
defaultValue={settings?.verifyEmailSubject}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Modèle d'email</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={verifyEmailTemplate}
|
||||||
|
onChange={setVerifyEmailTemplate}
|
||||||
|
placeholder="Contenu de l'email..."
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Variables : <code className="text-indigo-400">{"{name}"}</code>, <code className="text-indigo-400">{"{verifyUrl}"}</code>, <code className="text-indigo-400">{"{siteName}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white px-8 py-3 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Enregistrer les modifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Briefcase,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
Cpu,
|
||||||
|
Sprout,
|
||||||
|
Shirt,
|
||||||
|
Utensils,
|
||||||
|
HardHat,
|
||||||
|
Stethoscope,
|
||||||
|
GraduationCap,
|
||||||
|
Palette,
|
||||||
|
Plane,
|
||||||
|
Truck,
|
||||||
|
Wallet,
|
||||||
|
Zap,
|
||||||
|
Leaf,
|
||||||
|
Camera,
|
||||||
|
Music,
|
||||||
|
ShoppingBag,
|
||||||
|
Heart,
|
||||||
|
Home,
|
||||||
|
Hash,
|
||||||
|
Tags,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
User as UserIcon,
|
||||||
|
MapPin,
|
||||||
|
BookOpen,
|
||||||
|
Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
|
||||||
|
import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const ICON_LIST = [
|
||||||
|
{ name: 'Briefcase', icon: Briefcase },
|
||||||
|
{ name: 'Cpu', icon: Cpu },
|
||||||
|
{ name: 'Sprout', icon: Sprout },
|
||||||
|
{ name: 'Shirt', icon: Shirt },
|
||||||
|
{ name: 'Sparkles', icon: Sparkles },
|
||||||
|
{ name: 'Utensils', icon: Utensils },
|
||||||
|
{ name: 'HardHat', icon: HardHat },
|
||||||
|
{ name: 'Stethoscope', icon: Stethoscope },
|
||||||
|
{ name: 'GraduationCap', icon: GraduationCap },
|
||||||
|
{ name: 'Palette', icon: Palette },
|
||||||
|
{ name: 'Plane', icon: Plane },
|
||||||
|
{ name: 'Truck', icon: Truck },
|
||||||
|
{ name: 'Wallet', icon: Wallet },
|
||||||
|
{ name: 'Zap', icon: Zap },
|
||||||
|
{ name: 'Leaf', icon: Leaf },
|
||||||
|
{ name: 'Camera', icon: Camera },
|
||||||
|
{ name: 'Music', icon: Music },
|
||||||
|
{ name: 'ShoppingBag', icon: ShoppingBag },
|
||||||
|
{ name: 'Heart', icon: Heart },
|
||||||
|
{ name: 'Home', icon: Home },
|
||||||
|
{ name: 'Tags', icon: Tags },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'categories' | 'tags';
|
||||||
|
|
||||||
|
export default function TaxonomiesClient() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('categories');
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Category Form State
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag Form State
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [isEditingUsageName, setIsEditingUsageName] = useState(false);
|
||||||
|
const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [catsRes, tagsRes, sugRes] = await Promise.all([
|
||||||
|
getCategories(),
|
||||||
|
getAllTags(),
|
||||||
|
getSuggestedCategories()
|
||||||
|
]);
|
||||||
|
setCategories(catsRes);
|
||||||
|
setTags(tagsRes);
|
||||||
|
setSuggestions(sugRes);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Erreur de chargement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategorySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = editingCategory
|
||||||
|
? await updateCategory(editingCategory.id, formData)
|
||||||
|
: await createCategory(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
|
||||||
|
setShowAddModal(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagDelete = async (tag: string) => {
|
||||||
|
if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
|
||||||
|
const result = await deleteTagGlobally(tag);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Tag supprimé partout");
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowUsage = async (tag: string) => {
|
||||||
|
setLoadingUsage(true);
|
||||||
|
const result = await getTagUsage(tag);
|
||||||
|
if (result.success) {
|
||||||
|
setUsagePreview({ tag, data: result.data });
|
||||||
|
}
|
||||||
|
setLoadingUsage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-slate-500">Chargement...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
|
||||||
|
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('categories')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Catégories
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tags')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Tags / Mots-clés
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'categories' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Tags className="w-5 h-5 text-indigo-400" />
|
||||||
|
Liste des catégories ({categories.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
|
||||||
|
className="btn-verify flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Nouvelle catégorie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="card p-4 flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
|
||||||
|
<IconComp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-bold">{cat.name}</h3>
|
||||||
|
<p className="text-xs text-slate-500">/{cat.slug}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Hash className="w-5 h-5 text-indigo-400" />
|
||||||
|
Tags utilisés ({tags.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
onClick={() => handleShowUsage(tag)}
|
||||||
|
className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||||
|
<Trash2
|
||||||
|
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Ajout/Modif Catégorie */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
|
||||||
|
<form onSubmit={handleCategorySubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
|
||||||
|
{ICON_LIST.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, icon: item.name })}
|
||||||
|
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-8">
|
||||||
|
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
|
||||||
|
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
{editingCategory ? 'Mettre à jour' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Modal */}
|
||||||
|
{usagePreview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
|
||||||
|
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Hash className="w-6 h-6 text-indigo-500" />
|
||||||
|
{isEditingUsageName ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await renameTagGlobally(usagePreview.tag, newTagName);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("Renommé");
|
||||||
|
setUsagePreview({ ...usagePreview, tag: newTagName });
|
||||||
|
setIsEditingUsageName(false);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 bg-emerald-600 text-white rounded-lg"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{!isEditingUsageName && (
|
||||||
|
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||||
|
{/* Blog Posts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
|
||||||
|
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.blogPosts.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
|
||||||
|
) : usagePreview.data.blogPosts.map((post: any) => (
|
||||||
|
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
|
||||||
|
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interviews */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.interviews.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
|
||||||
|
) : usagePreview.data.interviews.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-amber-500/10 rounded-lg">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.events.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
|
||||||
|
) : usagePreview.data.events.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
admin/src/app/taxonomies/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import TaxonomiesClient from "./TaxonomiesClient";
|
||||||
|
|
||||||
|
export default function TaxonomiesPage() {
|
||||||
|
return <TaxonomiesClient />;
|
||||||
|
}
|
||||||
300
admin/src/app/users/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getClients } from "@/app/actions/user";
|
||||||
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
|
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||||
|
import PlanSelector from '@/components/PlanSelector';
|
||||||
|
import ClientActions from "@/components/ClientActions";
|
||||||
|
import ManualVerifyButton from "@/components/ManualVerifyButton";
|
||||||
|
import { ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
async function getBusinesses() {
|
||||||
|
return await prisma.business.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UsersPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>
|
||||||
|
}) {
|
||||||
|
const { tab = 'entrepreneurs' } = await searchParams;
|
||||||
|
|
||||||
|
// Data for Entrepreneurs
|
||||||
|
const businesses = await getBusinesses();
|
||||||
|
const pendingCount = businesses.filter((b: any) =>
|
||||||
|
!b.verified &&
|
||||||
|
!b.isSuspended &&
|
||||||
|
['BOOSTER', 'EMPIRE'].includes(b.plan)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Data for Clients/Visitors
|
||||||
|
const clientsResult = await getClients();
|
||||||
|
const clients = (clientsResult.success ? (clientsResult.data || []) : []) as any[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Utilisateurs</h1>
|
||||||
|
<p className="text-slate-400">Gérer les entrepreneurs et les visiteurs de la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
|
||||||
|
<Link
|
||||||
|
href="/users?tab=entrepreneurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'entrepreneurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Entrepreneurs ({businesses.length})
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/users?tab=visiteurs"
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'visiteurs' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Visiteurs ({clients.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'entrepreneurs' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse max-w-fit">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
|
||||||
|
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entreprise</th>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Propriétaire</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-center">Forfait</th>
|
||||||
|
<th className="text-center">Mise en avant</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{businesses.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="text-center py-10 text-slate-500">
|
||||||
|
Aucun entrepreneur trouvé.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
businesses.map((business: any) => (
|
||||||
|
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
|
{business.logoUrl ? (
|
||||||
|
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">LOGO</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-white">{business.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-sm text-slate-300">{business.category}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-sm text-white">{business.owner.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{business.owner.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={business.owner.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{business.owner?.isSuspended ? (
|
||||||
|
<span className="badge badge-error flex items-center gap-1">
|
||||||
|
<ShieldX className="w-3 h-3" /> Compte Suspendu
|
||||||
|
</span>
|
||||||
|
) : business.isSuspended ? (
|
||||||
|
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
|
||||||
|
<ShieldAlert className="w-3 h-3" /> Shop Masqué
|
||||||
|
</span>
|
||||||
|
) : business.verified ? (
|
||||||
|
<span className="badge badge-verified">Vérifié</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-pending">En attente</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<FeaturedModal business={business} />
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<ToggleVerifyButton id={business.id} verified={business.verified} />
|
||||||
|
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
|
||||||
|
<EntrepreneurActions
|
||||||
|
businessId={business.id}
|
||||||
|
businessName={business.name}
|
||||||
|
userId={business.owner.id}
|
||||||
|
userName={business.owner.name}
|
||||||
|
isBusinessSuspended={!!business.isSuspended}
|
||||||
|
isUserSuspended={!!business.owner?.isSuspended}
|
||||||
|
metaTitle={business.metaTitle}
|
||||||
|
metaDescription={business.metaDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Utilisateur</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Vérification</th>
|
||||||
|
<th>Localisation</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th className="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Users className="w-8 h-8 opacity-20" />
|
||||||
|
<p>Aucun visiteur trouvé.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
clients.map((client: any) => (
|
||||||
|
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
|
||||||
|
{client.avatar ? (
|
||||||
|
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
client.name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold">{client.name}</p>
|
||||||
|
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Mail className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.email}
|
||||||
|
</div>
|
||||||
|
{client.phone && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Phone className="w-3 h-3 text-slate-500" />
|
||||||
|
{client.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{client.emailVerified ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
|
</span>
|
||||||
|
<ManualVerifyButton userId={client.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{client.location || "Non spécifié"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{client.isSuspended ? (
|
||||||
|
<>
|
||||||
|
<span className="badge badge-error">Compte Suspendu</span>
|
||||||
|
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
|
||||||
|
{client.suspensionReason}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : client.businesses ? (
|
||||||
|
<span className="badge badge-pending">Shop Inactif</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-slate-800 text-slate-400">Actif</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
admin/src/components/BlogForm.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
||||||
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
imageUrl: string;
|
||||||
|
slug?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
|
publishedAt?: Date | string | null;
|
||||||
|
status?: ContentStatus;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
slug: formData.get('slug') as string,
|
||||||
|
excerpt: formData.get('excerpt') as string,
|
||||||
|
content: content,
|
||||||
|
author: formData.get('author') as string,
|
||||||
|
imageUrl: imageUrl, // Use state value
|
||||||
|
tags: tags, // Use state value
|
||||||
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.imageUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateBlogPost(initialData.id, data)
|
||||||
|
: await createBlogPost(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
||||||
|
router.push('/blog');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={initialData?.title}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: L'essor de la Tech en Afrique"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Auteur</label>
|
||||||
|
<input
|
||||||
|
name="author"
|
||||||
|
defaultValue={initialData?.author}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Jean Dupont"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Image de couverture"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
name="imageUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
|
||||||
|
<textarea
|
||||||
|
name="excerpt"
|
||||||
|
defaultValue={initialData?.excerpt}
|
||||||
|
required
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Un court résumé de l'article..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="Rédigez votre article ici..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEO SECTION */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
||||||
|
|
||||||
|
<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">Slug (URL personnalisée)</label>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
defaultValue={initialData?.slug || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="ex: lessor-de-la-tech-afrique"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<TagInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
suggestions={allExistingTags}
|
||||||
|
label="Mots-clés (tags)"
|
||||||
|
placeholder="tech, innovation, afrique..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
|
<input
|
||||||
|
name="metaTitle"
|
||||||
|
defaultValue={initialData?.metaTitle || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Titre optimisé pour les moteurs de recherche"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
|
<textarea
|
||||||
|
name="metaDescription"
|
||||||
|
defaultValue={initialData?.metaDescription || ''}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
admin/src/components/BusinessSeoModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from 'react';
|
||||||
|
import { X, Save, Loader2, Globe } from 'lucide-react';
|
||||||
|
import { updateBusinessSeo } from '@/app/actions/business';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface BusinessSeoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
business: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BusinessSeoModal({ isOpen, onClose, business }: BusinessSeoModalProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
||||||
|
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateBusinessSeo(business.id, {
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Configuration SEO mise à jour");
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la mise à jour");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
||||||
|
<div className="p-6 border-b border-slate-800 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||||
|
<Globe className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-white">SEO & Partage</h3>
|
||||||
|
<p className="text-xs text-slate-400">Personnaliser l'aperçu WhatsApp pour {business.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (WhatsApp/Google)</label>
|
||||||
|
<input
|
||||||
|
value={metaTitle}
|
||||||
|
onChange={(e) => setMetaTitle(e.target.value)}
|
||||||
|
placeholder={`Ex: ${business.name} - Expert en ...`}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Recommandé : Moins de 60 caractères.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description</label>
|
||||||
|
<textarea
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Décrivez l'entreprise en quelques mots pour attirer les clics..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Recommandé : Entre 140 et 160 caractères.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
|
||||||
|
<h4 className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest mb-2">Aperçu du partage</h4>
|
||||||
|
<div className="bg-slate-950 rounded-lg p-3 border border-slate-800">
|
||||||
|
<div className="text-sm font-bold text-indigo-400 line-clamp-1">{metaTitle || business.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 line-clamp-2 mt-1">{metaDescription || "Découvrez cette entreprise sur Afroprenariat..."}</div>
|
||||||
|
<div className="text-[10px] text-slate-600 mt-2">afroprenariat.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-3 rounded-xl border border-slate-800 text-slate-400 font-bold hover:bg-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex-[2] py-3 rounded-xl bg-indigo-600 text-white font-bold hover:bg-indigo-500 transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
admin/src/components/ClientActions.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||||
|
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import SuspensionModal from './SuspensionModal';
|
||||||
|
|
||||||
|
interface ClientActionsProps {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isSuspended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientActions({ userId, userName, isSuspended }: ClientActionsProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSuspend = async (reason: string) => {
|
||||||
|
const result = await suspendUser(userId, reason);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`${userName} a été suspendu.`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Échec de la suspension");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnsuspend = async () => {
|
||||||
|
const result = await unsuspendUser(userId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`${userName} a été réactivé.`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Échec de la réactivation");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSuspended) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleUnsuspend}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-green-500/20"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3 h-3" />
|
||||||
|
Réactiver
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-rose-500/20"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-3 h-3" />
|
||||||
|
Suspendre
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SuspensionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onConfirm={handleSuspend}
|
||||||
|
title="Suspendre le compte"
|
||||||
|
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
admin/src/components/DeleteBlogButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteBlogPost } from '@/app/actions/blog';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function DeleteBlogButton({ id }: { id: string }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Supprimer cet article ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteBlogPost(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Article supprimé");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
admin/src/components/DeleteCommentButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteComment } from '@/app/actions/comment';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteCommentButton({ id }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteComment(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Commentaire supprimé");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
admin/src/components/DeleteEventButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteEvent } from '@/app/actions/event';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteEventButton({ id }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteEvent(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Événement supprimé");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-2 text-slate-400 hover:text-rose-500 transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
admin/src/components/DeleteInterviewButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { deleteInterview } from '@/app/actions/interview';
|
||||||
|
import { Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function DeleteInterviewButton({ id }: { id: string }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Supprimer cette interview ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteInterview(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Interview supprimée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
admin/src/components/EntrepreneurActions.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ShieldAlert, ShieldCheck, ShoppingBag, User, Globe } from 'lucide-react';
|
||||||
|
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import SuspensionModal from './SuspensionModal';
|
||||||
|
import BusinessSeoModal from './BusinessSeoModal';
|
||||||
|
|
||||||
|
interface EntrepreneurActionsProps {
|
||||||
|
businessId: string;
|
||||||
|
businessName: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isBusinessSuspended: boolean;
|
||||||
|
isUserSuspended: boolean;
|
||||||
|
metaTitle?: string | null;
|
||||||
|
metaDescription?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntrepreneurActions({
|
||||||
|
businessId,
|
||||||
|
businessName,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
isBusinessSuspended,
|
||||||
|
isUserSuspended,
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
|
}: EntrepreneurActionsProps) {
|
||||||
|
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
|
||||||
|
const [seoModalOpen, setSeoModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async (reason: string) => {
|
||||||
|
if (modalType === 'USER') {
|
||||||
|
const result = await suspendUser(userId, reason);
|
||||||
|
if (result.success) toast.success(`Compte de ${userName} suspendu.`);
|
||||||
|
else toast.error(result.error || "Erreur");
|
||||||
|
} else if (modalType === 'BUSINESS') {
|
||||||
|
const result = await suspendBusiness(businessId, reason);
|
||||||
|
if (result.success) toast.success(`Boutique ${businessName} suspendue.`);
|
||||||
|
else toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
setModalType(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{/* SEO Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSeoModalOpen(true)}
|
||||||
|
className="btn-action bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white"
|
||||||
|
title="Modifier le SEO / Partage WhatsApp"
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">SEO</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Suspension */}
|
||||||
|
{isUserSuspended ? (
|
||||||
|
<button
|
||||||
|
onClick={() => unsuspendUser(userId)}
|
||||||
|
className="btn-action bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
|
||||||
|
title="Activer le compte"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">Compte</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setModalType('USER')}
|
||||||
|
className="btn-action bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white"
|
||||||
|
title="Suspendre le compte complet"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">Compte</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Business Suspension */}
|
||||||
|
{isBusinessSuspended ? (
|
||||||
|
<button
|
||||||
|
onClick={() => unsuspendBusiness(businessId)}
|
||||||
|
className="btn-action bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white"
|
||||||
|
title="Activer la boutique"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">Shop</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setModalType('BUSINESS')}
|
||||||
|
className="btn-action bg-orange-500/10 text-orange-500 hover:bg-orange-500 hover:text-white"
|
||||||
|
title="Suspendre uniquement la boutique"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] uppercase font-bold">Shop</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuspensionModal
|
||||||
|
isOpen={modalType !== null}
|
||||||
|
onClose={() => setModalType(null)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
title={modalType === 'USER' ? "Suspendre l'utilisateur" : "Suspendre la boutique"}
|
||||||
|
subtitle={modalType === 'USER'
|
||||||
|
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
|
||||||
|
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BusinessSeoModal
|
||||||
|
isOpen={seoModalOpen}
|
||||||
|
onClose={() => setSeoModalOpen(false)}
|
||||||
|
business={{
|
||||||
|
id: businessId,
|
||||||
|
name: businessName,
|
||||||
|
metaTitle,
|
||||||
|
metaDescription
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
admin/src/components/EventForm.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createEvent, updateEvent } from '@/app/actions/event';
|
||||||
|
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
slug: formData.get('slug') as string,
|
||||||
|
location: formData.get('location') as string,
|
||||||
|
date: new Date(formData.get('date') as string),
|
||||||
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
|
link: formData.get('link') as string || null,
|
||||||
|
description: description || '',
|
||||||
|
tags: tags, // Use state
|
||||||
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateEvent(initialData.id, data)
|
||||||
|
: await createEvent(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
|
||||||
|
router.push('/blog');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier l\'événement' : 'Nouvel Événement'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={initialData?.title}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Conférence AfroHub 2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="date"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Lieu</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="location"
|
||||||
|
defaultValue={initialData?.location}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Image (Affiche/Miniature)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
|
name="thumbnailUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="link"
|
||||||
|
defaultValue={initialData?.link}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
placeholder="Détails sur l'événement, programme, intervenants..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEO SECTION */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
||||||
|
|
||||||
|
<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">Slug (URL personnalisée)</label>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
defaultValue={initialData?.slug || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="ex: conference-afrohub-2026"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<TagInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
suggestions={allExistingTags}
|
||||||
|
label="Mots-clés (tags)"
|
||||||
|
placeholder="evenement, networking, afrique..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
|
<input
|
||||||
|
name="metaTitle"
|
||||||
|
defaultValue={initialData?.metaTitle || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Titre optimisé pour Google"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
|
<textarea
|
||||||
|
name="metaDescription"
|
||||||
|
defaultValue={initialData?.metaDescription || ''}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Description courte pour les résultats de recherche..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-indigo-600"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'événement'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
admin/src/components/FeaturedModal.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
||||||
|
import { Star, X, Loader2, Save } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
business: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isFeatured: boolean;
|
||||||
|
founderName?: string | null;
|
||||||
|
founderImageUrl?: string | null;
|
||||||
|
keyMetric?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeaturedModal({ business }: Props) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const data = {
|
||||||
|
founderName: formData.get('founderName') as string,
|
||||||
|
founderImageUrl: formData.get('founderImageUrl') as string,
|
||||||
|
keyMetric: formData.get('keyMetric') as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setFeaturedBusiness(business.id, data);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Afroshine mis à jour !");
|
||||||
|
setIsOpen(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (confirm("Retirer cet entrepreneur du titre 'Afroshine' ?")) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await removeFeaturedBusiness(business.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Titre révoqué");
|
||||||
|
setIsOpen(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue lors de la suppression");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
|
||||||
|
business.isFeatured
|
||||||
|
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
||||||
|
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
||||||
|
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
|
||||||
|
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleToggleFeature} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
|
||||||
|
<input
|
||||||
|
name="founderName"
|
||||||
|
defaultValue={business.founderName || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: Aliko Dangote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
|
||||||
|
<input
|
||||||
|
name="founderImageUrl"
|
||||||
|
defaultValue={business.founderImageUrl || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
|
||||||
|
<input
|
||||||
|
name="keyMetric"
|
||||||
|
defaultValue={business.keyMetric || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: +500 emplois créés"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
Définir comme Afroshine
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{business.isFeatured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
||||||
|
>
|
||||||
|
Révoquer le titre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
admin/src/components/ImageUploader.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { uploadImage } from '@/app/actions/upload';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
|
||||||
|
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("L'image ne doit pas dépasser 5 Mo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(formData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
onChange(result.url);
|
||||||
|
toast.success("Image uploadée avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur de connexion lors de l'upload");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
onChange('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
|
||||||
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('url')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
Lien URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('upload')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
{mode === 'url' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder || "https://images.unsplash.com/..."}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!value ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
|
||||||
|
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt="Prévisualisation"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Hidden input for value to be sent via form if needed */}
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value && mode === 'url' && (
|
||||||
|
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
|
||||||
|
<img src={value} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
admin/src/components/InterviewForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createInterview, updateInterview } from '@/app/actions/interview';
|
||||||
|
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { InterviewType, ContentStatus } from '@prisma/client';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InterviewForm({ initialData }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
slug: formData.get('slug') as string,
|
||||||
|
guestName: formData.get('guestName') as string,
|
||||||
|
companyName: formData.get('companyName') as string,
|
||||||
|
role: formData.get('role') as string,
|
||||||
|
type: formData.get('type') as InterviewType,
|
||||||
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
|
videoUrl: formData.get('videoUrl') as string || null,
|
||||||
|
excerpt: formData.get('excerpt') as string,
|
||||||
|
content: content || null,
|
||||||
|
duration: formData.get('duration') as string || null,
|
||||||
|
tags: tags, // Use state
|
||||||
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une miniature est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = initialData
|
||||||
|
? await updateInterview(initialData.id, data)
|
||||||
|
: await createInterview(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(initialData ? "Interview mise à jour" : "Interview créée avec succès");
|
||||||
|
router.push('/blog');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{initialData ? 'Modifier l\'interview' : 'Nouvelle Interview'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={initialData?.title}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Entreprise X : Une success story..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
|
||||||
|
<input
|
||||||
|
name="guestName"
|
||||||
|
defaultValue={initialData?.guestName}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Sarah Koné"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Entreprise</label>
|
||||||
|
<input
|
||||||
|
name="companyName"
|
||||||
|
defaultValue={initialData?.companyName}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: TechAfrica"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
|
||||||
|
<input
|
||||||
|
name="role"
|
||||||
|
defaultValue={initialData?.role}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: Fondatrice & CEO"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
defaultValue={initialData?.type || 'VIDEO'}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="VIDEO">Vidéo</option>
|
||||||
|
<option value="ARTICLE">Article (Texte)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Miniature (Thumbnail)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
|
name="thumbnailUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
|
||||||
|
<input
|
||||||
|
name="videoUrl"
|
||||||
|
defaultValue={initialData?.videoUrl}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="https://youtube.com/..."
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="duration"
|
||||||
|
defaultValue={initialData?.duration}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: 12 min"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
|
||||||
|
<textarea
|
||||||
|
name="excerpt"
|
||||||
|
defaultValue={initialData?.excerpt}
|
||||||
|
required
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="Rédigez la transcription ou le contenu ici..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEO SECTION */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
||||||
|
|
||||||
|
<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">Slug (URL personnalisée)</label>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
defaultValue={initialData?.slug || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="ex: interview-sarah-kone-techafrica"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<TagInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
suggestions={allExistingTags}
|
||||||
|
label="Mots-clés (tags)"
|
||||||
|
placeholder="tech, succes, interview..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
||||||
|
<input
|
||||||
|
name="metaTitle"
|
||||||
|
defaultValue={initialData?.metaTitle || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Titre optimisé SEO"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
||||||
|
<textarea
|
||||||
|
name="metaDescription"
|
||||||
|
defaultValue={initialData?.metaDescription || ''}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Description courte SEO..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-emerald-600"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'interview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
admin/src/components/ManualVerifyButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { verifyUserEmail } from '@/app/actions/user';
|
||||||
|
import { MailCheck, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManualVerifyButton({ userId }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await verifyUserEmail(userId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Email vérifié avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 text-indigo-500 hover:bg-indigo-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-indigo-500/20"
|
||||||
|
title="Vérifier manuellement l'email"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MailCheck className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Vérifier Email
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
admin/src/components/ModerationSuspensionButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||||
|
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import SuspensionModal from './SuspensionModal';
|
||||||
|
|
||||||
|
interface ModerationSuspensionButtonProps {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isSuspended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModerationSuspensionButton({
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
isSuspended
|
||||||
|
}: ModerationSuspensionButtonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSuspend = async (reason: string) => {
|
||||||
|
const result = await suspendUser(userId, reason);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`${userName} a été suspendu.`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Échec de la suspension");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSuspended) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => unsuspendUser(userId)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-xs font-bold"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
Réactiver le compte de l'auteur
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-xs font-bold"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-4 h-4" />
|
||||||
|
Suspendre l'auteur du message
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SuspensionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onConfirm={handleSuspend}
|
||||||
|
title="Suspendre l'auteur"
|
||||||
|
subtitle={`Voulez-vous suspendre le compte de ${userName} pour ce message non conforme ?`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
admin/src/components/PlanEditModal.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"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;
|
||||||
|
currentCycle: 'monthly' | 'yearly';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanEditModal({ isOpen, onClose, plan, currentCycle }: PlanEditModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: plan.name,
|
||||||
|
priceXOF: plan.priceXOF,
|
||||||
|
yearlyPriceXOF: plan.yearlyPriceXOF || '',
|
||||||
|
priceEUR: plan.priceEUR,
|
||||||
|
yearlyPriceEUR: plan.yearlyPriceEUR || '',
|
||||||
|
description: plan.description,
|
||||||
|
offerLimit: plan.offerLimit.toString(),
|
||||||
|
recommended: plan.recommended,
|
||||||
|
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">
|
||||||
|
Édition du mode <span className="text-indigo-400 font-bold uppercase">{currentCycle === 'yearly' ? 'Annuel' : 'Mensuel'}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{currentCycle === 'monthly' ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Mensuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.priceXOF}
|
||||||
|
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Mensuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.priceEUR}
|
||||||
|
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Annuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.yearlyPriceXOF}
|
||||||
|
onChange={(e) => setFormData({...formData, yearlyPriceXOF: e.target.value})}
|
||||||
|
placeholder="ex: 45 000 FCFA"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Annuel)</label>
|
||||||
|
<input
|
||||||
|
value={formData.yearlyPriceEUR}
|
||||||
|
onChange={(e) => setFormData({...formData, yearlyPriceEUR: e.target.value})}
|
||||||
|
placeholder="ex: 69 €"
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors shadow-[0_0_10px_rgba(16,185,129,0.1)] border-emerald-500/30"
|
||||||
|
/>
|
||||||
|
</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
@@ -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
@@ -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 || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
admin/src/components/ReportActionButtons.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { updateReportStatus } from '@/app/actions/moderation';
|
||||||
|
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reportId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportActionButtons({ reportId }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleAction = (status: 'RESOLVED' | 'DISMISSED') => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateReportStatus(reportId, status);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(status === 'RESOLVED' ? "Signalement résolu" : "Signalement rejeté");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('RESOLVED')}
|
||||||
|
disabled={isPending}
|
||||||
|
className="btn-verify px-3 py-1.5 text-xs"
|
||||||
|
title="Marquer comme résolu"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
|
||||||
|
Résoudre
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('DISMISSED')}
|
||||||
|
disabled={isPending}
|
||||||
|
className="btn-unverify px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 border-none"
|
||||||
|
title="Rejeter le signalement"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||||
|
Rejeter
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
admin/src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useRef } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import 'react-quill-new/dist/quill.snow.css';
|
||||||
|
|
||||||
|
// We need to import Quill for registration
|
||||||
|
const ReactQuill = dynamic(async () => {
|
||||||
|
const { default: RQ, Quill } = await import("react-quill-new");
|
||||||
|
|
||||||
|
// Register custom Divider Blot
|
||||||
|
if (Quill) {
|
||||||
|
const BlockEmbed = Quill.import('blots/block/embed') as any;
|
||||||
|
class DividerBlot extends BlockEmbed {
|
||||||
|
static blotName = 'divider';
|
||||||
|
static tagName = 'hr';
|
||||||
|
}
|
||||||
|
Quill.register(DividerBlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RQ;
|
||||||
|
}, {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="h-64 w-full bg-slate-900 animate-pulse rounded-lg border border-slate-700" />
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
|
||||||
|
const quillRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const modules = useMemo(() => ({
|
||||||
|
toolbar: {
|
||||||
|
container: [
|
||||||
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
['divider', 'link', 'clean'],
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
divider: function() {
|
||||||
|
const quill = (quillRef.current as any)?.getEditor();
|
||||||
|
if (quill) {
|
||||||
|
const range = quill.getSelection(true);
|
||||||
|
quill.insertEmbed(range.index, 'divider', true);
|
||||||
|
quill.setSelection(range.index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
'header',
|
||||||
|
'bold', 'italic', 'underline', 'strike',
|
||||||
|
'list',
|
||||||
|
'divider', 'link',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quill-dark-wrapper">
|
||||||
|
<ReactQuill
|
||||||
|
ref={quillRef}
|
||||||
|
theme="snow"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
modules={modules}
|
||||||
|
formats={formats}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="bg-slate-900 text-white rounded-lg overflow-hidden border border-slate-700 min-h-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
admin/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
MessageSquare,
|
||||||
|
BookOpen,
|
||||||
|
LogOut,
|
||||||
|
ShieldCheck,
|
||||||
|
BarChart3,
|
||||||
|
Flag,
|
||||||
|
CreditCard,
|
||||||
|
Settings,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Briefcase,
|
||||||
|
Tags
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||||
|
{ name: 'Utilisateurs', icon: Users, href: '/users' },
|
||||||
|
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||||
|
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
|
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||||
|
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
||||||
|
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
||||||
|
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||||
|
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||||
|
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getPendingVerificationCount } from '@/app/actions/business';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPendingVerificationCount().then(setPendingCount);
|
||||||
|
}, [pathname]); // Refresh on navigation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-sidebar p-6 flex flex-col">
|
||||||
|
<div className="flex items-center gap-3 mb-10 px-2">
|
||||||
|
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
||||||
|
<ShieldCheck className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<item.icon className="w-5 h-5 mr-3" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.name === 'Utilisateurs' && pendingCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-6 border-t border-slate-700">
|
||||||
|
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
|
||||||
|
<LogOut className="w-5 h-5 mr-3" />
|
||||||
|
<span>Déconnexion</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
admin/src/components/SuspensionModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ShieldAlert, X, AlertOctagon } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface SuspensionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (reason: string) => Promise<void>;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SuspensionModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
subtitle
|
||||||
|
}: SuspensionModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen || !mounted) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!reason.trim()) {
|
||||||
|
toast.error("Veuillez saisir un motif de suspension");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onConfirm(reason);
|
||||||
|
setReason('');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="modal-overlay" style={{ zIndex: 9999 }}>
|
||||||
|
<div className="modal-content max-w-md shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3 text-red-500">
|
||||||
|
<ShieldAlert className="w-7 h-7" />
|
||||||
|
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-full hover:bg-slate-800 text-slate-500 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 p-4 rounded-xl flex gap-3 mb-6">
|
||||||
|
<AlertOctagon className="w-6 h-6 text-red-500 shrink-0" />
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed font-medium">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||||
|
Motif de la suspension
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Expliquez pourquoi ce compte est suspendu. Ce message sera visible par l'utilisateur."
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-500 transition-all text-sm shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition-all font-bold text-sm"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-xl transition-all font-bold text-sm shadow-lg shadow-red-900/40"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "En cours..." : "Confirmer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
180
admin/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
|
||||||
|
import { X, Hash, Search, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
|
!value.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else if (showSuggestions && suggestions.length > 0) {
|
||||||
|
// Show popular/all suggestions when empty but focused
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag => !value.includes(tag.toLowerCase()))
|
||||||
|
.slice(0, 8);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else {
|
||||||
|
setFilteredSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}, [inputValue, suggestions, value, showSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTag = (tagToAdd?: string) => {
|
||||||
|
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||||
|
if (tag && !value.includes(tag)) {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
// Keep focus if we added via suggestion
|
||||||
|
if (tagToAdd) setShowSuggestions(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
onChange(value.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||||
|
addTag(filteredSuggestions[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
} else if (e.key === ',' || e.key === ';') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!showSuggestions && suggestions.length > 0) {
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||||
|
removeTag(value[value.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 relative" ref={containerRef}>
|
||||||
|
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
|
||||||
|
onClick={() => {
|
||||||
|
const input = containerRef.current?.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<Hash className="w-3 h-3 opacity-50" />
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTag(tag);
|
||||||
|
}}
|
||||||
|
className="hover:text-red-400 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
|
||||||
|
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Popover */}
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
|
||||||
|
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
|
||||||
|
</p>
|
||||||
|
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addTag(suggestion);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-slate-500 italic">
|
||||||
|
Appuyez sur Entrée ou virgule pour ajouter un tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
admin/src/components/ToggleVerifyButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toggleVerification } from '@/app/actions/business';
|
||||||
|
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToggleVerifyButton({ id, verified }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await toggleVerification(id, verified);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(verified ? "Vérification révoquée" : "Entreprise validée");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={verified ? "btn-unverify" : "btn-verify"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : verified ? (
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{verified ? "Révoquer" : "Valider"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
admin/src/lib/prisma.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!
|
||||||
|
|
||||||
|
function makePrisma() {
|
||||||
|
const pool = new pg.Pool({ connectionString })
|
||||||
|
const adapter = new PrismaPg(pool)
|
||||||
|
return new PrismaClient({ adapter })
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
|
||||||
|
|
||||||
|
export default prisma
|
||||||
13
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function generateSlug(text: string): string {
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.normalize('NFD') // remove accents
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/\s+/g, '-') // replace spaces with -
|
||||||
|
.replace(/[^\w-]+/g, '') // remove all non-word chars
|
||||||
|
.replace(/--+/g, '-') // replace multiple - with single -
|
||||||
|
.replace(/^-+/, '') // trim - from start of text
|
||||||
|
.replace(/-+$/, ''); // trim - from end of text
|
||||||
|
}
|
||||||
26
admin/test-db.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
console.log('Testing connection to:', connectionString ? 'URL present' : 'URL MISSING');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
console.log('Successfully connected to DB!');
|
||||||
|
const res = await client.query('SELECT NOW()');
|
||||||
|
console.log('Query result:', res.rows[0]);
|
||||||
|
client.release();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connection failed:', err.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
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)
|
||||||
4
admin/test-prisma.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
console.log('Models available in Prisma:', Object.keys(prisma).filter(k => !k.startsWith('_')))
|
||||||
|
prisma.$disconnect()
|
||||||
34
admin/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||