fix: corrige les dépendances mobile et la qualité du code frontend
Mobile (Expo SDK 53) : - supprime victory-native et react-native-svg (dépendances mortes, non importées) qui empêchaient l'installation (ETARGET sur svg@~16.0.0 + conflit de peer reanimated/skia) - ajoute les peers manquants expo-constants et expo-linking (requis par expo-router) - aligne expo-router, react-native et react-native-screens sur le SDK 53 - ajoute package-lock.json (installs reproductibles) - expo-doctor 18/18, tsc sans erreur Frontend (Next.js) : - EmotionChart : remplace 4 `any` par les types Recharts (ValueType/NameType) - EditSaisieModal : remplace le setState dans useEffect par l'ajustement d'état pendant le rendu (pattern recommandé par React) - tsc, eslint et build de production sans erreur Backend : 15 tests passent (Auth, Admin, Tracker, RGPD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,9 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Requests\UpdatePasswordRequest;
|
||||
use App\Http\Requests\UpdateProfilRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProfilController extends Controller
|
||||
{
|
||||
@@ -56,4 +58,68 @@ class ProfilController extends Controller
|
||||
'message' => 'Compte supprimé avec succès',
|
||||
]);
|
||||
}
|
||||
|
||||
// RGPD — Article 20 : droit à la portabilité des données
|
||||
public function export(): JsonResponse
|
||||
{
|
||||
$utilisateur = auth()->user();
|
||||
$utilisateur->load(['role', 'tracker.saisies.emotion', 'contactsUrgence']);
|
||||
|
||||
$payload = [
|
||||
'export_genere_le' => now()->toIso8601String(),
|
||||
'utilisateur' => [
|
||||
'id' => $utilisateur->id,
|
||||
'nom' => $utilisateur->nom,
|
||||
'prenom' => $utilisateur->prenom,
|
||||
'email' => $utilisateur->email,
|
||||
'role' => $utilisateur->role->nom ?? null,
|
||||
'consentement_rgpd' => $utilisateur->consentement_rgpd,
|
||||
'est_actif' => $utilisateur->est_actif,
|
||||
'cree_le' => $utilisateur->created_at?->toIso8601String(),
|
||||
'mis_a_jour_le' => $utilisateur->updated_at?->toIso8601String(),
|
||||
],
|
||||
'saisies_tracker' => $utilisateur->tracker
|
||||
? $utilisateur->tracker->saisies->map(fn ($s) => [
|
||||
'date_saisie' => $s->date_saisie?->toIso8601String(),
|
||||
'emotion' => $s->emotion->nom ?? null,
|
||||
'emotion_parent' => $s->emotion->parent->nom ?? null,
|
||||
'intensite' => $s->intensite,
|
||||
'note' => $s->note,
|
||||
])->values()
|
||||
: [],
|
||||
];
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Content-Disposition', 'attachment; filename="cesizen-export-'.$utilisateur->id.'.json"');
|
||||
}
|
||||
|
||||
// RGPD — Article 17 : alternative à la suppression définitive — anonymisation
|
||||
public function anonymiser(): JsonResponse
|
||||
{
|
||||
$utilisateur = auth()->user();
|
||||
|
||||
DB::transaction(function () use ($utilisateur) {
|
||||
$pseudo = 'anonyme_'.Str::random(10);
|
||||
$utilisateur->update([
|
||||
'nom' => 'Anonyme',
|
||||
'prenom' => 'Utilisateur',
|
||||
'email' => $pseudo.'@anonymise.cesizen.local',
|
||||
'password' => Hash::make(Str::random(40)),
|
||||
'est_actif' => false,
|
||||
'consentement_rgpd' => false,
|
||||
]);
|
||||
|
||||
$utilisateur->contactsUrgence()->delete();
|
||||
|
||||
if ($utilisateur->tracker) {
|
||||
$utilisateur->tracker->saisies()->update(['note' => null]);
|
||||
}
|
||||
});
|
||||
|
||||
auth()->logout();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Compte anonymisé. Vos données personnelles ont été effacées et vos saisies dépersonnalisées.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class SaisieTrackerController extends Controller
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$query = $tracker->saisies()->with('emotion')->orderBy('date_saisie', 'desc');
|
||||
$query = $tracker->saisies()->with('emotion.parent')->orderBy('date_saisie', 'desc');
|
||||
|
||||
if ($request->filled('date_debut')) {
|
||||
$query->where('date_saisie', '>=', $request->date_debut);
|
||||
|
||||
@@ -44,6 +44,10 @@ Route::prefix('v1')->group(function () {
|
||||
Route::put('profil/password', [ProfilController::class, 'updatePassword']);
|
||||
Route::delete('profil', [ProfilController::class, 'destroy']);
|
||||
|
||||
// RGPD
|
||||
Route::get('profil/export', [ProfilController::class, 'export']);
|
||||
Route::post('profil/anonymiser', [ProfilController::class, 'anonymiser']);
|
||||
|
||||
// Émotions
|
||||
Route::get('emotions', [EmotionController::class, 'index']);
|
||||
|
||||
@@ -58,7 +62,7 @@ Route::prefix('v1')->group(function () {
|
||||
});
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────
|
||||
Route::middleware(['jwt.auth', 'role:administrateur'])->prefix('admin')->group(function () {
|
||||
Route::middleware(['jwt.auth', 'role:administrateur', 'throttle:60,1'])->prefix('admin')->group(function () {
|
||||
|
||||
// Utilisateurs
|
||||
Route::get('utilisateurs', [AdminUtilisateurController::class, 'index']);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\Utilisateur;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||
|
||||
class AdminUtilisateurTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected Utilisateur $admin;
|
||||
protected Utilisateur $membre;
|
||||
protected string $tokenAdmin;
|
||||
protected string $tokenMembre;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$roleAdmin = Role::where('nom', 'administrateur')->first();
|
||||
$roleMembre = Role::where('nom', 'membre')->first();
|
||||
|
||||
$this->admin = Utilisateur::create([
|
||||
'nom' => 'Admin', 'prenom' => 'Super', 'email' => 'admin@test.fr',
|
||||
'password' => 'Admin123!', 'role_id' => $roleAdmin->id, 'consentement_rgpd' => true,
|
||||
]);
|
||||
$this->membre = Utilisateur::create([
|
||||
'nom' => 'Membre', 'prenom' => 'Lambda', 'email' => 'membre@test.fr',
|
||||
'password' => 'Password123!', 'role_id' => $roleMembre->id, 'consentement_rgpd' => true,
|
||||
]);
|
||||
|
||||
$this->tokenAdmin = JWTAuth::fromUser($this->admin);
|
||||
$this->tokenMembre = JWTAuth::fromUser($this->membre);
|
||||
}
|
||||
|
||||
public function test_un_admin_peut_lister_les_utilisateurs(): void
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
|
||||
->getJson('/api/v1/admin/utilisateurs')
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_un_membre_ne_peut_pas_acceder_a_l_admin(): void
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenMembre])
|
||||
->getJson('/api/v1/admin/utilisateurs')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_un_admin_peut_desactiver_un_compte(): void
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
|
||||
->patchJson('/api/v1/admin/utilisateurs/'.$this->membre->id.'/toggle-active')
|
||||
->assertOk();
|
||||
|
||||
$this->assertFalse($this->membre->fresh()->est_actif);
|
||||
}
|
||||
|
||||
public function test_un_admin_peut_supprimer_un_compte_soft_delete(): void
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenAdmin])
|
||||
->deleteJson('/api/v1/admin/utilisateurs/'.$this->membre->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertSoftDeleted('utilisateurs', ['id' => $this->membre->id]);
|
||||
}
|
||||
|
||||
public function test_endpoint_export_rgpd_renvoie_les_donnees_utilisateur(): void
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->tokenMembre])
|
||||
->getJson('/api/v1/profil/export')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['export_genere_le', 'utilisateur', 'saisies_tracker']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\Utilisateur;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
}
|
||||
|
||||
public function test_un_visiteur_peut_creer_un_compte(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/auth/register', [
|
||||
'nom' => 'Marzuk',
|
||||
'prenom' => 'Adam',
|
||||
'email' => 'adam@test.fr',
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
'consentement_rgpd' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure(['utilisateur', 'token']);
|
||||
|
||||
$this->assertDatabaseHas('utilisateurs', ['email' => 'adam@test.fr']);
|
||||
}
|
||||
|
||||
public function test_un_utilisateur_peut_se_connecter(): void
|
||||
{
|
||||
$role = Role::where('nom', 'membre')->first();
|
||||
Utilisateur::create([
|
||||
'nom' => 'Test',
|
||||
'prenom' => 'User',
|
||||
'email' => 'login@test.fr',
|
||||
'password' => 'Password123!',
|
||||
'role_id' => $role->id,
|
||||
'consentement_rgpd' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => 'login@test.fr',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['utilisateur', 'token']);
|
||||
}
|
||||
|
||||
public function test_un_compte_desactive_ne_peut_pas_se_connecter(): void
|
||||
{
|
||||
$role = Role::where('nom', 'membre')->first();
|
||||
Utilisateur::create([
|
||||
'nom' => 'Inactif',
|
||||
'prenom' => 'User',
|
||||
'email' => 'inactif@test.fr',
|
||||
'password' => 'Password123!',
|
||||
'role_id' => $role->id,
|
||||
'consentement_rgpd' => true,
|
||||
'est_actif' => false,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => 'inactif@test.fr',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_un_acces_protege_renvoie_401_sans_token(): void
|
||||
{
|
||||
$this->getJson('/api/v1/profil')->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tracker;
|
||||
use App\Models\Utilisateur;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||
|
||||
class TrackerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected Utilisateur $utilisateur;
|
||||
protected Emotion $emotion;
|
||||
protected string $token;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->emotion = Emotion::create([
|
||||
'nom' => 'Joie',
|
||||
'couleur' => '#FFD700',
|
||||
'niveau' => 1,
|
||||
'est_actif' => true,
|
||||
]);
|
||||
|
||||
$role = Role::where('nom', 'membre')->first();
|
||||
$this->utilisateur = Utilisateur::create([
|
||||
'nom' => 'Test',
|
||||
'prenom' => 'User',
|
||||
'email' => 'tracker@test.fr',
|
||||
'password' => 'Password123!',
|
||||
'role_id' => $role->id,
|
||||
'consentement_rgpd' => true,
|
||||
]);
|
||||
Tracker::create(['utilisateur_id' => $this->utilisateur->id, 'nom' => 'Mon journal']);
|
||||
|
||||
$this->token = JWTAuth::fromUser($this->utilisateur);
|
||||
}
|
||||
|
||||
private function authHeaders(): array
|
||||
{
|
||||
return ['Authorization' => 'Bearer '.$this->token];
|
||||
}
|
||||
|
||||
public function test_utilisateur_peut_creer_une_saisie(): void
|
||||
{
|
||||
$response = $this->withHeaders($this->authHeaders())
|
||||
->postJson('/api/v1/tracker/saisies', [
|
||||
'emotion_id' => $this->emotion->id,
|
||||
'intensite' => 7,
|
||||
'note' => 'Bonne journée',
|
||||
'date_saisie' => '2026-05-15',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('saisie.intensite', 7);
|
||||
|
||||
$this->assertDatabaseHas('saisie_trackers', ['intensite' => 7]);
|
||||
}
|
||||
|
||||
public function test_intensite_doit_etre_entre_1_et_10(): void
|
||||
{
|
||||
$response = $this->withHeaders($this->authHeaders())
|
||||
->postJson('/api/v1/tracker/saisies', [
|
||||
'emotion_id' => $this->emotion->id,
|
||||
'intensite' => 15,
|
||||
'date_saisie' => '2026-05-15',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['intensite']);
|
||||
}
|
||||
|
||||
public function test_utilisateur_peut_lister_ses_saisies(): void
|
||||
{
|
||||
$this->utilisateur->tracker->saisies()->create([
|
||||
'emotion_id' => $this->emotion->id,
|
||||
'intensite' => 5,
|
||||
'date_saisie' => '2026-05-15',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders())
|
||||
->getJson('/api/v1/tracker/saisies');
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_utilisateur_peut_supprimer_sa_saisie(): void
|
||||
{
|
||||
$saisie = $this->utilisateur->tracker->saisies()->create([
|
||||
'emotion_id' => $this->emotion->id,
|
||||
'intensite' => 5,
|
||||
'date_saisie' => '2026-05-15',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->authHeaders())
|
||||
->deleteJson('/api/v1/tracker/saisies/'.$saisie->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('saisie_trackers', ['id' => $saisie->id]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Filter } from 'lucide-react';
|
||||
import { Plus, Filter, Download } from 'lucide-react';
|
||||
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||
import { useEmotions } from '@/hooks/useEmotions';
|
||||
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
||||
@@ -10,9 +10,33 @@ import EditSaisieModal from '@/components/features/EditSaisieModal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SaisieTracker } from '@/types';
|
||||
|
||||
function exportSaisiesCsv(saisies: SaisieTracker[]): void {
|
||||
const escape = (v: unknown): string => {
|
||||
const s = v === null || v === undefined ? '' : String(v);
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
};
|
||||
const headers = ['Date', 'Emotion', 'Emotion parente', 'Intensite', 'Note'];
|
||||
const rows = saisies.map((s) => [
|
||||
s.date_saisie,
|
||||
s.emotion?.nom ?? '',
|
||||
s.emotion?.parent?.nom ?? '',
|
||||
s.intensite,
|
||||
s.note ?? '',
|
||||
]);
|
||||
const csv = [headers, ...rows].map((r) => r.map(escape).join(',')).join('\n');
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cesizen-saisies-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function JournalPage() {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<{
|
||||
@@ -21,21 +45,31 @@ export default function JournalPage() {
|
||||
emotion_id?: number;
|
||||
}>({});
|
||||
const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const { data: saisies, isLoading } = useSaisies(filters);
|
||||
const { data: emotions } = useEmotions();
|
||||
const deleteSaisie = useDeleteSaisie();
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Supprimer cette saisie ?')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await deleteSaisie.mutateAsync(id);
|
||||
await deleteSaisie.mutateAsync(deleteId);
|
||||
toast.success('Saisie supprimée');
|
||||
} catch {
|
||||
toast.error('Erreur lors de la suppression');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!saisies || saisies.length === 0) {
|
||||
toast.error('Aucune saisie à exporter');
|
||||
return;
|
||||
}
|
||||
exportSaisiesCsv(saisies);
|
||||
toast.success('Export CSV téléchargé');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
@@ -56,6 +90,10 @@ export default function JournalPage() {
|
||||
<Filter size={16} />
|
||||
Filtrer
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)">
|
||||
<Download size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Link href="/journal/nouvelle-saisie">
|
||||
<Button size="sm">
|
||||
<Plus size={16} />
|
||||
@@ -138,7 +176,7 @@ export default function JournalPage() {
|
||||
<TrackerTimeline
|
||||
saisies={saisies || []}
|
||||
onEdit={(saisie) => setEditingSaisie(saisie)}
|
||||
onDelete={handleDelete}
|
||||
onDelete={(id) => setDeleteId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -148,6 +186,16 @@ export default function JournalPage() {
|
||||
isOpen={!!editingSaisie}
|
||||
onClose={() => setEditingSaisie(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteId !== null}
|
||||
title="Supprimer la saisie"
|
||||
message="Cette saisie sera définitivement supprimée de votre journal. Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
destructive
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { SaisieTracker } from '@/types';
|
||||
import { useUpdateSaisie } from '@/hooks/useTracker';
|
||||
import EmotionPicker from '@/components/features/EmotionPicker';
|
||||
@@ -22,14 +22,16 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
|
||||
const [note, setNote] = useState('');
|
||||
const [dateSaisie, setDateSaisie] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (saisie) {
|
||||
setEmotionId(saisie.emotion_id);
|
||||
setIntensite(saisie.intensite);
|
||||
setNote(saisie.note || '');
|
||||
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
||||
}
|
||||
}, [saisie]);
|
||||
// Synchronise le formulaire quand une nouvelle saisie est passée : ajustement
|
||||
// d'état pendant le rendu (pattern recommandé par React, sans effet).
|
||||
const [prevSaisieId, setPrevSaisieId] = useState<number | null>(null);
|
||||
if (saisie && saisie.id !== prevSaisieId) {
|
||||
setPrevSaisieId(saisie.id);
|
||||
setEmotionId(saisie.emotion_id);
|
||||
setIntensite(saisie.intensite);
|
||||
setNote(saisie.note || '');
|
||||
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!saisie || !emotionId) return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Line,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import type { ValueType, NameType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
import type { RapportData } from '@/types';
|
||||
|
||||
interface EmotionChartProps {
|
||||
@@ -48,14 +49,18 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
|
||||
outerRadius={120}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
label={({ name, pourcentage }: any) => `${name} (${pourcentage}%)`}
|
||||
label={({ name, pourcentage }: { name?: string; pourcentage?: number }) =>
|
||||
`${name} (${pourcentage}%)`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: any, name: any) => [`${value} saisies`, name]}
|
||||
formatter={(value: ValueType | undefined, name: NameType | undefined) =>
|
||||
[`${value} saisies`, name]
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
border: 'none',
|
||||
@@ -139,7 +144,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
formatter={(value: any) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']}
|
||||
formatter={(value: ValueType | undefined) => [`${Number(value).toFixed(1)}`, 'Intensité moyenne']}
|
||||
/>
|
||||
<Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
title = "Confirmation",
|
||||
message,
|
||||
confirmLabel = "Confirmer",
|
||||
cancelLabel = "Annuler",
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<div
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
destructive
|
||||
? "bg-red-50 dark:bg-red-950/40"
|
||||
: "bg-malachite-50 dark:bg-malachite-950/40"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className={
|
||||
destructive
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-malachite-600 dark:text-malachite-400"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed pt-1">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "danger" : "primary"}
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface Emotion {
|
||||
icone: string | null;
|
||||
niveau: number;
|
||||
parent_id: number | null;
|
||||
parent?: Emotion | null;
|
||||
est_actif: boolean;
|
||||
enfants?: Emotion[];
|
||||
}
|
||||
|
||||
@@ -38,9 +38,17 @@ export default function TabLayout() {
|
||||
name="info"
|
||||
options={{
|
||||
title: 'Infos',
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="information-circle" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="urgence"
|
||||
options={{
|
||||
title: 'Urgence',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { useLocalSearchParams, Stack } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFeed } from '@/hooks/useFeeds';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
export default function FeedDetailScreen() {
|
||||
const { slug } = useLocalSearchParams<{ slug: string }>();
|
||||
const { data: feed, isLoading, isError } = useFeed(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={Colors.secondary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !feed) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color={Colors.gray[400]} />
|
||||
<Text style={styles.errorText}>Article introuvable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const texteNettoye = (feed.contenu ?? '').replace(/<[^>]*>/g, '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: feed.titre ?? '' }} />
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name="information-circle" size={14} color={Colors.secondary} />
|
||||
<Text style={styles.badgeText}>Information santé mentale</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{feed.titre}</Text>
|
||||
|
||||
<Text style={styles.date}>
|
||||
Publié le {new Date(feed.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.body}>{texteNettoye}</Text>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.white },
|
||||
content: { padding: 20, paddingBottom: 40 },
|
||||
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.white },
|
||||
badge: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', backgroundColor: `${Colors.secondary}15`, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginBottom: 12 },
|
||||
badgeText: { fontSize: 12, fontWeight: '600', color: Colors.secondary },
|
||||
title: { fontSize: 24, fontWeight: '800', color: Colors.black, marginBottom: 8, lineHeight: 30 },
|
||||
date: { fontSize: 13, color: Colors.gray[400], marginBottom: 20 },
|
||||
body: { fontSize: 15, lineHeight: 24, color: Colors.gray[700] ?? '#374151' },
|
||||
errorText: { marginTop: 12, fontSize: 16, color: Colors.gray[400] },
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
export default function InfoLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: Colors.white },
|
||||
headerTitleStyle: { fontWeight: '700', color: Colors.black },
|
||||
headerTintColor: Colors.secondary,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: 'Infos' }} />
|
||||
<Stack.Screen name="[slug]" options={{ title: '', headerBackTitle: 'Retour' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useFeeds } from '@/hooks/useFeeds';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
export default function InfoScreen() {
|
||||
export default function InfoListScreen() {
|
||||
const { data: feeds, isLoading } = useFeeds();
|
||||
const router = useRouter();
|
||||
|
||||
const renderItem = ({ item }: { item: any }) => (
|
||||
<TouchableOpacity style={styles.card}>
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => router.push(`/(tabs)/info/${item.slug}`)}
|
||||
>
|
||||
<View style={styles.cardIcon}>
|
||||
<Ionicons name="document-text" size={24} color={Colors.secondary} />
|
||||
</View>
|
||||
@@ -33,8 +38,8 @@ export default function InfoScreen() {
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Sante mentale</Text>
|
||||
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-etre</Text>
|
||||
<Text style={styles.headerTitle}>Santé mentale</Text>
|
||||
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
@@ -0,0 +1,125 @@
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
type Contact = {
|
||||
nom: string;
|
||||
numero: string;
|
||||
description: string;
|
||||
disponibilite: string;
|
||||
};
|
||||
|
||||
const CONTACTS: Contact[] = [
|
||||
{
|
||||
nom: 'Prévention du suicide',
|
||||
numero: '3114',
|
||||
description: 'Numéro national gratuit et confidentiel pour les personnes en détresse psychologique et leur entourage.',
|
||||
disponibilite: '24h/24 — 7j/7',
|
||||
},
|
||||
{
|
||||
nom: 'SOS Amitié',
|
||||
numero: '0972394050',
|
||||
description: 'Écoute et soutien pour les personnes en souffrance, solitude ou détresse morale.',
|
||||
disponibilite: '24h/24',
|
||||
},
|
||||
{
|
||||
nom: 'Fil Santé Jeunes',
|
||||
numero: '0800235236',
|
||||
description: 'Ligne d\'écoute gratuite et anonyme pour les 12-25 ans.',
|
||||
disponibilite: 'Lun-Dim, 9h-23h',
|
||||
},
|
||||
{
|
||||
nom: 'SAMU',
|
||||
numero: '15',
|
||||
description: 'Service d\'aide médicale urgente. En cas d\'urgence vitale.',
|
||||
disponibilite: '24h/24 — 7j/7',
|
||||
},
|
||||
{
|
||||
nom: 'SOS Médecins',
|
||||
numero: '3624',
|
||||
description: 'Consultations médicales à domicile.',
|
||||
disponibilite: '24h/24 — 7j/7',
|
||||
},
|
||||
{
|
||||
nom: 'Numéro européen d\'urgence',
|
||||
numero: '112',
|
||||
description: 'Police, pompiers, SAMU. À utiliser en cas d\'urgence vitale.',
|
||||
disponibilite: '24h/24 — 7j/7',
|
||||
},
|
||||
];
|
||||
|
||||
function formatNumero(n: string): string {
|
||||
if (n.length <= 4) return n;
|
||||
return n.replace(/(\d{2})(?=\d)/g, '$1 ').trim();
|
||||
}
|
||||
|
||||
export default function UrgenceScreen() {
|
||||
const appeler = async (numero: string, nom: string) => {
|
||||
const url = `tel:${numero}`;
|
||||
const supported = await Linking.canOpenURL(url);
|
||||
if (supported) {
|
||||
Linking.openURL(url);
|
||||
} else {
|
||||
Alert.alert('Impossible d\'appeler', `Composez manuellement le ${formatNumero(numero)} pour joindre ${nom}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconBadge}>
|
||||
<Ionicons name="heart" size={20} color="#DC2626" />
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>Vous n'êtes pas seul(e)</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{CONTACTS.map((c) => (
|
||||
<TouchableOpacity key={c.numero} style={styles.card} onPress={() => appeler(c.numero, c.nom)}>
|
||||
<View style={styles.cardIcon}>
|
||||
<Ionicons name="call" size={22} color="#DC2626" />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardName}>{c.nom}</Text>
|
||||
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text>
|
||||
<Text style={styles.cardDescription}>{c.description}</Text>
|
||||
<Text style={styles.cardAvailability}>{c.disponibilite}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<View style={styles.warning}>
|
||||
<Ionicons name="warning" size={18} color="#DC2626" />
|
||||
<Text style={styles.warningText}>
|
||||
En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le <Text style={styles.bold}>112</Text>.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.white },
|
||||
content: { padding: 16, paddingBottom: 32 },
|
||||
header: { marginBottom: 20, alignItems: 'flex-start' },
|
||||
iconBadge: { width: 40, height: 40, borderRadius: 999, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
|
||||
headerTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 6 },
|
||||
headerSubtitle: { fontSize: 14, color: Colors.gray[500], lineHeight: 20 },
|
||||
card: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16,
|
||||
backgroundColor: Colors.gray[50], borderRadius: 16, marginBottom: 10,
|
||||
borderWidth: 1, borderColor: Colors.gray[100],
|
||||
},
|
||||
cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#FEE2E2', justifyContent: 'center', alignItems: 'center' },
|
||||
cardContent: { flex: 1 },
|
||||
cardName: { fontSize: 14, fontWeight: '700', color: Colors.black, marginBottom: 2 },
|
||||
cardNumber: { fontSize: 20, fontWeight: '800', color: '#DC2626', marginBottom: 6 },
|
||||
cardDescription: { fontSize: 12, color: Colors.gray[500], lineHeight: 17 },
|
||||
cardAvailability: { fontSize: 11, color: Colors.secondary, marginTop: 4, fontWeight: '600' },
|
||||
warning: { marginTop: 8, padding: 14, borderRadius: 12, backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FECACA', flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
|
||||
warningText: { flex: 1, fontSize: 13, color: '#B91C1C', lineHeight: 19 },
|
||||
bold: { fontWeight: '800' },
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
if (!email.trim()) {
|
||||
setError('Veuillez renseigner votre adresse email.');
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
setError('Format d\'email invalide.');
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.success}>
|
||||
<View style={styles.successIcon}>
|
||||
<Ionicons name="mail-open" size={32} color={Colors.secondary} />
|
||||
</View>
|
||||
<Text style={styles.successTitle}>Demande enregistrée</Text>
|
||||
<Text style={styles.successText}>
|
||||
Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen, un email contenant les instructions de réinitialisation sera envoyé.
|
||||
</Text>
|
||||
<Text style={styles.successText}>
|
||||
Si vous ne recevez rien sous 24h, contactez votre administrateur.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={() => router.replace('/auth/login')}>
|
||||
<Text style={styles.buttonText}>Retour à la connexion</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>Mot de passe oublié ?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les instructions pour réinitialiser votre mot de passe.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={(v) => { setEmail(v); setError(null); }}
|
||||
placeholder="votre@email.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
|
||||
<Text style={styles.buttonText}>Envoyer les instructions</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Link href="/auth/login" asChild>
|
||||
<TouchableOpacity>
|
||||
<Text style={styles.link}>Retour à la connexion</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.white },
|
||||
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
||||
header: { alignItems: 'center', marginBottom: 32 },
|
||||
logo: { fontSize: 26, fontWeight: '800', color: Colors.black, textAlign: 'center' },
|
||||
subtitle: { fontSize: 14, color: Colors.gray[500], marginTop: 10, textAlign: 'center', lineHeight: 20 },
|
||||
form: { backgroundColor: Colors.gray[50], borderRadius: 24, padding: 24 },
|
||||
inputGroup: { marginBottom: 16 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
||||
input: { backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
|
||||
errorText: { color: '#DC2626', marginTop: 6, fontSize: 12 },
|
||||
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
|
||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||
footer: { alignItems: 'center', marginTop: 20 },
|
||||
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
||||
success: { padding: 24, alignItems: 'center', flex: 1, justifyContent: 'center' },
|
||||
successIcon: { width: 72, height: 72, borderRadius: 999, backgroundColor: `${Colors.secondary}15`, justifyContent: 'center', alignItems: 'center', marginBottom: 20 },
|
||||
successTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 12 },
|
||||
successText: { fontSize: 14, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: 12 },
|
||||
bold: { fontWeight: '700', color: Colors.black },
|
||||
});
|
||||
@@ -70,6 +70,12 @@ export default function LoginScreen() {
|
||||
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Link href="/auth/forgot-password" asChild>
|
||||
<TouchableOpacity style={styles.forgotLink}>
|
||||
<Text style={styles.link}>Mot de passe oublié ?</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Pas encore de compte ? </Text>
|
||||
<Link href="/auth/register" asChild>
|
||||
@@ -113,7 +119,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
||||
forgotLink: { alignItems: 'center', marginTop: 12 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 16 },
|
||||
footerText: { color: Colors.gray[500], fontSize: 14 },
|
||||
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const router = useRouter();
|
||||
const { register } = useAuthStore();
|
||||
@@ -12,23 +15,43 @@ export default function RegisterScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
||||
const [consentement, setConsentement] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!nom.trim()) e.nom = 'Le nom est obligatoire';
|
||||
if (!prenom.trim()) e.prenom = 'Le prénom est obligatoire';
|
||||
if (!email.trim()) e.email = 'L\'email est obligatoire';
|
||||
else if (!EMAIL_REGEX.test(email)) e.email = 'Format d\'email invalide';
|
||||
if (!password) e.password = 'Le mot de passe est obligatoire';
|
||||
else if (password.length < 8) e.password = 'Le mot de passe doit faire au moins 8 caractères';
|
||||
if (password !== passwordConfirmation) e.passwordConfirmation = 'Les mots de passe ne correspondent pas';
|
||||
if (!consentement) e.consentement = 'Vous devez accepter le traitement de vos données (RGPD)';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!nom || !prenom || !email || !password || !passwordConfirmation) {
|
||||
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirmation) {
|
||||
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await register({ nom, prenom, email, password, password_confirmation: passwordConfirmation });
|
||||
await register({
|
||||
nom, prenom, email, password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
consentement_rgpd: consentement,
|
||||
});
|
||||
router.replace('/(tabs)');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erreur', error.response?.data?.message || 'Erreur lors de l\'inscription');
|
||||
const msg = error.response?.data?.message || 'Erreur lors de l\'inscription';
|
||||
const apiErrors = error.response?.data?.errors;
|
||||
if (apiErrors) {
|
||||
const mapped: Record<string, string> = {};
|
||||
Object.entries(apiErrors).forEach(([k, v]) => { mapped[k] = Array.isArray(v) ? v[0] as string : String(v); });
|
||||
setErrors(mapped);
|
||||
}
|
||||
Alert.alert('Erreur', msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,7 +62,7 @@ export default function RegisterScreen() {
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>CESIZen</Text>
|
||||
<Text style={styles.subtitle}>Creez votre compte</Text>
|
||||
<Text style={styles.subtitle}>Créez votre compte</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
@@ -49,34 +72,50 @@ export default function RegisterScreen() {
|
||||
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
|
||||
<Text style={styles.label}>Nom</Text>
|
||||
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
|
||||
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>}
|
||||
</View>
|
||||
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}>
|
||||
<Text style={styles.label}>Prenom</Text>
|
||||
<Text style={styles.label}>Prénom</Text>
|
||||
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" />
|
||||
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" />
|
||||
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} />
|
||||
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Mot de passe</Text>
|
||||
<Text style={styles.label}>Mot de passe (min. 8 caractères)</Text>
|
||||
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry />
|
||||
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Confirmer le mot de passe</Text>
|
||||
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
|
||||
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.consentRow} onPress={() => setConsentement(!consentement)} activeOpacity={0.7}>
|
||||
<View style={[styles.checkbox, consentement && styles.checkboxChecked]}>
|
||||
{consentement && <Ionicons name="checkmark" size={16} color={Colors.white} />}
|
||||
</View>
|
||||
<Text style={styles.consentText}>
|
||||
J'accepte le traitement de mes données de bien-être conformément à la <Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD).
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{errors.consentement && <Text style={styles.errorText}>{errors.consentement}</Text>}
|
||||
{errors.consentement_rgpd && <Text style={styles.errorText}>{errors.consentement_rgpd}</Text>}
|
||||
|
||||
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}>
|
||||
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Deja un compte ? </Text>
|
||||
<Text style={styles.footerText}>Déjà un compte ? </Text>
|
||||
<Link href="/auth/login" asChild>
|
||||
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
|
||||
</Link>
|
||||
@@ -98,11 +137,14 @@ const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row' },
|
||||
inputGroup: { marginBottom: 16 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
||||
input: {
|
||||
backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300],
|
||||
borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black,
|
||||
},
|
||||
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
|
||||
input: { backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
|
||||
errorText: { color: '#DC2626', marginTop: 4, fontSize: 12 },
|
||||
consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, marginVertical: 12 },
|
||||
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, borderColor: Colors.gray[300], backgroundColor: Colors.white, justifyContent: 'center', alignItems: 'center', marginTop: 1 },
|
||||
checkboxChecked: { backgroundColor: Colors.secondary, borderColor: Colors.secondary },
|
||||
consentText: { flex: 1, fontSize: 13, color: Colors.gray[700], lineHeight: 18 },
|
||||
consentLink: { color: Colors.secondary, fontWeight: '600' },
|
||||
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 12 },
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
||||
|
||||
+18
-1
@@ -24,11 +24,28 @@ api.interceptors.request.use(async (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Évite d'invalider la session sur les endpoints publics d'auth (login/register/forgot)
|
||||
function isAuthPublicEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return /\/auth\/(login|register|forgot)/.test(url);
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const status = error.response?.status;
|
||||
const url: string | undefined = error.config?.url;
|
||||
|
||||
if (status === 401 && !isAuthPublicEndpoint(url)) {
|
||||
await SecureStore.deleteItemAsync('cesizen_token');
|
||||
// Le AuthGuard de _layout détecte l'absence de token au prochain loadToken
|
||||
// et redirige vers /auth/login.
|
||||
try {
|
||||
const { useAuthStore } = require('./auth-store');
|
||||
useAuthStore.setState({ user: null, token: null, isAuthenticated: false });
|
||||
} catch {
|
||||
// store pas encore monté
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface AuthState {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
consentement_rgpd: boolean;
|
||||
}) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
fetchUser: () => Promise<void>;
|
||||
|
||||
Generated
+8981
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -9,24 +9,24 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~53.0.0",
|
||||
"expo-router": "~5.0.0",
|
||||
"expo-status-bar": "~2.2.0",
|
||||
"expo-secure-store": "~14.2.0",
|
||||
"expo-font": "~13.3.0",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.10.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.0",
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"zustand": "^5.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"react-native-svg": "~16.0.0",
|
||||
"victory-native": "^41.0.0"
|
||||
"expo": "~53.0.0",
|
||||
"expo-constants": "~17.1.8",
|
||||
"expo-font": "~13.3.0",
|
||||
"expo-linking": "~7.1.7",
|
||||
"expo-router": "~5.1.11",
|
||||
"expo-secure-store": "~14.2.0",
|
||||
"expo-status-bar": "~2.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.6",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
|
||||
Reference in New Issue
Block a user