feat: initialisation complète du projet CESIZen

- Backend Laravel 11 (API REST, JWT auth, PostgreSQL)
- Frontend Next.js 14 (React, TypeScript, Tailwind CSS)
- Docker Compose (PostgreSQL, backend, frontend)
- Modules : auth, profil, tracker émotions, informations, admin
This commit is contained in:
Azmog
2026-03-11 16:27:01 +01:00
commit e3da625927
158 changed files with 25139 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
.next
.env.local
+41
View 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
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
# Dépendances d'abord (cache Docker)
COPY package.json package-lock.json* ./
RUN npm install
# Copier le reste du code
COPY . .
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
CMD ["npm", "run", "dev"]
+36
View 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.
+18
View 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;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7183
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hot-toast": "^2.6.0",
"recharts": "^3.8.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View 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
View 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
View 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

+1
View 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
View 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

@@ -0,0 +1,223 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Edit2, Trash2, Eye, EyeOff } from 'lucide-react';
import api from '@/lib/api';
import type { Feed } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast';
export default function AdminContenusPage() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
const [formLoading, setFormLoading] = useState(false);
const [form, setForm] = useState({
titre: '',
contenu: '',
image_url: '',
est_publie: true,
ordre: 0,
});
const fetchFeeds = async () => {
try {
const res = await api.get('/admin/feeds');
setFeeds(Array.isArray(res.data) ? res.data : res.data?.data || []);
} catch {
toast.error('Erreur lors du chargement');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchFeeds(); }, []);
const openCreate = () => {
setEditingFeed(null);
setForm({ titre: '', contenu: '', image_url: '', est_publie: true, ordre: 0 });
setModalOpen(true);
};
const openEdit = (feed: Feed) => {
setEditingFeed(feed);
setForm({
titre: feed.titre,
contenu: feed.contenu,
image_url: feed.image_url || '',
est_publie: feed.est_publie,
ordre: feed.ordre,
});
setModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormLoading(true);
try {
if (editingFeed) {
await api.put(`/admin/feeds/${editingFeed.id}`, form);
toast.success('Article mis à jour');
} else {
await api.post('/admin/feeds', form);
toast.success('Article créé');
}
setModalOpen(false);
await fetchFeeds();
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur');
} finally {
setFormLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Supprimer cet article ?')) return;
try {
await api.delete(`/admin/feeds/${id}`);
await fetchFeeds();
toast.success('Article supprimé');
} catch {
toast.error('Erreur lors de la suppression');
}
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Contenus
</h1>
<p className="text-gray-500 dark:text-gray-400">
Gérez les articles et ressources de la plateforme
</p>
</div>
<Button onClick={openCreate} size="sm">
<Plus size={16} />
Nouvel article
</Button>
</div>
{loading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<div className="h-6 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-48 mb-2" />
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-full" />
</Card>
))}
</div>
) : feeds.length === 0 ? (
<Card className="text-center py-12">
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucun article</p>
<Button onClick={openCreate} size="sm">Créer le premier article</Button>
</Card>
) : (
<div className="space-y-4">
{feeds.map((feed) => (
<Card key={feed.id} className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{feed.titre}
</h3>
<Badge color={feed.est_publie ? '#06c656' : '#9ca3af'}>
{feed.est_publie ? 'Publié' : 'Brouillon'}
</Badge>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
/{feed.slug} · Ordre: {feed.ordre}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => openEdit(feed)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Modifier"
>
<Edit2 size={16} className="text-gray-500" />
</button>
<button
onClick={() => handleDelete(feed.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
title="Supprimer"
>
<Trash2 size={16} className="text-red-500" />
</button>
</div>
</Card>
))}
</div>
)}
{/* Modal création/édition */}
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingFeed ? 'Modifier l\'article' : 'Nouvel article'} size="lg">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Titre"
value={form.titre}
onChange={(e) => setForm({ ...form, titre: e.target.value })}
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Contenu (HTML)
</label>
<textarea
value={form.contenu}
onChange={(e) => setForm({ ...form, contenu: e.target.value })}
rows={10}
required
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 font-mono text-sm resize-none"
/>
</div>
<Input
label="URL de l'image (optionnel)"
value={form.image_url}
onChange={(e) => setForm({ ...form, image_url: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Ordre d&apos;affichage
</label>
<input
type="number"
value={form.ordre}
onChange={(e) => setForm({ ...form, ordre: parseInt(e.target.value) || 0 })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.est_publie}
onChange={(e) => setForm({ ...form, est_publie: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-malachite-500 focus:ring-malachite-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Publié
</span>
</label>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={formLoading}>
{editingFeed ? 'Mettre à jour' : 'Créer'}
</Button>
<Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Users, FileText, Heart, TrendingUp } from 'lucide-react';
import Card from '@/components/ui/Card';
import api from '@/lib/api';
interface AdminStats {
total_utilisateurs: number;
total_feeds: number;
total_emotions: number;
total_saisies: number;
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
try {
const [usersRes, feedsRes, emotionsRes] = await Promise.all([
api.get('/admin/utilisateurs'),
api.get('/admin/feeds'),
api.get('/admin/emotions'),
]);
setStats({
total_utilisateurs: Array.isArray(usersRes.data) ? usersRes.data.length : usersRes.data?.data?.length || 0,
total_feeds: Array.isArray(feedsRes.data) ? feedsRes.data.length : feedsRes.data?.data?.length || 0,
total_emotions: Array.isArray(emotionsRes.data) ? emotionsRes.data.length : emotionsRes.data?.data?.length || 0,
total_saisies: 0,
});
} catch {
// Silencieux
} finally {
setLoading(false);
}
}
fetchStats();
}, []);
const statCards = [
{
label: 'Utilisateurs',
value: stats?.total_utilisateurs || 0,
icon: <Users size={24} />,
color: 'text-blue-500',
bg: 'bg-blue-50 dark:bg-blue-950/30',
},
{
label: 'Articles',
value: stats?.total_feeds || 0,
icon: <FileText size={24} />,
color: 'text-malachite-500',
bg: 'bg-malachite-50 dark:bg-malachite-950/30',
},
{
label: 'Émotions',
value: stats?.total_emotions || 0,
icon: <Heart size={24} />,
color: 'text-candlelight-500',
bg: 'bg-candlelight-50 dark:bg-candlelight-950/30',
},
];
return (
<div>
<div className="mb-8">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
Administration
</h1>
<p className="text-gray-500 dark:text-gray-400">
Vue d&apos;ensemble de la plateforme CESIZen
</p>
</div>
<div className="grid sm:grid-cols-3 gap-6 mb-8">
{statCards.map((card) => (
<Card key={card.label}>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl ${card.bg} flex items-center justify-center ${card.color}`}>
{card.icon}
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">{card.label}</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{loading ? '...' : card.value}
</p>
</div>
</div>
</Card>
))}
</div>
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Actions rapides
</h2>
<div className="grid sm:grid-cols-3 gap-4">
<Link
href="/admin/utilisateurs"
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Users size={20} className="text-blue-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les utilisateurs</span>
</Link>
<Link
href="/admin/contenus"
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<FileText size={20} className="text-malachite-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les contenus</span>
</Link>
<Link
href="/admin/emotions"
className="flex items-center gap-3 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Heart size={20} className="text-candlelight-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Gérer les émotions</span>
</Link>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,291 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Edit2, Trash2, Eye, EyeOff } from 'lucide-react';
import api from '@/lib/api';
import type { Emotion } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast';
export default function AdminEmotionsPage() {
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingEmotion, setEditingEmotion] = useState<Emotion | null>(null);
const [formLoading, setFormLoading] = useState(false);
const [form, setForm] = useState({
nom: '',
couleur: '#fce117',
icone: '',
niveau: 1,
parent_id: null as number | null,
est_actif: true,
});
const fetchEmotions = async () => {
try {
const res = await api.get('/admin/emotions');
setEmotions(Array.isArray(res.data) ? res.data : res.data?.data || []);
} catch {
toast.error('Erreur lors du chargement');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchEmotions(); }, []);
const parentEmotions = emotions.filter((e) => e.niveau === 1);
const openCreate = (parentId?: number) => {
setEditingEmotion(null);
setForm({
nom: '',
couleur: '#fce117',
icone: '',
niveau: parentId ? 2 : 1,
parent_id: parentId || null,
est_actif: true,
});
setModalOpen(true);
};
const openEdit = (emotion: Emotion) => {
setEditingEmotion(emotion);
setForm({
nom: emotion.nom,
couleur: emotion.couleur,
icone: emotion.icone || '',
niveau: emotion.niveau,
parent_id: emotion.parent_id,
est_actif: emotion.est_actif,
});
setModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormLoading(true);
try {
const payload = { ...form, icone: form.icone || null };
if (editingEmotion) {
await api.put(`/admin/emotions/${editingEmotion.id}`, payload);
toast.success('Émotion mise à jour');
} else {
await api.post('/admin/emotions', payload);
toast.success('Émotion créée');
}
setModalOpen(false);
await fetchEmotions();
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur');
} finally {
setFormLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Supprimer cette émotion ?')) return;
try {
await api.delete(`/admin/emotions/${id}`);
await fetchEmotions();
toast.success('Émotion supprimée');
} catch {
toast.error('Erreur lors de la suppression');
}
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Émotions
</h1>
<p className="text-gray-500 dark:text-gray-400">
Gérez le catalogue des émotions disponibles
</p>
</div>
<Button onClick={() => openCreate()} size="sm">
<Plus size={16} />
Nouvelle émotion
</Button>
</div>
{loading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<div className="h-6 bg-gray-100 dark:bg-gray-800 rounded animate-pulse w-32" />
</Card>
))}
</div>
) : emotions.length === 0 ? (
<Card className="text-center py-12">
<p className="text-gray-400 dark:text-gray-600 mb-4">Aucune émotion</p>
<Button onClick={() => openCreate()} size="sm">Créer la première émotion</Button>
</Card>
) : (
<div className="space-y-4">
{parentEmotions.map((emotion) => (
<Card key={emotion.id}>
<div className="flex items-center gap-4 mb-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl shrink-0"
style={{ backgroundColor: `${emotion.couleur}20` }}
>
{emotion.icone || '🔵'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 dark:text-white">
{emotion.nom}
</h3>
<Badge color={emotion.couleur}>{emotion.couleur}</Badge>
{!emotion.est_actif && (
<Badge color="#9ca3af">Inactif</Badge>
)}
</div>
<p className="text-sm text-gray-400">Niveau 1</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openCreate(emotion.id)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Ajouter sous-émotion"
>
<Plus size={16} className="text-malachite-500" />
</button>
<button
onClick={() => openEdit(emotion)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Modifier"
>
<Edit2 size={16} className="text-gray-500" />
</button>
<button
onClick={() => handleDelete(emotion.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
title="Supprimer"
>
<Trash2 size={16} className="text-red-500" />
</button>
</div>
</div>
{/* Sous-émotions */}
{emotion.enfants && emotion.enfants.length > 0 && (
<div className="ml-8 pl-4 border-l-2 border-gray-200 dark:border-gray-700 space-y-2">
{emotion.enfants.map((child) => (
<div key={child.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<span className="text-lg">{child.icone || '🔵'}</span>
<span className="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{child.nom}
</span>
<div
className="w-4 h-4 rounded-full shrink-0"
style={{ backgroundColor: child.couleur }}
/>
{!child.est_actif && <Badge color="#9ca3af">Inactif</Badge>}
<button
onClick={() => openEdit(child)}
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Edit2 size={14} className="text-gray-400" />
</button>
<button
onClick={() => handleDelete(child.id)}
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
>
<Trash2 size={14} className="text-red-400" />
</button>
</div>
))}
</div>
)}
</Card>
))}
</div>
)}
{/* Modal création/édition */}
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingEmotion ? 'Modifier l\'émotion' : 'Nouvelle émotion'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Nom"
value={form.nom}
onChange={(e) => setForm({ ...form, nom: e.target.value })}
required
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Couleur
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={form.couleur}
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-700 cursor-pointer"
/>
<input
type="text"
value={form.couleur}
onChange={(e) => setForm({ ...form, couleur: e.target.value })}
className="flex-1 rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500 font-mono text-sm"
/>
</div>
</div>
<Input
label="Icône (emoji)"
value={form.icone}
onChange={(e) => setForm({ ...form, icone: e.target.value })}
placeholder="😊"
/>
</div>
{form.niveau === 2 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Émotion parente
</label>
<select
value={form.parent_id || ''}
onChange={(e) => setForm({ ...form, parent_id: Number(e.target.value) || null })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
>
<option value="">Sélectionner...</option>
{parentEmotions.map((e) => (
<option key={e.id} value={e.id}>{e.icone} {e.nom}</option>
))}
</select>
</div>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.est_actif}
onChange={(e) => setForm({ ...form, est_actif: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-malachite-500 focus:ring-malachite-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Active
</span>
</label>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={formLoading}>
{editingEmotion ? 'Mettre à jour' : 'Créer'}
</Button>
<Button variant="ghost" onClick={() => setModalOpen(false)}>
Annuler
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,221 @@
'use client';
import { useEffect, useState } from 'react';
import { Search, UserCheck, UserX, Trash2, Plus } from 'lucide-react';
import api from '@/lib/api';
import type { Utilisateur } from '@/types';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import toast from 'react-hot-toast';
export default function AdminUtilisateursPage() {
const [utilisateurs, setUtilisateurs] = useState<Utilisateur[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [newUser, setNewUser] = useState({
nom: '', prenom: '', email: '', password: '', password_confirmation: '', role_id: 2,
});
const fetchUsers = async () => {
try {
const res = await api.get('/admin/utilisateurs');
setUtilisateurs(Array.isArray(res.data) ? res.data : res.data?.data || []);
} catch {
toast.error('Erreur lors du chargement');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchUsers(); }, []);
const handleToggleActive = async (id: number) => {
try {
await api.patch(`/admin/utilisateurs/${id}/toggle-active`);
await fetchUsers();
toast.success('Statut modifié');
} catch {
toast.error('Erreur');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Supprimer cet utilisateur ?')) return;
try {
await api.delete(`/admin/utilisateurs/${id}`);
await fetchUsers();
toast.success('Utilisateur supprimé');
} catch {
toast.error('Erreur lors de la suppression');
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setFormLoading(true);
try {
await api.post('/admin/utilisateurs', newUser);
setCreateModalOpen(false);
setNewUser({ nom: '', prenom: '', email: '', password: '', password_confirmation: '', role_id: 2 });
await fetchUsers();
toast.success('Utilisateur créé');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur lors de la création');
} finally {
setFormLoading(false);
}
};
const filtered = utilisateurs.filter((u) =>
`${u.prenom} ${u.nom} ${u.email}`.toLowerCase().includes(search.toLowerCase())
);
const roleColor = (nom: string) => {
switch (nom) {
case 'administrateur': return '#ef4444';
case 'membre': return '#06c656';
default: return '#9ca3af';
}
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Utilisateurs
</h1>
<p className="text-gray-500 dark:text-gray-400">
{utilisateurs.length} utilisateur{utilisateurs.length > 1 ? 's' : ''} enregistré{utilisateurs.length > 1 ? 's' : ''}
</p>
</div>
<Button onClick={() => setCreateModalOpen(true)} size="sm">
<Plus size={16} />
Nouvel utilisateur
</Button>
</div>
{/* Recherche */}
<div className="mb-6">
<Input
placeholder="Rechercher un utilisateur..."
icon={<Search size={18} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Table */}
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Utilisateur</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Email</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Rôle</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Statut</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
<td colSpan={5} className="px-6 py-4">
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
</td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-400">
Aucun utilisateur trouvé
</td>
</tr>
) : (
filtered.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
<td className="px-6 py-4">
<span className="font-medium text-gray-900 dark:text-white">
{user.prenom} {user.nom}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{user.email}
</td>
<td className="px-6 py-4">
<Badge color={roleColor(user.role?.nom)}>
{user.role?.nom || 'N/A'}
</Badge>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 text-sm ${user.est_actif ? 'text-malachite-600' : 'text-red-500'}`}>
<span className={`w-2 h-2 rounded-full ${user.est_actif ? 'bg-malachite-500' : 'bg-red-500'}`} />
{user.est_actif ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleToggleActive(user.id)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={user.est_actif ? 'Désactiver' : 'Activer'}
>
{user.est_actif
? <UserX size={16} className="text-red-500" />
: <UserCheck size={16} className="text-malachite-500" />
}
</button>
<button
onClick={() => handleDelete(user.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors"
title="Supprimer"
>
<Trash2 size={16} className="text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{/* Modal création */}
<Modal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} title="Nouvel utilisateur">
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input label="Prénom" value={newUser.prenom} onChange={(e) => setNewUser({ ...newUser, prenom: e.target.value })} required />
<Input label="Nom" value={newUser.nom} onChange={(e) => setNewUser({ ...newUser, nom: e.target.value })} required />
</div>
<Input label="Email" type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} required />
<Input label="Mot de passe" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} required />
<Input label="Confirmer" type="password" value={newUser.password_confirmation} onChange={(e) => setNewUser({ ...newUser, password_confirmation: e.target.value })} required />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Rôle</label>
<select
value={newUser.role_id}
onChange={(e) => setNewUser({ ...newUser, role_id: Number(e.target.value) })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
>
<option value={2}>Membre</option>
<option value={3}>Administrateur</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" loading={formLoading}>Créer</Button>
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Annuler</Button>
</div>
</form>
</Modal>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Header from '@/components/layout/Header';
import Sidebar from '@/components/layout/Sidebar';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 bg-gray-50 dark:bg-gray-950">
<div className="p-6 lg:p-8">
{children}
</div>
</main>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import toast from 'react-hot-toast';
export default function ConnexionPage() {
const router = useRouter();
const { login } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success('Connexion réussie !');
router.push('/dashboard');
} catch (error: any) {
const message = error?.response?.data?.message || 'Identifiants incorrects';
toast.error(message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-candlelight-50 via-white to-malachite-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-block">
<h1 className="font-display text-4xl font-bold">
<span className="text-malachite-600 dark:text-malachite-400">CESI</span>
<span className="text-candlelight-500">ZEN</span>
</h1>
</Link>
<p className="text-gray-500 dark:text-gray-400 mt-2">Connectez-vous à votre compte</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Adresse email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="vous@exemple.fr"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Votre mot de passe"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-candlelight-500 hover:bg-candlelight-600 disabled:opacity-50 disabled:cursor-not-allowed text-black font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Connexion...
</span>
) : (
'Se connecter'
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
Pas encore de compte ?{' '}
<Link href="/inscription" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline">
Créer un compte
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,221 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import toast from 'react-hot-toast';
export default function InscriptionPage() {
const router = useRouter();
const { register } = useAuthStore();
const [form, setForm] = useState({
nom: '',
prenom: '',
email: '',
password: '',
password_confirmation: '',
consentement_rgpd: false,
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrors({});
try {
await register(form);
toast.success('Inscription réussie ! Bienvenue sur CESIZen.');
router.push('/dashboard');
} catch (error: any) {
const data = error?.response?.data;
if (data?.errors) {
setErrors(data.errors);
} else {
toast.error(data?.message || 'Erreur lors de l\'inscription');
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-candlelight-50 via-white to-malachite-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 px-4 py-12">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-block">
<h1 className="font-display text-4xl font-bold">
<span className="text-malachite-600 dark:text-malachite-400">CESI</span>
<span className="text-candlelight-500">ZEN</span>
</h1>
</Link>
<p className="text-gray-500 dark:text-gray-400 mt-2">Créez votre compte gratuitement</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="prenom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Prénom
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="prenom"
name="prenom"
type="text"
value={form.prenom}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Jean"
/>
</div>
{errors.prenom && <p className="text-red-500 text-xs mt-1">{errors.prenom[0]}</p>}
</div>
<div>
<label htmlFor="nom" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Nom
</label>
<input
id="nom"
name="nom"
type="text"
value={form.nom}
onChange={handleChange}
required
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Dupont"
/>
{errors.nom && <p className="text-red-500 text-xs mt-1">{errors.nom[0]}</p>}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Adresse email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="vous@exemple.fr"
/>
</div>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email[0]}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={handleChange}
required
minLength={8}
className="w-full pl-11 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Minimum 8 caractères"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password[0]}</p>}
</div>
<div>
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Confirmer le mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password_confirmation"
name="password_confirmation"
type={showPassword ? 'text' : 'password'}
value={form.password_confirmation}
onChange={handleChange}
required
className="w-full pl-11 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-candlelight-500 focus:border-transparent outline-none transition-all"
placeholder="Retapez votre mot de passe"
/>
</div>
</div>
<div className="flex items-start gap-3 pt-2">
<input
id="consentement_rgpd"
name="consentement_rgpd"
type="checkbox"
checked={form.consentement_rgpd}
onChange={handleChange}
required
className="mt-1 w-4 h-4 rounded border-gray-300 text-candlelight-500 focus:ring-candlelight-500"
/>
<label htmlFor="consentement_rgpd" className="text-sm text-gray-600 dark:text-gray-400">
J&apos;accepte le traitement de mes données personnelles conformément à la{' '}
<Link href="/informations" className="text-malachite-600 dark:text-malachite-400 hover:underline">
politique de confidentialite
</Link>
. Mes données sont chiffrées et je peux supprimer mon compte à tout moment.
</label>
</div>
{errors.consentement_rgpd && <p className="text-red-500 text-xs">{errors.consentement_rgpd[0]}</p>}
<button
type="submit"
disabled={loading || !form.consentement_rgpd}
className="w-full bg-malachite-500 hover:bg-malachite-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-xl transition-all duration-200 hover:shadow-lg mt-2"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Inscription...
</span>
) : (
'Créer mon compte'
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
Déjà un compte ?{' '}
<Link href="/connexion" className="text-malachite-600 dark:text-malachite-400 font-medium hover:underline">
Se connecter
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,193 @@
'use client';
import Link from 'next/link';
import { Heart, BarChart3, Plus, BookOpen, TrendingUp } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useSaisies } from '@/hooks/useTracker';
import { useRapport } from '@/hooks/useRapport';
import Card from '@/components/ui/Card';
import Badge from '@/components/ui/Badge';
import Skeleton from '@/components/ui/Skeleton';
export default function DashboardPage() {
const { user } = useAuth();
const { data: saisies, isLoading: saisiesLoading } = useSaisies();
const { data: rapport, isLoading: rapportLoading } = useRapport('week');
const recentSaisies = saisies?.slice(0, 5) || [];
return (
<div>
{/* En-tête */}
<div className="mb-8">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
Bonjour {user?.prenom} 👋
</h1>
<p className="text-gray-500 dark:text-gray-400">
Voici un aperçu de votre bien-être cette semaine.
</p>
</div>
{/* Actions rapides */}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<Link href="/journal/nouvelle-saisie">
<Card hover className="flex items-center gap-4 bg-linear-to-r from-candlelight-50 to-candlelight-100 dark:from-candlelight-950/30 dark:to-candlelight-900/20 border-candlelight-200 dark:border-candlelight-800">
<div className="w-12 h-12 rounded-xl bg-candlelight-200 dark:bg-candlelight-900 flex items-center justify-center">
<Plus className="text-candlelight-700 dark:text-candlelight-400" size={24} />
</div>
<div>
<p className="font-semibold text-gray-900 dark:text-white">Nouvelle saisie</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Enregistrer une émotion</p>
</div>
</Card>
</Link>
<Link href="/journal">
<Card hover className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center">
<BookOpen className="text-malachite-600 dark:text-malachite-400" size={24} />
</div>
<div>
<p className="font-semibold text-gray-900 dark:text-white">Mon journal</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Voir toutes mes saisies</p>
</div>
</Card>
</Link>
<Link href="/journal/rapports">
<Card hover className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<BarChart3 className="text-blue-600 dark:text-blue-400" size={24} />
</div>
<div>
<p className="font-semibold text-gray-900 dark:text-white">Mes rapports</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Visualiser mes tendances</p>
</div>
</Card>
</Link>
</div>
{/* Stats de la semaine */}
<div className="grid sm:grid-cols-3 gap-4 mb-8">
{rapportLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</Card>
))
) : (
<>
<Card>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Saisies cette semaine</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport?.stats.total_saisies || 0}
</p>
</Card>
<Card>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Intensité moyenne</p>
<div className="flex items-center gap-2">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport?.stats.intensite_moyenne?.toFixed(1) || '—'}
</p>
<span className="text-sm text-gray-400">/10</span>
</div>
</Card>
<Card>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Émotion dominante</p>
{rapport?.stats.emotion_dominante ? (
<div className="flex items-center gap-2">
<Badge color={rapport.stats.emotion_dominante.couleur}>
{rapport.stats.emotion_dominante.nom}
</Badge>
<span className="text-sm text-gray-400">
({rapport.stats.emotion_dominante.count}x)
</span>
</div>
) : (
<p className="text-xl text-gray-300 dark:text-gray-600">—</p>
)}
</Card>
</>
)}
</div>
{/* Saisies récentes */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white">
Saisies récentes
</h2>
<Link
href="/journal"
className="text-sm text-malachite-600 dark:text-malachite-400 hover:underline"
>
Voir tout →
</Link>
</div>
{saisiesLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton variant="circle" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-1" />
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
) : recentSaisies.length > 0 ? (
<div className="space-y-3">
{recentSaisies.map((saisie) => (
<div
key={saisie.id}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg shrink-0"
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }}
>
{saisie.emotion?.icone || '🔵'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white text-sm">
{saisie.emotion?.nom}
</span>
<span className="text-xs text-gray-400">
{saisie.intensite}/10
</span>
</div>
{saisie.note && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{saisie.note}
</p>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400 dark:text-gray-600">
<Heart size={32} className="mx-auto mb-2 opacity-50" />
<p>Aucune saisie pour le moment</p>
<Link
href="/journal/nouvelle-saisie"
className="text-sm text-candlelight-600 hover:underline mt-2 inline-block"
>
Faire ma première saisie
</Link>
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
import { useCreateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/features/EmotionPicker';
import Slider from '@/components/ui/Slider';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import toast from 'react-hot-toast';
const STEPS = [
{ id: 1, title: 'Émotion', description: 'Comment vous sentez-vous ?' },
{ id: 2, title: 'Intensité', description: 'À quel point ressentez-vous cette émotion ?' },
{ id: 3, title: 'Note', description: 'Souhaitez-vous ajouter un commentaire ?' },
];
export default function NouvelleSaisiePage() {
const router = useRouter();
const createSaisie = useCreateSaisie();
const [step, setStep] = useState(1);
const [emotionId, setEmotionId] = useState<number | undefined>();
const [intensite, setIntensite] = useState(5);
const [note, setNote] = useState('');
const [dateSaisie, setDateSaisie] = useState(
new Date().toISOString().split('T')[0]
);
const canNext = () => {
if (step === 1) return !!emotionId;
return true;
};
const handleSubmit = async () => {
if (!emotionId) return;
try {
await createSaisie.mutateAsync({
emotion_id: emotionId,
intensite,
note: note.trim() || undefined,
date_saisie: dateSaisie,
});
toast.success('Saisie enregistrée !');
router.push('/journal');
} catch {
toast.error('Erreur lors de l\'enregistrement');
}
};
return (
<div className="max-w-2xl mx-auto">
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-6 transition-colors"
>
<ArrowLeft size={16} />
Retour
</button>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-2">
Nouvelle saisie
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-8">
Enregistrez votre émotion en quelques étapes simples.
</p>
{/* Stepper */}
<div className="flex items-center gap-2 mb-8">
{STEPS.map((s) => (
<div key={s.id} className="flex items-center gap-2 flex-1">
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold shrink-0
${step > s.id
? 'bg-malachite-500 text-white'
: step === s.id
? 'bg-candlelight-500 text-black'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}
`}
>
{step > s.id ? <Check size={16} /> : s.id}
</div>
{s.id < STEPS.length && (
<div
className={`flex-1 h-0.5 ${
step > s.id ? 'bg-malachite-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</div>
))}
</div>
{/* Étape actuelle */}
<Card className="mb-6">
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-1">
{STEPS[step - 1].title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{STEPS[step - 1].description}
</p>
{step === 1 && (
<EmotionPicker value={emotionId} onChange={setEmotionId} />
)}
{step === 2 && (
<div className="space-y-6">
<Slider
value={intensite}
onChange={setIntensite}
min={1}
max={10}
label="Intensité de l'émotion"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Faible</span>
<span>Modérée</span>
<span>Forte</span>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date de la saisie
</label>
<input
type="date"
value={dateSaisie}
onChange={(e) => setDateSaisie(e.target.value)}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Note (optionnel)
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
rows={4}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-candlelight-500 placeholder:text-gray-400 resize-none"
/>
</div>
</div>
)}
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button
variant="ghost"
onClick={() => setStep(step - 1)}
disabled={step === 1}
>
<ArrowLeft size={16} />
Précédent
</Button>
{step < 3 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={!canNext()}
>
Suivant
<ArrowRight size={16} />
</Button>
) : (
<Button
onClick={handleSubmit}
loading={createSaisie.isPending}
disabled={!emotionId}
>
<Check size={16} />
Enregistrer
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Plus, Filter, Calendar } from 'lucide-react';
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
import { useEmotions } from '@/hooks/useEmotions';
import TrackerTimeline from '@/components/features/TrackerTimeline';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
import toast from 'react-hot-toast';
export default function JournalPage() {
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<{
date_debut?: string;
date_fin?: string;
emotion_id?: number;
}>({});
const { data: saisies, isLoading } = useSaisies(filters);
const { data: emotions } = useEmotions();
const deleteSaisie = useDeleteSaisie();
const handleDelete = async (id: number) => {
if (!confirm('Supprimer cette saisie ?')) return;
try {
await deleteSaisie.mutateAsync(id);
toast.success('Saisie supprimée');
} catch {
toast.error('Erreur lors de la suppression');
}
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Mon journal
</h1>
<p className="text-gray-500 dark:text-gray-400">
Historique de vos émotions au quotidien
</p>
</div>
<div className="flex gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={16} />
Filtrer
</Button>
<Link href="/journal/nouvelle-saisie">
<Button size="sm">
<Plus size={16} />
Nouvelle saisie
</Button>
</Link>
</div>
</div>
{/* Filtres */}
{showFilters && (
<Card className="mb-6">
<div className="grid sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date début
</label>
<input
type="date"
value={filters.date_debut || ''}
onChange={(e) => setFilters({ ...filters, date_debut: e.target.value || undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Date fin
</label>
<input
type="date"
value={filters.date_fin || ''}
onChange={(e) => setFilters({ ...filters, date_fin: e.target.value || undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Émotion
</label>
<select
value={filters.emotion_id || ''}
onChange={(e) => setFilters({ ...filters, emotion_id: e.target.value ? Number(e.target.value) : undefined })}
className="w-full rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-candlelight-500"
>
<option value="">Toutes</option>
{emotions?.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.icone} {emotion.nom}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters({})}
>
Réinitialiser
</Button>
</div>
</Card>
)}
{/* Timeline */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 bg-white dark:bg-gray-900 rounded-xl">
<Skeleton variant="circle" className="w-12 h-12" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
) : (
<TrackerTimeline
saisies={saisies || []}
onDelete={handleDelete}
/>
)}
</div>
);
}
@@ -0,0 +1,191 @@
'use client';
import { useState } from 'react';
import { useRapport } from '@/hooks/useRapport';
import EmotionChart from '@/components/features/EmotionChart';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
import Badge from '@/components/ui/Badge';
import { BarChart3, TrendingUp, PieChart } from 'lucide-react';
type Period = 'week' | 'month' | 'quarter' | 'year';
const PERIODS: { value: Period; label: string }[] = [
{ value: 'week', label: 'Semaine' },
{ value: 'month', label: 'Mois' },
{ value: 'quarter', label: 'Trimestre' },
{ value: 'year', label: 'Année' },
];
export default function RapportsPage() {
const [period, setPeriod] = useState<Period>('month');
const { data: rapport, isLoading } = useRapport(period);
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-1">
Mes rapports
</h1>
<p className="text-gray-500 dark:text-gray-400">
Analysez vos tendances émotionnelles
</p>
</div>
{/* Sélecteur de période */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
${period === p.value
? 'bg-white dark:bg-gray-700 shadow-sm text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}
`}
>
{p.label}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="space-y-6">
<div className="grid sm:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</Card>
))}
</div>
<Card>
<Skeleton variant="rect" className="h-80" />
</Card>
</div>
) : rapport ? (
<div className="space-y-6">
{/* Stats résumées */}
<div className="grid sm:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3 mb-2">
<BarChart3 size={18} className="text-candlelight-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Total saisies</p>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport.stats.total_saisies}
</p>
<p className="text-xs text-gray-400 mt-1">
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
</p>
</Card>
<Card>
<div className="flex items-center gap-3 mb-2">
<TrendingUp size={18} className="text-malachite-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Intensité moyenne</p>
</div>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
</p>
<span className="text-sm text-gray-400">/10</span>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-2">
<PieChart size={18} className="text-blue-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">Émotion dominante</p>
</div>
{rapport.stats.emotion_dominante ? (
<div>
<Badge color={rapport.stats.emotion_dominante.couleur}>
{rapport.stats.emotion_dominante.nom}
</Badge>
<p className="text-xs text-gray-400 mt-1">
{rapport.stats.emotion_dominante.count} fois
</p>
</div>
) : (
<p className="text-xl text-gray-300 dark:text-gray-600">—</p>
)}
</Card>
</div>
{/* Répartition (Camembert) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Répartition des émotions
</h2>
<EmotionChart data={rapport} type="repartition" />
</Card>
{/* Évolution (Lignes) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Évolution dans le temps
</h2>
<EmotionChart data={rapport} type="evolution" />
</Card>
{/* Intensité moyenne (Barres) */}
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Intensité moyenne par période
</h2>
<EmotionChart data={rapport} type="intensite" />
</Card>
{/* Détail répartition */}
{rapport.repartition.length > 0 && (
<Card>
<h2 className="font-display text-lg font-semibold text-gray-900 dark:text-white mb-4">
Détail par émotion
</h2>
<div className="space-y-3">
{rapport.repartition.map((item) => (
<div key={item.nom} className="flex items-center gap-4">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: item.couleur }}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-32">
{item.nom}
</span>
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${item.pourcentage}%`,
backgroundColor: item.couleur,
}}
/>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400 w-16 text-right">
{item.pourcentage}%
</span>
<span className="text-xs text-gray-400 w-20 text-right">
Moy: {item.intensite_moyenne.toFixed(1)}/10
</span>
</div>
))}
</div>
</Card>
)}
</div>
) : (
<Card className="text-center py-16">
<BarChart3 size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-lg text-gray-500 dark:text-gray-400">
Pas de données pour cette période
</p>
</Card>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 bg-gray-50 dark:bg-gray-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { User, Mail, Lock, Shield, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '@/lib/api';
import Modal from '@/components/ui/Modal';
export default function ProfilPage() {
const { user, fetchUser, logout } = useAuth();
const [editing, setEditing] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
nom: user?.nom || '',
prenom: user?.prenom || '',
email: user?.email || '',
});
const [passwordForm, setPasswordForm] = useState({
ancien_mot_de_passe: '',
password: '',
password_confirmation: '',
});
const handleUpdateProfil = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await api.put('/profil', form);
await fetchUser();
setEditing(false);
toast.success('Profil mis à jour');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur lors de la mise à jour');
} finally {
setLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await api.put('/profil/password', passwordForm);
setChangingPassword(false);
setPasswordForm({ ancien_mot_de_passe: '', password: '', password_confirmation: '' });
toast.success('Mot de passe modifié');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur lors du changement');
} finally {
setLoading(false);
}
};
const handleDeleteAccount = async () => {
setLoading(true);
try {
await api.delete('/profil');
toast.success('Compte supprimé');
logout();
} catch (err: any) {
toast.error(err.response?.data?.message || 'Erreur lors de la suppression');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<h1 className="font-display text-3xl font-bold text-gray-900 dark:text-white mb-8">
Mon profil
</h1>
{/* Infos du profil */}
<Card className="mb-6">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 rounded-full bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center">
<User size={28} className="text-malachite-600 dark:text-malachite-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{user?.prenom} {user?.nom}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.email}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Membre depuis le {user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : '—'}
</p>
</div>
</div>
{editing ? (
<form onSubmit={handleUpdateProfil} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<Input
label="Prénom"
value={form.prenom}
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
icon={<User size={18} />}
required
/>
<Input
label="Nom"
value={form.nom}
onChange={(e) => setForm({ ...form, nom: e.target.value })}
icon={<User size={18} />}
required
/>
</div>
<Input
label="Email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
icon={<Mail size={18} />}
required
/>
<div className="flex gap-3">
<Button type="submit" loading={loading}>
Enregistrer
</Button>
<Button variant="ghost" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => {
setForm({ nom: user?.nom || '', prenom: user?.prenom || '', email: user?.email || '' });
setEditing(true);
}}>
Modifier mes informations
</Button>
)}
</Card>
{/* Changer mot de passe */}
<Card className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Lock size={20} className="text-gray-400" />
<h3 className="font-display font-semibold text-gray-900 dark:text-white">
Mot de passe
</h3>
</div>
{changingPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
label="Mot de passe actuel"
type="password"
value={passwordForm.ancien_mot_de_passe}
onChange={(e) => setPasswordForm({ ...passwordForm, ancien_mot_de_passe: e.target.value })}
required
/>
<Input
label="Nouveau mot de passe"
type="password"
value={passwordForm.password}
onChange={(e) => setPasswordForm({ ...passwordForm, password: e.target.value })}
required
/>
<Input
label="Confirmer le nouveau mot de passe"
type="password"
value={passwordForm.password_confirmation}
onChange={(e) => setPasswordForm({ ...passwordForm, password_confirmation: e.target.value })}
required
/>
<div className="flex gap-3">
<Button type="submit" loading={loading}>
Changer le mot de passe
</Button>
<Button variant="ghost" onClick={() => setChangingPassword(false)}>
Annuler
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setChangingPassword(true)}>
Changer mon mot de passe
</Button>
)}
</Card>
{/* Zone dangereuse */}
<Card className="border-red-200 dark:border-red-900">
<div className="flex items-center gap-3 mb-4">
<Shield size={20} className="text-red-500" />
<h3 className="font-display font-semibold text-red-600 dark:text-red-400">
Zone dangereuse
</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
La suppression de votre compte est irréversible. Toutes vos données seront effacées conformément au RGPD.
</p>
<Button variant="danger" onClick={() => setDeleteModalOpen(true)}>
<Trash2 size={16} />
Supprimer mon compte
</Button>
</Card>
{/* Modal confirmation suppression */}
<Modal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title="Confirmer la suppression"
size="sm"
>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.
</p>
<div className="flex gap-3">
<Button variant="danger" onClick={handleDeleteAccount} loading={loading}>
Oui, supprimer
</Button>
<Button variant="ghost" onClick={() => setDeleteModalOpen(false)}>
Annuler
</Button>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,90 @@
'use client';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Calendar, User } from 'lucide-react';
import { useFeed } from '@/hooks/useFeeds';
import Skeleton from '@/components/ui/Skeleton';
export default function ArticlePage() {
const params = useParams();
const slug = params.slug as string;
const { data: feed, isLoading, error } = useFeed(slug);
if (isLoading) {
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Skeleton className="h-8 w-48 mb-8" />
<Skeleton variant="rect" className="h-64 mb-6" />
<Skeleton className="h-10 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-2/3" />
</div>
);
}
if (error || !feed) {
return (
<div className="max-w-3xl mx-auto px-4 py-12 text-center">
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4">
Article introuvable
</p>
<Link
href="/informations"
className="text-malachite-600 dark:text-malachite-400 hover:underline"
>
← Retour aux informations
</Link>
</div>
);
}
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Link
href="/informations"
className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-8 transition-colors"
>
<ArrowLeft size={16} />
Retour aux informations
</Link>
{feed.image_url && (
<div className="relative w-full h-64 sm:h-80 rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 mb-8">
<img
src={feed.image_url}
alt={feed.titre}
className="w-full h-full object-cover"
/>
</div>
)}
<h1 className="font-display text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{feed.titre}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400 dark:text-gray-500 mb-8 pb-8 border-b border-gray-200 dark:border-gray-800">
{feed.auteur && (
<div className="flex items-center gap-1.5">
<User size={14} />
{feed.auteur.prenom} {feed.auteur.nom}
</div>
)}
<div className="flex items-center gap-1.5">
<Calendar size={14} />
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</div>
<article
className="prose prose-lg dark:prose-invert max-w-none prose-headings:font-display prose-a:text-malachite-600 dark:prose-a:text-malachite-400"
dangerouslySetInnerHTML={{ __html: feed.contenu }}
/>
</div>
);
}
@@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
import { BookOpen, Calendar } from 'lucide-react';
import { useFeeds } from '@/hooks/useFeeds';
import Card from '@/components/ui/Card';
import Skeleton from '@/components/ui/Skeleton';
export default function InformationsPage() {
const { data: feeds, isLoading } = useFeeds();
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="font-display text-4xl font-bold text-gray-900 dark:text-white mb-4">
Informations & Ressources
</h1>
<p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
Des contenus validés sur la santé mentale, la gestion du stress
et le bien-être émotionnel.
</p>
</div>
{isLoading ? (
<div className="grid md:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<Skeleton variant="rect" className="h-48 mb-4" />
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-2/3" />
</Card>
))}
</div>
) : feeds && feeds.length > 0 ? (
<div className="grid md:grid-cols-2 gap-6">
{feeds.map((feed) => (
<Link key={feed.id} href={`/informations/${feed.slug}`}>
<Card hover className="h-full">
{feed.image_url && (
<div className="relative w-full h-48 mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800">
<img
src={feed.image_url}
alt={feed.titre}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex items-center gap-2 mb-3">
<BookOpen size={16} className="text-malachite-500" />
<span className="text-xs font-medium text-malachite-600 dark:text-malachite-400 uppercase tracking-wide">
Article
</span>
</div>
<h2 className="font-display text-xl font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
{feed.titre}
</h2>
<div
className="text-gray-500 dark:text-gray-400 text-sm line-clamp-3 mb-4"
dangerouslySetInnerHTML={{
__html: (feed.contenu ?? '').replace(/<[^>]*>/g, '').substring(0, 200) + '...',
}}
/>
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<Calendar size={14} />
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</Card>
</Link>
))}
</div>
) : (
<div className="text-center py-16 text-gray-400 dark:text-gray-600">
<BookOpen size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg">Aucun article disponible pour le moment</p>
</div>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+80
View File
@@ -0,0 +1,80 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: "Inter", system-ui, sans-serif;
--font-display: "Poppins", system-ui, sans-serif;
/* Candlelight */
--color-candlelight-50: #fffde6;
--color-candlelight-100: #fffacc;
--color-candlelight-200: #fff599;
--color-candlelight-300: #ffef66;
--color-candlelight-400: #ffe833;
--color-candlelight-500: #fce117;
--color-candlelight-600: #e5c800;
--color-candlelight-700: #b39c00;
--color-candlelight-800: #806f00;
--color-candlelight-900: #4d4300;
--color-candlelight-950: #332d00;
/* Malachite */
--color-malachite-50: #e6fdf0;
--color-malachite-100: #ccfbe0;
--color-malachite-200: #99f7c2;
--color-malachite-300: #66f3a3;
--color-malachite-400: #33ef85;
--color-malachite-500: #06c656;
--color-malachite-600: #05a348;
--color-malachite-700: #047d37;
--color-malachite-800: #035827;
--color-malachite-900: #023316;
--color-malachite-950: #01220f;
}
/* Variables de thème clair */
:root {
--background: #ffffff;
--foreground: #171717;
--primary: #fce117;
--primary-foreground: #332d00;
--secondary: #06c656;
--secondary-foreground: #01220f;
--muted: #f5f5f5;
--muted-foreground: #737373;
--border: #e5e5e5;
--ring: #fce117;
}
/* Variables de thème sombre */
.dark {
--background: #0a0a0a;
--foreground: #ededed;
--primary: #fce117;
--primary-foreground: #332d00;
--secondary: #06c656;
--secondary-foreground: #01220f;
--muted: #262626;
--muted-foreground: #a3a3a3;
--border: #404040;
--ring: #fce117;
}
/* Styles de base */
body {
background: var(--background);
color: var(--foreground);
font-family: "Inter", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Poppins", system-ui, sans-serif;
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Inter, Poppins } from "next/font/google";
import "./globals.css";
import Providers from "@/components/Providers";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
display: "swap",
});
const poppins = Poppins({
variable: "--font-poppins",
subsets: ["latin"],
weight: ["500", "600", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "CESIZen - Votre sant\u00e9 mentale",
description:
"Plateforme de gestion du stress et suivi \u00e9motionnel. Suivez vos \u00e9motions, consultez des ressources et prenez soin de votre sant\u00e9 mentale.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${inter.variable} ${poppins.variable} antialiased`}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+592
View File
@@ -0,0 +1,592 @@
'use client';
import { useEffect, useRef } from 'react';
import Link from 'next/link';
import {
BookOpen,
Heart,
BarChart3,
Shield,
ArrowRight,
Sparkles,
Brain,
TrendingUp,
Users,
CheckCircle2,
Star,
Zap,
Clock,
SmilePlus,
} from 'lucide-react';
// Hook d'animation au scroll (Intersection Observer)
function useScrollReveal() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('animate-in');
observer.unobserve(el);
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ref;
}
function RevealSection({ children, className = '', delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
const ref = useScrollReveal();
return (
<div
ref={ref}
className={`reveal-section ${className}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export default function HomePage() {
return (
<div className="min-h-screen overflow-hidden">
<style jsx global>{`
.reveal-section {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal-section.animate-in {
opacity: 1;
transform: translateY(0);
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(2deg); }
}
@keyframes float-delay {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(-2deg); }
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0; }
100% { transform: scale(0.8); opacity: 0; }
}
@keyframes count-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.float-anim { animation: float 6s ease-in-out infinite; }
.float-anim-delay { animation: float-delay 7s ease-in-out infinite; }
.gradient-text {
background: linear-gradient(135deg, #06c656, #fce117, #06c656);
background-size: 200% 200%;
animation: gradient-shift 4s ease infinite;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
`}</style>
{/* ─── Hero Section ─── */}
<section className="relative overflow-hidden bg-linear-to-br from-candlelight-50 via-white to-malachite-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 min-h-[90vh] flex items-center">
{/* Orbes décoratives */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-125 h-125 bg-candlelight-200/40 dark:bg-candlelight-500/10 rounded-full blur-[100px] float-anim" />
<div className="absolute -bottom-40 -left-40 w-125 h-125 bg-malachite-200/40 dark:bg-malachite-500/10 rounded-full blur-[100px] float-anim-delay" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-150 h-150 bg-candlelight-100/20 dark:bg-candlelight-500/5 rounded-full blur-[120px]" />
</div>
{/* Grille de fond */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.03)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-size-[60px_60px] pointer-events-none" />
<div className="relative max-w-6xl mx-auto px-4 py-20 sm:py-32 w-full">
<div className="text-center">
<RevealSection>
<div className="inline-flex items-center gap-2 bg-white/70 dark:bg-gray-800/70 backdrop-blur-md rounded-full px-5 py-2.5 mb-8 border border-gray-200/80 dark:border-gray-700/80 shadow-sm">
<Sparkles className="w-4 h-4 text-candlelight-500" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
Plateforme du Ministère des Solidarités et de la Santé
</span>
</div>
</RevealSection>
<RevealSection delay={100}>
<h1 className="font-display text-6xl sm:text-7xl lg:text-8xl font-bold tracking-tight mb-6">
<span className="gradient-text">CESI</span>
<span className="text-gray-900 dark:text-white">ZEN</span>
</h1>
</RevealSection>
<RevealSection delay={200}>
<p className="text-xl sm:text-2xl lg:text-3xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-4 font-light">
Votre compagnon pour une{' '}
<span className="font-semibold text-malachite-600 dark:text-malachite-400">santé mentale</span>{' '}
épanouie
</p>
</RevealSection>
<RevealSection delay={300}>
<p className="text-base sm:text-lg text-gray-500 dark:text-gray-400 max-w-xl mx-auto mb-12 leading-relaxed">
Suivez vos émotions, comprenez vos tendances et prenez soin de vous
au quotidien avec des outils simples, bienveillants et confidentiels.
</p>
</RevealSection>
<RevealSection delay={400}>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16">
<Link
href="/inscription"
className="group inline-flex items-center justify-center gap-2 bg-candlelight-500 hover:bg-candlelight-400 text-black font-semibold px-8 py-4 rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-candlelight-500/30 hover:-translate-y-0.5 text-lg"
>
Commencer gratuitement
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</Link>
<Link
href="/informations"
className="inline-flex items-center justify-center gap-2 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm hover:bg-white dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 font-semibold px-8 py-4 rounded-2xl border-2 border-gray-200 dark:border-gray-700 transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 text-lg"
>
<BookOpen className="w-5 h-5" />
En savoir plus
</Link>
</div>
</RevealSection>
{/* Mini stats */}
<RevealSection delay={500}>
<div className="flex flex-wrap justify-center gap-8 sm:gap-12">
<MiniStat icon={<Users className="w-5 h-5" />} value="100%" label="Gratuit" />
<MiniStat icon={<Shield className="w-5 h-5" />} value="RGPD" label="Conforme" />
<MiniStat icon={<Clock className="w-5 h-5" />} value="2 min" label="Pour commencer" />
</div>
</RevealSection>
</div>
</div>
{/* Séparateur vague */}
<div className="absolute bottom-0 left-0 right-0">
<svg viewBox="0 0 1440 80" fill="none" className="w-full h-auto" preserveAspectRatio="none">
<path d="M0 80L60 68C120 56 240 32 360 24C480 16 600 24 720 32C840 40 960 48 1080 44C1200 40 1320 24 1380 16L1440 8V80H1380C1320 80 1200 80 1080 80C960 80 840 80 720 80C600 80 480 80 360 80C240 80 120 80 60 80H0Z" className="fill-white dark:fill-gray-950" />
</svg>
</div>
</section>
{/* ─── Comment ça marche ─── */}
<section className="py-24 bg-white dark:bg-gray-950">
<div className="max-w-6xl mx-auto px-4">
<RevealSection>
<div className="text-center mb-20">
<span className="inline-flex items-center gap-2 text-malachite-600 dark:text-malachite-400 font-semibold text-sm uppercase tracking-wider mb-4">
<Zap className="w-4 h-4" />
Simple et rapide
</span>
<h2 className="font-display text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Comment ça marche ?
</h2>
<p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
Trois étapes simples pour prendre soin de votre bien-être émotionnel.
</p>
</div>
</RevealSection>
<div className="grid md:grid-cols-3 gap-8 lg:gap-12 relative">
{/* Ligne de connexion */}
<div className="hidden md:block absolute top-24 left-[20%] right-[20%] h-0.5 bg-linear-to-r from-candlelight-300 via-malachite-300 to-candlelight-300 dark:from-candlelight-800 dark:via-malachite-800 dark:to-candlelight-800" />
<RevealSection delay={0}>
<StepCard
step={1}
icon={<SmilePlus className="w-7 h-7" />}
title="Identifiez votre émotion"
description="Choisissez parmi un panel d'émotions et sous-émotions. Notez l'intensité ressentie sur une échelle de 1 à 10."
color="candlelight"
/>
</RevealSection>
<RevealSection delay={150}>
<StepCard
step={2}
icon={<Brain className="w-7 h-7" />}
title="Ajoutez du contexte"
description="Notez ce qui a déclenché cette émotion. Même quelques mots suffisent à mieux comprendre vos tendances."
color="malachite"
/>
</RevealSection>
<RevealSection delay={300}>
<StepCard
step={3}
icon={<TrendingUp className="w-7 h-7" />}
title="Suivez votre évolution"
description="Consultez vos rapports visuels pour identifier vos tendances émotionnelles sur la semaine, le mois ou l'année."
color="candlelight"
/>
</RevealSection>
</div>
</div>
</section>
{/* ─── Features Section ─── */}
<section className="py-24 bg-gray-50 dark:bg-gray-900/50 relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-candlelight-200/20 dark:bg-candlelight-500/5 rounded-full blur-[100px] pointer-events-none" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-malachite-200/20 dark:bg-malachite-500/5 rounded-full blur-[100px] pointer-events-none" />
<div className="max-w-6xl mx-auto px-4 relative">
<RevealSection>
<div className="text-center mb-20">
<span className="inline-flex items-center gap-2 text-candlelight-600 dark:text-candlelight-400 font-semibold text-sm uppercase tracking-wider mb-4">
<Star className="w-4 h-4" />
Fonctionnalités
</span>
<h2 className="font-display text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Tout ce dont vous avez besoin
</h2>
<p className="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
Des outils conçus pour vous accompagner au quotidien dans la gestion
de votre bien-être émotionnel.
</p>
</div>
</RevealSection>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{
icon: <Heart className="w-7 h-7" />,
title: "Journal d'émotions",
description: "Enregistrez vos émotions quotidiennes et suivez leur évolution dans le temps grâce à un journal intime sécurisé.",
color: 'malachite' as const,
delay: 0,
},
{
icon: <BarChart3 className="w-7 h-7" />,
title: 'Rapports visuels',
description: "Visualisez vos tendances grâce à des graphiques clairs : répartition, évolution et intensité de vos émotions.",
color: 'candlelight' as const,
delay: 100,
},
{
icon: <BookOpen className="w-7 h-7" />,
title: 'Ressources fiables',
description: "Accédez à des contenus validés par des experts sur la santé mentale, la gestion du stress et le bien-être.",
color: 'malachite' as const,
delay: 200,
},
{
icon: <Shield className="w-7 h-7" />,
title: 'Données protégées',
description: "Vos données sont chiffrées et protégées conformément au RGPD. Vous gardez le contrôle total sur vos informations.",
color: 'candlelight' as const,
delay: 300,
},
].map((feature) => (
<RevealSection key={feature.title} delay={feature.delay}>
<FeatureCard {...feature} />
</RevealSection>
))}
</div>
</div>
</section>
{/* ─── Témoignages / Social Proof ─── */}
<section className="py-24 bg-white dark:bg-gray-950">
<div className="max-w-6xl mx-auto px-4">
<RevealSection>
<div className="text-center mb-16">
<span className="inline-flex items-center gap-2 text-malachite-600 dark:text-malachite-400 font-semibold text-sm uppercase tracking-wider mb-4">
<Heart className="w-4 h-4" />
Pourquoi CESIZen ?
</span>
<h2 className="font-display text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Conçu pour votre bien-être
</h2>
</div>
</RevealSection>
<div className="grid md:grid-cols-3 gap-8">
{[
{
quote: "Prendre conscience de ses émotions est le premier pas vers une meilleure santé mentale.",
author: "Approche scientifique",
detail: "Basé sur les travaux de la psychologie positive",
delay: 0,
},
{
quote: "Le simple fait de nommer une émotion réduit son intensité de 50%. C'est scientifiquement prouvé.",
author: "Affect Labeling",
detail: "Étude UCLA, Lieberman et al.",
delay: 150,
},
{
quote: "L'écriture expressive améliore la santé physique et mentale sur le long terme.",
author: "Journaling thérapeutique",
detail: "Méthode Pennebaker",
delay: 300,
},
].map((item) => (
<RevealSection key={item.author} delay={item.delay}>
<div className="bg-gray-50 dark:bg-gray-900 rounded-3xl p-8 h-full flex flex-col border border-gray-100 dark:border-gray-800 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="flex gap-1 mb-4">
{[1, 2, 3, 4, 5].map((s) => (
<Star key={s} className="w-4 h-4 fill-candlelight-400 text-candlelight-400" />
))}
</div>
<blockquote className="text-gray-700 dark:text-gray-300 leading-relaxed mb-6 flex-1 italic">
&ldquo;{item.quote}&rdquo;
</blockquote>
<div>
<p className="font-semibold text-gray-900 dark:text-white text-sm">{item.author}</p>
<p className="text-gray-500 dark:text-gray-500 text-xs mt-1">{item.detail}</p>
</div>
</div>
</RevealSection>
))}
</div>
</div>
</section>
{/* ─── Checklist Section ─── */}
<section className="py-24 bg-gray-50 dark:bg-gray-900/50">
<div className="max-w-6xl mx-auto px-4">
<div className="grid lg:grid-cols-2 gap-16 items-center">
<RevealSection>
<div>
<span className="inline-flex items-center gap-2 text-candlelight-600 dark:text-candlelight-400 font-semibold text-sm uppercase tracking-wider mb-4">
<CheckCircle2 className="w-4 h-4" />
Avantages
</span>
<h2 className="font-display text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-6">
Une application pensée pour vous
</h2>
<p className="text-gray-500 dark:text-gray-400 mb-8 leading-relaxed">
CESIZen est conçu par le Ministère des Solidarités et de la Santé
pour offrir un outil fiable, accessible et respectueux de votre vie privée.
</p>
<div className="space-y-4">
{[
'Totalement gratuit et sans publicité',
'Conforme RGPD — vos données vous appartiennent',
'Interface accessible et inclusive (RGAA)',
'Mode sombre pour un usage confortable',
'Rapports visuels pour comprendre vos tendances',
'Contenus validés par des professionnels de santé',
'Export de vos données à tout moment',
'Suppression de compte en un clic',
].map((item, i) => (
<div key={i} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-malachite-500 mt-0.5 shrink-0" />
<span className="text-gray-700 dark:text-gray-300">{item}</span>
</div>
))}
</div>
</div>
</RevealSection>
<RevealSection delay={200}>
<div className="relative">
{/* Carte décorative simulant l'app */}
<div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl shadow-gray-200/50 dark:shadow-black/30 p-8 border border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-candlelight-500 rounded-xl flex items-center justify-center">
<Heart className="w-5 h-5 text-black" />
</div>
<div>
<p className="font-semibold text-gray-900 dark:text-white text-sm">Comment vous sentez-vous ?</p>
<p className="text-gray-500 text-xs">Aujourd&apos;hui à 14:30</p>
</div>
</div>
<div className="grid grid-cols-4 gap-3 mb-6">
{[
{ emoji: '😊', label: 'Joie', color: 'bg-yellow-100 dark:bg-yellow-900/30' },
{ emoji: '😌', label: 'Calme', color: 'bg-blue-100 dark:bg-blue-900/30' },
{ emoji: '😤', label: 'Colère', color: 'bg-red-100 dark:bg-red-900/30' },
{ emoji: '😢', label: 'Triste', color: 'bg-purple-100 dark:bg-purple-900/30' },
].map((e) => (
<div key={e.label} className={`${e.color} rounded-2xl p-3 text-center transition-transform hover:scale-105`}>
<span className="text-2xl block mb-1">{e.emoji}</span>
<span className="text-xs text-gray-600 dark:text-gray-400">{e.label}</span>
</div>
))}
</div>
<div className="space-y-3 mb-6">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Intensité</span>
<span className="text-sm font-semibold text-malachite-600 dark:text-malachite-400">7/10</span>
</div>
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-3">
<div className="bg-linear-to-r from-malachite-400 to-malachite-500 rounded-full h-3" style={{ width: '70%' }} />
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-2xl p-4">
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
&ldquo;Belle journée au travail, bonne ambiance avec l&apos;équipe...&rdquo;
</p>
</div>
</div>
{/* Décoration */}
<div className="absolute -z-10 -top-6 -right-6 w-full h-full bg-linear-to-br from-candlelight-200 to-malachite-200 dark:from-candlelight-900/30 dark:to-malachite-900/30 rounded-3xl" />
</div>
</RevealSection>
</div>
</div>
</section>
{/* ─── CTA Section ─── */}
<section className="py-24 relative overflow-hidden">
<div className="absolute inset-0 bg-linear-to-br from-malachite-500 via-malachite-600 to-malachite-700" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)] bg-size-[40px_40px] pointer-events-none" />
<div className="absolute -top-20 -right-20 w-80 h-80 bg-candlelight-400/20 rounded-full blur-[80px] pointer-events-none" />
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-malachite-300/20 rounded-full blur-[80px] pointer-events-none" />
<div className="max-w-4xl mx-auto px-4 text-center relative">
<RevealSection>
<div className="inline-flex items-center gap-2 bg-white/15 backdrop-blur-sm rounded-full px-5 py-2 mb-8 border border-white/20">
<Sparkles className="w-4 h-4 text-candlelight-300" />
<span className="text-sm font-medium text-white/90">Rejoignez la communauté</span>
</div>
</RevealSection>
<RevealSection delay={100}>
<h2 className="font-display text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
Prêt à prendre soin de vous ?
</h2>
</RevealSection>
<RevealSection delay={200}>
<p className="text-lg text-white/80 mb-10 max-w-2xl mx-auto leading-relaxed">
Créez votre compte en 2 minutes et commencez à suivre votre bien-être
émotionnel. C&apos;est gratuit, confidentiel et sans engagement.
</p>
</RevealSection>
<RevealSection delay={300}>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/inscription"
className="group inline-flex items-center gap-2 bg-candlelight-500 hover:bg-candlelight-400 text-black font-bold px-10 py-4 rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-candlelight-500/30 hover:-translate-y-0.5 text-lg"
>
Créer mon compte
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</Link>
<Link
href="/connexion"
className="inline-flex items-center gap-2 bg-white/15 hover:bg-white/25 backdrop-blur-sm text-white font-semibold px-10 py-4 rounded-2xl border border-white/20 transition-all duration-300 hover:-translate-y-0.5 text-lg"
>
Se connecter
</Link>
</div>
</RevealSection>
</div>
</section>
{/* ─── Urgence Banner ─── */}
<section className="bg-red-50 dark:bg-red-950/30 border-t border-red-100 dark:border-red-900/50 py-8">
<div className="max-w-4xl mx-auto px-4 text-center">
<p className="text-red-700 dark:text-red-300 font-medium leading-relaxed">
<span className="font-bold">En cas d&apos;urgence</span>, appelez le{' '}
<a href="tel:3114" className="font-bold underline decoration-2 underline-offset-2 hover:text-red-900 dark:hover:text-red-200 transition-colors">
3114
</a>{' '}
(numéro national de prévention du suicide) ou le{' '}
<a href="tel:15" className="font-bold underline decoration-2 underline-offset-2 hover:text-red-900 dark:hover:text-red-200 transition-colors">
15
</a>{' '}
(SAMU)
</p>
</div>
</section>
</div>
);
}
/* ─── Composants ─── */
function MiniStat({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
return (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/70 dark:bg-gray-800/70 backdrop-blur-sm rounded-xl flex items-center justify-center text-malachite-600 dark:text-malachite-400 shadow-sm border border-gray-200/50 dark:border-gray-700/50">
{icon}
</div>
<div className="text-left">
<p className="font-bold text-gray-900 dark:text-white text-sm">{value}</p>
<p className="text-gray-500 dark:text-gray-500 text-xs">{label}</p>
</div>
</div>
);
}
function StepCard({
step,
icon,
title,
description,
color,
}: {
step: number;
icon: React.ReactNode;
title: string;
description: string;
color: 'malachite' | 'candlelight';
}) {
const colors = color === 'malachite'
? { bg: 'bg-malachite-50 dark:bg-malachite-950/30', icon: 'text-malachite-600 dark:text-malachite-400', iconBg: 'bg-malachite-100 dark:bg-malachite-900/50', badge: 'bg-malachite-500' }
: { bg: 'bg-candlelight-50 dark:bg-candlelight-950/30', icon: 'text-candlelight-600 dark:text-candlelight-400', iconBg: 'bg-candlelight-100 dark:bg-candlelight-900/50', badge: 'bg-candlelight-500' };
return (
<div className={`${colors.bg} rounded-3xl p-8 text-center relative transition-all duration-300 hover:shadow-xl hover:-translate-y-2 border border-transparent hover:border-gray-200 dark:hover:border-gray-700 h-full`}>
<div className={`${colors.badge} text-white w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm absolute -top-3 left-1/2 -translate-x-1/2 shadow-lg`}>
{step}
</div>
<div className={`${colors.iconBg} ${colors.icon} w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-5 mt-2`}>
{icon}
</div>
<h3 className="font-display font-semibold text-lg text-gray-900 dark:text-white mb-3">{title}</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">{description}</p>
</div>
);
}
function FeatureCard({
icon,
title,
description,
color,
}: {
icon: React.ReactNode;
title: string;
description: string;
color: 'malachite' | 'candlelight';
}) {
const bgColor = color === 'malachite'
? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-800/50';
const iconColor = color === 'malachite'
? 'text-malachite-600 dark:text-malachite-400'
: 'text-candlelight-600 dark:text-candlelight-400';
const iconBg = color === 'malachite'
? 'bg-malachite-100 dark:bg-malachite-900/50'
: 'bg-candlelight-100 dark:bg-candlelight-900/50';
return (
<div className={`${bgColor} rounded-3xl p-7 transition-all duration-300 hover:shadow-xl hover:-translate-y-2 border border-gray-100 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 h-full`}>
<div className={`${iconBg} ${iconColor} w-14 h-14 rounded-2xl flex items-center justify-center mb-5`}>
{icon}
</div>
<h3 className="font-display font-semibold text-lg text-gray-900 dark:text-white mb-3">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
{description}
</p>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
import { useState } from "react";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
);
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" />
</QueryClientProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,160 @@
'use client';
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
Legend,
} from 'recharts';
import type { RapportData } from '@/types';
interface EmotionChartProps {
data: RapportData;
type?: 'repartition' | 'evolution' | 'intensite';
}
export default function EmotionChart({ data, type = 'repartition' }: EmotionChartProps) {
if (type === 'repartition') {
const pieData = data.repartition.map((item) => ({
name: item.nom,
value: item.count,
color: item.couleur,
pourcentage: item.pourcentage,
}));
if (pieData.length === 0) {
return <EmptyChart message="Aucune donnée pour cette période" />;
}
return (
<div className="w-full h-80">
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={120}
paddingAngle={3}
dataKey="value"
label={({ name, pourcentage }: any) => `${name} (${pourcentage}%)`}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: any, name: any) => [`${value} saisies`, name]}
contentStyle={{
backgroundColor: 'rgba(255,255,255,0.95)',
border: 'none',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
if (type === 'evolution') {
const lineData = data.evolution.labels.map((label, index) => {
const point: Record<string, string | number> = { date: label };
data.evolution.emotions.forEach((emotion) => {
point[emotion.nom] = emotion.data[index] || 0;
});
return point;
});
if (lineData.length === 0) {
return <EmptyChart message="Aucune donnée d'évolution" />;
}
return (
<div className="w-full h-80">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255,255,255,0.95)',
border: 'none',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
/>
<Legend />
{data.evolution.emotions.map((emotion) => (
<Line
key={emotion.nom}
type="monotone"
dataKey={emotion.nom}
stroke={emotion.couleur}
strokeWidth={2}
dot={{ fill: emotion.couleur, r: 4 }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
if (type === 'intensite') {
const barData = data.evolution.labels.map((label, index) => ({
date: label,
intensite: data.evolution.intensite_moyenne[index] || 0,
}));
if (barData.length === 0) {
return <EmptyChart message="Aucune donnée d'intensité" />;
}
return (
<div className="w-full h-80">
<ResponsiveContainer width="100%" height={320}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis domain={[0, 10]} tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255,255,255,0.95)',
border: 'none',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
formatter={(value: any) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']}
/>
<Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
return null;
}
function EmptyChart({ message }: { message: string }) {
return (
<div className="w-full h-80 flex items-center justify-center text-gray-400 dark:text-gray-600">
<p className="text-lg">{message}</p>
</div>
);
}
@@ -0,0 +1,105 @@
'use client';
import React, { useState } from 'react';
import { useEmotions } from '@/hooks/useEmotions';
import type { Emotion } from '@/types';
interface EmotionPickerProps {
value?: number;
onChange: (emotionId: number) => void;
}
export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
const { data: emotions, isLoading } = useEmotions();
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
if (isLoading) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-20 rounded-xl bg-gray-100 dark:bg-gray-800 animate-pulse" />
))}
</div>
);
}
// Si une émotion parent est sélectionnée et qu'elle a des enfants, afficher les sous-émotions
if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
return (
<div>
<button
type="button"
onClick={() => setSelectedParent(null)}
className="mb-4 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 flex items-center gap-1"
>
← Retour aux émotions
</button>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Précisez votre émotion de <span className="font-medium" style={{ color: selectedParent.couleur }}>{selectedParent.nom}</span> :
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{selectedParent.enfants.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onChange(child.id)}
className={`
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${value === child.id
? 'border-current shadow-lg scale-105'
: 'border-gray-200 dark:border-gray-700 hover:border-current hover:shadow-md'
}
`}
style={{
color: child.couleur,
borderColor: value === child.id ? child.couleur : undefined,
}}
>
<span className="text-2xl">{child.icone || '🔵'}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{child.nom}</span>
</button>
))}
</div>
</div>
);
}
return (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{emotions?.map((emotion) => {
const hasChildren = emotion.enfants && emotion.enfants.length > 0;
const isSelected = value === emotion.id || emotion.enfants?.some(e => e.id === value);
return (
<button
key={emotion.id}
type="button"
onClick={() => {
if (hasChildren) {
setSelectedParent(emotion);
} else {
onChange(emotion.id);
}
}}
className={`
flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isSelected
? 'shadow-lg scale-105'
: 'border-gray-200 dark:border-gray-700 hover:shadow-md'
}
`}
style={{
borderColor: isSelected ? emotion.couleur : undefined,
}}
>
<span className="text-3xl">{emotion.icone || '🔵'}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{emotion.nom}</span>
{hasChildren && (
<span className="text-xs text-gray-400">▼</span>
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,113 @@
'use client';
import React from 'react';
import type { SaisieTracker } from '@/types';
import Badge from '@/components/ui/Badge';
import { Trash2, Edit2 } from 'lucide-react';
interface TrackerTimelineProps {
saisies: SaisieTracker[];
onEdit?: (saisie: SaisieTracker) => void;
onDelete?: (id: number) => void;
}
export default function TrackerTimeline({ saisies, onEdit, onDelete }: TrackerTimelineProps) {
if (saisies.length === 0) {
return (
<div className="text-center py-12 text-gray-400 dark:text-gray-600">
<p className="text-lg mb-2">Aucune saisie enregistrée</p>
<p className="text-sm">Commencez par enregistrer votre première émotion</p>
</div>
);
}
// Grouper les saisies par date
const grouped = saisies.reduce<Record<string, SaisieTracker[]>>((acc, saisie) => {
const date = new Date(saisie.date_saisie).toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
if (!acc[date]) acc[date] = [];
acc[date].push(saisie);
return acc;
}, {});
return (
<div className="space-y-8">
{Object.entries(grouped).map(([date, items]) => (
<div key={date}>
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
{date}
</h3>
<div className="space-y-3">
{items.map((saisie) => (
<div
key={saisie.id}
className="flex items-start gap-4 p-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 hover:shadow-md transition-all duration-200"
>
{/* Icône émotion */}
<div
className="shrink-0 w-12 h-12 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: `${saisie.emotion?.couleur}20` }}
>
{saisie.emotion?.icone || '🔵'}
</div>
{/* Contenu */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge color={saisie.emotion?.couleur}>
{saisie.emotion?.nom}
</Badge>
<span className="text-sm text-gray-400 dark:text-gray-500">
Intensité : {saisie.intensite}/10
</span>
</div>
{/* Barre d'intensité */}
<div className="w-full h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full mb-2">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${saisie.intensite * 10}%`,
backgroundColor: saisie.emotion?.couleur || '#fce117',
}}
/>
</div>
{saisie.note && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{saisie.note}
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{onEdit && (
<button
onClick={() => onEdit(saisie)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Modifier"
>
<Edit2 size={16} />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(saisie.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-400 hover:text-red-500 transition-colors"
aria-label="Supprimer"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import React from "react";
import Link from "next/link";
import { Phone } from "lucide-react";
export default function Footer() {
return (
<footer className="bg-gray-50 dark:bg-gray-950 border-t border-gray-200 dark:border-gray-800">
{/* Bandeau urgence */}
<div className="bg-red-50 dark:bg-red-950/50 border-b border-red-100 dark:border-red-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<p className="flex items-center justify-center gap-2 text-sm text-red-700 dark:text-red-400 font-medium text-center">
<Phone size={16} className="shrink-0" />
En cas d&apos;urgence : 3114 (numéro national de prévention du suicide)
</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
{/* Logo + Copyright */}
<div className="flex flex-col items-center md:items-start gap-2">
<div className="flex items-center gap-0.5">
<span className="text-xl font-bold font-display text-malachite-500">
CESI
</span>
<span className="text-xl font-bold font-display text-candlelight-500">
ZEN
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center md:text-left">
CESIZen 2024 - Ministère des Solidarités et de la Santé
</p>
</div>
{/* Links */}
<nav className="flex flex-wrap items-center justify-center gap-6">
<Link
href="/mentions-legales"
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
>
Mentions légales
</Link>
<Link
href="/politique-de-confidentialite"
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
>
Politique de confidentialité
</Link>
<Link
href="/contacts-urgence"
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
>
Contacts d&apos;urgence
</Link>
</nav>
</div>
</div>
</footer>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Menu,
X,
User,
LogOut,
Settings,
BarChart3,
BookOpen,
Home,
} from "lucide-react";
import ThemeToggle from "@/components/ui/ThemeToggle";
import { useAuthStore } from "@/lib/auth-store";
interface NavLink {
href: string;
label: string;
icon: React.ReactNode;
}
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const pathname = usePathname();
const { user, token, logout } = useAuthStore();
const isAuthenticated = !!token;
const isAdmin = user?.role?.nom === "administrateur";
const publicLinks: NavLink[] = [
{ href: "/", label: "Accueil", icon: <Home size={18} /> },
{ href: "/informations", label: "Informations", icon: <BookOpen size={18} /> },
];
const authLinks: NavLink[] = [
{ href: "/journal", label: "Journal", icon: <BookOpen size={18} /> },
{ href: "/journal/rapports", label: "Rapports", icon: <BarChart3 size={18} /> },
];
const adminLinks: NavLink[] = [
{ href: "/admin", label: "Admin", icon: <Settings size={18} /> },
];
const navLinks = [
...publicLinks,
...(isAuthenticated ? authLinks : []),
...(isAdmin ? adminLinks : []),
];
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
const handleLogout = () => {
logout();
setUserDropdownOpen(false);
setMobileMenuOpen(false);
};
return (
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-lg border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-0.5 shrink-0">
<span className="text-2xl font-bold font-display text-malachite-500">
CESI
</span>
<span className="text-2xl font-bold font-display text-candlelight-500">
ZEN
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`
flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200
${
isActive(link.href)
? "bg-candlelight-100 dark:bg-candlelight-950 text-candlelight-700 dark:text-candlelight-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200"
}
`}
>
{link.icon}
{link.label}
</Link>
))}
</nav>
{/* Right actions */}
<div className="flex items-center gap-2">
<ThemeToggle />
{/* Desktop auth */}
<div className="hidden md:flex items-center gap-2">
{isAuthenticated ? (
<div className="relative">
<button
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div className="h-8 w-8 rounded-full bg-malachite-100 dark:bg-malachite-900 flex items-center justify-center">
<User
size={16}
className="text-malachite-600 dark:text-malachite-400"
/>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{user?.prenom || "Utilisateur"}
</span>
</button>
{userDropdownOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setUserDropdownOpen(false)}
/>
<div className="absolute right-0 mt-2 w-48 z-20 bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-100 dark:border-gray-800 py-1 animate-in fade-in zoom-in-95 duration-150">
<Link
href="/profil"
onClick={() => setUserDropdownOpen(false)}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Settings size={16} />
Mon profil
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
>
<LogOut size={16} />
Déconnexion
</button>
</div>
</>
)}
</div>
) : (
<>
<Link
href="/connexion"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
>
Connexion
</Link>
<Link
href="/inscription"
className="px-4 py-2 text-sm font-semibold bg-candlelight-500 hover:bg-candlelight-600 text-black rounded-xl transition-all duration-200 shadow-sm hover:shadow-md"
>
Inscription
</Link>
</>
)}
</div>
{/* Mobile hamburger */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-400"
aria-label="Menu"
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 animate-in slide-in-from-top duration-200">
<nav className="px-4 py-4 space-y-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileMenuOpen(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${
isActive(link.href)
? "bg-candlelight-100 dark:bg-candlelight-950 text-candlelight-700 dark:text-candlelight-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}
`}
>
{link.icon}
{link.label}
</Link>
))}
<div className="pt-4 border-t border-gray-200 dark:border-gray-800 mt-4 space-y-1">
{isAuthenticated ? (
<>
<Link
href="/profil"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<User size={18} />
Mon profil
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
>
<LogOut size={18} />
Déconnexion
</button>
</>
) : (
<>
<Link
href="/connexion"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
Connexion
</Link>
<Link
href="/inscription"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-semibold bg-candlelight-500 hover:bg-candlelight-600 text-black transition-all duration-200"
>
Inscription
</Link>
</>
)}
</div>
</nav>
</div>
)}
</header>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
FileText,
Heart,
Menu,
X,
} from "lucide-react";
interface SidebarLink {
href: string;
label: string;
icon: React.ReactNode;
}
const sidebarLinks: SidebarLink[] = [
{
href: "/admin",
label: "Dashboard",
icon: <LayoutDashboard size={20} />,
},
{
href: "/admin/utilisateurs",
label: "Utilisateurs",
icon: <Users size={20} />,
},
{
href: "/admin/contenus",
label: "Contenus",
icon: <FileText size={20} />,
},
{
href: "/admin/emotions",
label: "Émotions",
icon: <Heart size={20} />,
},
];
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
};
return (
<>
{/* Mobile toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
className="lg:hidden fixed top-20 left-4 z-40 p-2 bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400"
aria-label="Toggle sidebar"
>
{collapsed ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay mobile */}
{collapsed && (
<div
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
onClick={() => setCollapsed(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:sticky top-0 left-0 z-40 lg:z-auto
h-screen w-64
bg-white dark:bg-gray-950
border-r border-gray-200 dark:border-gray-800
flex flex-col
transition-transform duration-300 ease-in-out
${collapsed ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
>
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-800">
<Link href="/admin" className="flex items-center gap-0.5">
<span className="text-xl font-bold font-display text-malachite-500">
CESI
</span>
<span className="text-xl font-bold font-display text-candlelight-500">
ZEN
</span>
</Link>
<button
onClick={() => setCollapsed(false)}
className="lg:hidden p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
>
<X size={18} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{sidebarLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setCollapsed(false)}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${
isActive(link.href)
? "bg-candlelight-100 dark:bg-candlelight-950 text-candlelight-700 dark:text-candlelight-400 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200"
}
`}
>
{link.icon}
{link.label}
</Link>
))}
</nav>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<p className="text-xs text-gray-400 dark:text-gray-600">
Administration CESIZen
</p>
</div>
</aside>
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import React from "react";
interface BadgeProps {
children: React.ReactNode;
color?: string;
className?: string;
}
export default function Badge({
children,
color = "#fce117",
className = "",
}: BadgeProps) {
return (
<span
className={`
inline-flex items-center
px-2.5 py-0.5
rounded-full
text-xs font-medium
transition-all duration-200
${className}
`}
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{children}
</span>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React from "react";
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary:
"bg-candlelight-500 hover:bg-candlelight-600 text-black font-semibold shadow-sm hover:shadow-md",
secondary:
"bg-malachite-500 hover:bg-malachite-600 text-white font-semibold shadow-sm hover:shadow-md",
outline:
"border-2 border-candlelight-500 text-candlelight-700 dark:text-candlelight-400 hover:bg-candlelight-50 dark:hover:bg-candlelight-950",
ghost:
"hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300",
danger:
"bg-red-500 hover:bg-red-600 text-white font-semibold shadow-sm hover:shadow-md",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-7 py-3.5 text-lg",
};
export default function Button({
variant = "primary",
size = "md",
loading = false,
disabled,
children,
className = "",
...rest
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center gap-2
rounded-xl transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2
dark:focus:ring-offset-gray-900
disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]}
${sizeClasses[size]}
${className}
`}
{...rest}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import React from "react";
interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
}
export default function Card({ children, className = "", hover = false }: CardProps) {
return (
<div
className={`
bg-white dark:bg-gray-900
rounded-2xl shadow-sm
border border-gray-100 dark:border-gray-800
p-6
transition-all duration-200
${hover ? "hover:shadow-lg hover:-translate-y-0.5" : ""}
${className}
`}
>
{children}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import React from "react";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function Input({
label,
error,
icon,
className = "",
id,
...inputProps
}: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 dark:text-gray-500">
{icon}
</div>
)}
<input
id={inputId}
className={`
w-full rounded-xl border border-gray-300 dark:border-gray-700
bg-white dark:bg-gray-900
text-gray-900 dark:text-gray-100
placeholder:text-gray-400 dark:placeholder:text-gray-500
focus:outline-none focus:ring-2 focus:ring-candlelight-500 focus:border-candlelight-500
transition-all duration-200
px-4 py-2.5 text-base
${icon ? "pl-10" : ""}
${error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : ""}
${className}
`}
{...inputProps}
/>
</div>
{error && (
<p className="mt-1.5 text-sm text-red-500 dark:text-red-400">
{error}
</p>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import React, { useEffect, useCallback } from "react";
import { X } from "lucide-react";
type ModalSize = "sm" | "md" | "lg";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: ModalSize;
}
const sizeClasses: Record<ModalSize, string> = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
};
export default function Modal({
isOpen,
onClose,
title,
children,
size = "md",
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
},
[onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-100 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
/>
{/* Modal */}
<div
className={`
relative w-full ${sizeClasses[size]}
bg-white dark:bg-gray-900
rounded-2xl shadow-2xl
border border-gray-100 dark:border-gray-800
p-6
animate-in zoom-in-95 fade-in duration-200
`}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
{title && (
<h2 className="text-xl font-bold font-display text-gray-900 dark:text-gray-100">
{title}
</h2>
)}
<button
onClick={onClose}
className="ml-auto p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
aria-label="Fermer"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div>{children}</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import React from "react";
type SkeletonVariant = "text" | "circle" | "rect";
interface SkeletonProps {
className?: string;
variant?: SkeletonVariant;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: "h-4 w-full rounded",
circle: "h-10 w-10 rounded-full",
rect: "h-24 w-full rounded-xl",
};
export default function Skeleton({
className = "",
variant = "text",
}: SkeletonProps) {
return (
<div
className={`
animate-pulse
bg-gray-200 dark:bg-gray-700
${variantClasses[variant]}
${className}
`}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import React from "react";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label?: string;
showValue?: boolean;
}
export default function Slider({
value,
onChange,
min = 1,
max = 10,
label,
showValue = true,
}: SliderProps) {
return (
<div className="w-full">
{(label || showValue) && (
<div className="flex items-center justify-between mb-2">
{label && (
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
{showValue && (
<span className="text-sm font-bold text-candlelight-600 dark:text-candlelight-400 bg-candlelight-50 dark:bg-candlelight-950 px-2 py-0.5 rounded-lg">
{value}
</span>
)}
</div>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-2 rounded-full appearance-none cursor-pointer bg-gray-200 dark:bg-gray-700 accent-candlelight-500"
/>
<div className="flex justify-between mt-1">
<span className="text-xs text-gray-400 dark:text-gray-500">{min}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{max}</span>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import React from "react";
import { useTheme } from "next-themes";
import { Sun, Moon } from "lucide-react";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Changer le thème"
>
<div className="h-5 w-5" />
</button>
);
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200 text-gray-600 dark:text-gray-400"
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
>
{theme === "dark" ? <Sun size={20} /> : <Moon size={20} />}
</button>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { useAuthStore } from '@/lib/auth-store';
export function useAuth() {
const store = useAuthStore();
const isAdmin = store.user?.role?.nom === 'administrateur';
return {
...store,
isAdmin,
};
}
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Emotion } from '@/types';
export function useEmotions() {
return useQuery<Emotion[]>({
queryKey: ['emotions'],
queryFn: async () => {
const response = await api.get('/emotions');
return response.data;
},
});
}
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Feed } from '@/types';
export function useFeeds() {
return useQuery<Feed[]>({
queryKey: ['feeds'],
queryFn: async () => {
const response = await api.get('/feeds');
return response.data;
},
});
}
export function useFeed(slug: string) {
return useQuery<Feed>({
queryKey: ['feeds', slug],
queryFn: async () => {
const response = await api.get(`/feeds/${slug}`);
return response.data;
},
enabled: !!slug,
});
}
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { RapportData } from '@/types';
export function useRapport(period: 'week' | 'month' | 'quarter' | 'year') {
return useQuery<RapportData>({
queryKey: ['rapport', period],
queryFn: async () => {
const response = await api.get('/tracker/rapports', {
params: { period },
});
return response.data;
},
enabled: !!period,
});
}
+75
View File
@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import type { SaisieTracker } from '@/types';
interface SaisieFilters {
date_debut?: string;
date_fin?: string;
emotion_id?: number;
}
export function useSaisies(filters?: SaisieFilters) {
return useQuery<SaisieTracker[]>({
queryKey: ['saisies', filters],
queryFn: async () => {
const response = await api.get('/tracker/saisies', { params: filters });
return response.data.data ?? response.data;
},
});
}
export function useCreateSaisie() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: {
emotion_id: number;
intensite: number;
note?: string;
date_saisie?: string;
}) => {
const response = await api.post('/tracker/saisies', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saisies'] });
},
});
}
export function useUpdateSaisie() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
...data
}: {
id: number;
emotion_id?: number;
intensite?: number;
note?: string;
date_saisie?: string;
}) => {
const response = await api.put(`/tracker/saisies/${id}`, data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saisies'] });
},
});
}
export function useDeleteSaisie() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.delete(`/tracker/saisies/${id}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saisies'] });
},
});
}
+41
View File
@@ -0,0 +1,41 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Intercepteur de requête : ajouter le token JWT si présent
api.interceptors.request.use(
(config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('cesizen_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Intercepteur de réponse : gérer les erreurs 401
api.interceptors.response.use(
(response) => response,
(error) => {
if (
error.response?.status === 401 &&
typeof window !== 'undefined' &&
!window.location.pathname.includes('/connexion')
) {
localStorage.removeItem('cesizen_token');
window.location.href = '/connexion';
}
return Promise.reject(error);
}
);
export default api;
+103
View File
@@ -0,0 +1,103 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import api from './api';
import type { Utilisateur } from '@/types';
function syncCookie(user: Utilisateur | null, token: string | null) {
if (typeof document === 'undefined') return;
if (token) {
const value = JSON.stringify({
state: {
token,
isAuthenticated: true,
user: user ? { role: { nom: user.role?.nom } } : null,
},
});
document.cookie = `cesizen-auth=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 7};SameSite=Lax`;
} else {
document.cookie = 'cesizen-auth=;path=/;max-age=0';
}
}
interface AuthState {
user: Utilisateur | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: {
nom: string;
prenom: string;
email: string;
password: string;
password_confirmation: string;
}) => Promise<void>;
logout: () => Promise<void>;
fetchUser: () => Promise<void>;
setAuth: (user: Utilisateur | null, token: string | null) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
const { utilisateur, token } = response.data;
localStorage.setItem('cesizen_token', token);
syncCookie(utilisateur, token);
set({ user: utilisateur, token, isAuthenticated: true });
},
register: async (data) => {
const response = await api.post('/auth/register', data);
const { utilisateur, token } = response.data;
localStorage.setItem('cesizen_token', token);
syncCookie(utilisateur, token);
set({ user: utilisateur, token, isAuthenticated: true });
},
logout: async () => {
try {
await api.post('/auth/logout');
} catch {
// Ignorer les erreurs de déconnexion
}
localStorage.removeItem('cesizen_token');
syncCookie(null, null);
set({ user: null, token: null, isAuthenticated: false });
},
fetchUser: async () => {
const response = await api.get('/auth/me');
set({ user: response.data });
},
setAuth: (user: Utilisateur | null, token: string | null) => {
if (token) {
localStorage.setItem('cesizen_token', token);
} else {
localStorage.removeItem('cesizen_token');
}
set({ user, token, isAuthenticated: !!token });
},
}),
{
name: 'cesizen-auth',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
onRehydrateStorage: () => {
return (state) => {
if (state) {
syncCookie(state.user, state.token);
}
};
},
}
)
);
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/profil', '/journal'];
const adminRoutes = ['/admin'];
const authRoutes = ['/connexion', '/inscription'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Vérifier si un token existe dans les cookies de Zustand persist (localStorage côté client)
// En middleware Next.js (edge), on ne peut pas lire localStorage.
// On utilise un cookie pour transmettre l'état d'auth.
const authCookie = request.cookies.get('cesizen-auth');
let isAuthenticated = false;
let isAdmin = false;
if (authCookie?.value) {
try {
const parsed = JSON.parse(authCookie.value);
const state = parsed?.state;
isAuthenticated = !!state?.token;
isAdmin = state?.user?.role?.nom === 'administrateur';
} catch {
// Cookie invalide
}
}
// Pages auth (connexion/inscription) : rediriger si déjà connecté
if (authRoutes.some((route) => pathname.startsWith(route))) {
if (isAuthenticated) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
// Routes protégées : rediriger si pas connecté
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!isAuthenticated) {
const url = new URL('/connexion', request.url);
url.searchParams.set('redirect', pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// Routes admin : vérifier rôle admin
if (adminRoutes.some((route) => pathname.startsWith(route))) {
if (!isAuthenticated) {
const url = new URL('/connexion', request.url);
url.searchParams.set('redirect', pathname);
return NextResponse.redirect(url);
}
if (!isAdmin) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: [
'/dashboard/:path*',
'/profil/:path*',
'/journal/:path*',
'/admin/:path*',
'/connexion',
'/inscription',
],
};
+95
View File
@@ -0,0 +1,95 @@
export interface Role {
id: number;
nom: string;
description: string | null;
}
export interface Utilisateur {
id: number;
nom: string;
prenom: string;
email: string;
role_id: number;
role: Role;
est_actif: boolean;
created_at: string;
updated_at: string;
}
export interface Emotion {
id: number;
nom: string;
couleur: string;
icone: string | null;
niveau: number;
parent_id: number | null;
est_actif: boolean;
enfants?: Emotion[];
}
export interface SaisieTracker {
id: number;
tracker_id: number;
emotion_id: number;
emotion: Emotion;
intensite: number;
note: string | null;
date_saisie: string;
created_at: string;
}
export interface Feed {
id: number;
titre: string;
slug: string;
contenu: string;
image_url: string | null;
est_publie: boolean;
auteur_id: number;
auteur?: { id: number; nom: string; prenom: string };
ordre: number;
created_at: string;
}
export interface RapportStats {
total_saisies: number;
intensite_moyenne: number;
emotion_dominante: {
nom: string;
couleur: string;
icone: string | null;
count: number;
} | null;
}
export interface RapportData {
periode: string;
date_debut: string;
date_fin: string;
stats: RapportStats;
repartition: {
nom: string;
couleur: string;
icone: string | null;
count: number;
pourcentage: number;
intensite_moyenne: number;
}[];
evolution: {
labels: string[];
intensite_moyenne: number[];
emotions: {
nom: string;
couleur: string;
data: number[];
}[];
};
}
export interface PaginatedResponse<T> {
data: T[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
theme: {
extend: {
colors: {
candlelight: {
50: "#fffde6",
100: "#fffacc",
200: "#fff599",
300: "#ffef66",
400: "#ffe833",
500: "#fce117",
600: "#e5c800",
700: "#b39c00",
800: "#806f00",
900: "#4d4300",
950: "#332d00",
},
malachite: {
50: "#e6fdf0",
100: "#ccfbe0",
200: "#99f7c2",
300: "#66f3a3",
400: "#33ef85",
500: "#06c656",
600: "#05a348",
700: "#047d37",
800: "#035827",
900: "#023316",
950: "#01220f",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
display: ["Poppins", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;
+34
View 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"]
}