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\UpdatePasswordRequest;
|
||||||
use App\Http\Requests\UpdateProfilRequest;
|
use App\Http\Requests\UpdateProfilRequest;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ProfilController extends Controller
|
class ProfilController extends Controller
|
||||||
{
|
{
|
||||||
@@ -56,4 +58,68 @@ class ProfilController extends Controller
|
|||||||
'message' => 'Compte supprimé avec succès',
|
'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([]);
|
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')) {
|
if ($request->filled('date_debut')) {
|
||||||
$query->where('date_saisie', '>=', $request->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::put('profil/password', [ProfilController::class, 'updatePassword']);
|
||||||
Route::delete('profil', [ProfilController::class, 'destroy']);
|
Route::delete('profil', [ProfilController::class, 'destroy']);
|
||||||
|
|
||||||
|
// RGPD
|
||||||
|
Route::get('profil/export', [ProfilController::class, 'export']);
|
||||||
|
Route::post('profil/anonymiser', [ProfilController::class, 'anonymiser']);
|
||||||
|
|
||||||
// Émotions
|
// Émotions
|
||||||
Route::get('emotions', [EmotionController::class, 'index']);
|
Route::get('emotions', [EmotionController::class, 'index']);
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Admin ──────────────────────────────────────────────
|
// ── 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
|
// Utilisateurs
|
||||||
Route::get('utilisateurs', [AdminUtilisateurController::class, 'index']);
|
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 { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||||
import { useEmotions } from '@/hooks/useEmotions';
|
import { useEmotions } from '@/hooks/useEmotions';
|
||||||
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
import TrackerTimeline from '@/components/features/TrackerTimeline';
|
||||||
@@ -10,9 +10,33 @@ import EditSaisieModal from '@/components/features/EditSaisieModal';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Skeleton from '@/components/ui/Skeleton';
|
import Skeleton from '@/components/ui/Skeleton';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type { SaisieTracker } from '@/types';
|
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() {
|
export default function JournalPage() {
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
@@ -21,21 +45,31 @@ export default function JournalPage() {
|
|||||||
emotion_id?: number;
|
emotion_id?: number;
|
||||||
}>({});
|
}>({});
|
||||||
const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null);
|
const [editingSaisie, setEditingSaisie] = useState<SaisieTracker | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: saisies, isLoading } = useSaisies(filters);
|
const { data: saisies, isLoading } = useSaisies(filters);
|
||||||
const { data: emotions } = useEmotions();
|
const { data: emotions } = useEmotions();
|
||||||
const deleteSaisie = useDeleteSaisie();
|
const deleteSaisie = useDeleteSaisie();
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const confirmDelete = async () => {
|
||||||
if (!confirm('Supprimer cette saisie ?')) return;
|
if (!deleteId) return;
|
||||||
try {
|
try {
|
||||||
await deleteSaisie.mutateAsync(id);
|
await deleteSaisie.mutateAsync(deleteId);
|
||||||
toast.success('Saisie supprimée');
|
toast.success('Saisie supprimée');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Erreur lors de la suppression');
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
<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} />
|
<Filter size={16} />
|
||||||
Filtrer
|
Filtrer
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleExport} title="Exporter mes saisies (RGPD)">
|
||||||
|
<Download size={16} />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
<Link href="/journal/nouvelle-saisie">
|
<Link href="/journal/nouvelle-saisie">
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
@@ -138,7 +176,7 @@ export default function JournalPage() {
|
|||||||
<TrackerTimeline
|
<TrackerTimeline
|
||||||
saisies={saisies || []}
|
saisies={saisies || []}
|
||||||
onEdit={(saisie) => setEditingSaisie(saisie)}
|
onEdit={(saisie) => setEditingSaisie(saisie)}
|
||||||
onDelete={handleDelete}
|
onDelete={(id) => setDeleteId(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -148,6 +186,16 @@ export default function JournalPage() {
|
|||||||
isOpen={!!editingSaisie}
|
isOpen={!!editingSaisie}
|
||||||
onClose={() => setEditingSaisie(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { SaisieTracker } from '@/types';
|
import type { SaisieTracker } from '@/types';
|
||||||
import { useUpdateSaisie } from '@/hooks/useTracker';
|
import { useUpdateSaisie } from '@/hooks/useTracker';
|
||||||
import EmotionPicker from '@/components/features/EmotionPicker';
|
import EmotionPicker from '@/components/features/EmotionPicker';
|
||||||
@@ -22,14 +22,16 @@ export default function EditSaisieModal({ saisie, isOpen, onClose }: EditSaisieM
|
|||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [dateSaisie, setDateSaisie] = useState('');
|
const [dateSaisie, setDateSaisie] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
// Synchronise le formulaire quand une nouvelle saisie est passée : ajustement
|
||||||
if (saisie) {
|
// 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);
|
setEmotionId(saisie.emotion_id);
|
||||||
setIntensite(saisie.intensite);
|
setIntensite(saisie.intensite);
|
||||||
setNote(saisie.note || '');
|
setNote(saisie.note || '');
|
||||||
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
setDateSaisie(saisie.date_saisie.split('T')[0]);
|
||||||
}
|
}
|
||||||
}, [saisie]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!saisie || !emotionId) return;
|
if (!saisie || !emotionId) return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Line,
|
Line,
|
||||||
Legend,
|
Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import type { ValueType, NameType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
import type { RapportData } from '@/types';
|
import type { RapportData } from '@/types';
|
||||||
|
|
||||||
interface EmotionChartProps {
|
interface EmotionChartProps {
|
||||||
@@ -48,14 +49,18 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
|
|||||||
outerRadius={120}
|
outerRadius={120}
|
||||||
paddingAngle={3}
|
paddingAngle={3}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
label={({ name, pourcentage }: any) => `${name} (${pourcentage}%)`}
|
label={({ name, pourcentage }: { name?: string; pourcentage?: number }) =>
|
||||||
|
`${name} (${pourcentage}%)`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{pieData.map((entry, index) => (
|
{pieData.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any, name: any) => [`${value} saisies`, name]}
|
formatter={(value: ValueType | undefined, name: NameType | undefined) =>
|
||||||
|
[`${value} saisies`, name]
|
||||||
|
}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -139,7 +144,7 @@ export default function EmotionChart({ data, type = 'repartition' }: EmotionChar
|
|||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
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]} />
|
<Bar dataKey="intensite" fill="#fce117" radius={[6, 6, 0, 0]} />
|
||||||
</BarChart>
|
</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;
|
icone: string | null;
|
||||||
niveau: number;
|
niveau: number;
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
|
parent?: Emotion | null;
|
||||||
est_actif: boolean;
|
est_actif: boolean;
|
||||||
enfants?: Emotion[];
|
enfants?: Emotion[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,17 @@ export default function TabLayout() {
|
|||||||
name="info"
|
name="info"
|
||||||
options={{
|
options={{
|
||||||
title: 'Infos',
|
title: 'Infos',
|
||||||
|
headerShown: false,
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="information-circle" size={size} color={color} />,
|
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
|
<Tabs.Screen
|
||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
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 { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { useFeeds } from '@/hooks/useFeeds';
|
import { useFeeds } from '@/hooks/useFeeds';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
export default function InfoScreen() {
|
export default function InfoListScreen() {
|
||||||
const { data: feeds, isLoading } = useFeeds();
|
const { data: feeds, isLoading } = useFeeds();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: any }) => (
|
const renderItem = ({ item }: { item: any }) => (
|
||||||
<TouchableOpacity style={styles.card}>
|
<TouchableOpacity
|
||||||
|
style={styles.card}
|
||||||
|
onPress={() => router.push(`/(tabs)/info/${item.slug}`)}
|
||||||
|
>
|
||||||
<View style={styles.cardIcon}>
|
<View style={styles.cardIcon}>
|
||||||
<Ionicons name="document-text" size={24} color={Colors.secondary} />
|
<Ionicons name="document-text" size={24} color={Colors.secondary} />
|
||||||
</View>
|
</View>
|
||||||
@@ -33,8 +38,8 @@ export default function InfoScreen() {
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>Sante mentale</Text>
|
<Text style={styles.headerTitle}>Santé mentale</Text>
|
||||||
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-etre</Text>
|
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
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>
|
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
|
||||||
</TouchableOpacity>
|
</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}>
|
<View style={styles.footer}>
|
||||||
<Text style={styles.footerText}>Pas encore de compte ? </Text>
|
<Text style={styles.footerText}>Pas encore de compte ? </Text>
|
||||||
<Link href="/auth/register" asChild>
|
<Link href="/auth/register" asChild>
|
||||||
@@ -113,7 +119,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
buttonDisabled: { opacity: 0.6 },
|
buttonDisabled: { opacity: 0.6 },
|
||||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
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 },
|
footerText: { color: Colors.gray[500], fontSize: 14 },
|
||||||
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
link: { color: Colors.secondary, fontSize: 14, fontWeight: '600' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
import { Link, useRouter } from 'expo-router';
|
import { Link, useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { register } = useAuthStore();
|
const { register } = useAuthStore();
|
||||||
@@ -12,23 +15,43 @@ export default function RegisterScreen() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
||||||
|
const [consentement, setConsentement] = useState(false);
|
||||||
const [loading, setLoading] = 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 () => {
|
const handleRegister = async () => {
|
||||||
if (!nom || !prenom || !email || !password || !passwordConfirmation) {
|
if (!validate()) return;
|
||||||
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password !== passwordConfirmation) {
|
|
||||||
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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)');
|
router.replace('/(tabs)');
|
||||||
} catch (error: any) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +62,7 @@ export default function RegisterScreen() {
|
|||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.logo}>CESIZen</Text>
|
<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>
|
||||||
|
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
@@ -49,34 +72,50 @@ export default function RegisterScreen() {
|
|||||||
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
|
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
|
||||||
<Text style={styles.label}>Nom</Text>
|
<Text style={styles.label}>Nom</Text>
|
||||||
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
|
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
|
||||||
|
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}>
|
<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" />
|
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" />
|
||||||
|
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text style={styles.label}>Email</Text>
|
<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>
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<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 />
|
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry />
|
||||||
|
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<Text style={styles.label}>Confirmer le mot de passe</Text>
|
<Text style={styles.label}>Confirmer le mot de passe</Text>
|
||||||
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
|
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
|
||||||
|
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>}
|
||||||
</View>
|
</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}>
|
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}>
|
||||||
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
|
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<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>
|
<Link href="/auth/login" asChild>
|
||||||
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
|
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -98,11 +137,14 @@ const styles = StyleSheet.create({
|
|||||||
row: { flexDirection: 'row' },
|
row: { flexDirection: 'row' },
|
||||||
inputGroup: { marginBottom: 16 },
|
inputGroup: { marginBottom: 16 },
|
||||||
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
label: { fontSize: 14, fontWeight: '600', color: Colors.gray[700], marginBottom: 6 },
|
||||||
input: {
|
input: { backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300], borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
|
||||||
backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.gray[300],
|
errorText: { color: '#DC2626', marginTop: 4, fontSize: 12 },
|
||||||
borderRadius: 12, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black,
|
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 },
|
||||||
button: { backgroundColor: Colors.primary, borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
|
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 },
|
buttonDisabled: { opacity: 0.6 },
|
||||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.black },
|
||||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
||||||
|
|||||||
+18
-1
@@ -24,11 +24,28 @@ api.interceptors.request.use(async (config) => {
|
|||||||
return 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(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
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');
|
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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface AuthState {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
password_confirmation: string;
|
password_confirmation: string;
|
||||||
|
consentement_rgpd: boolean;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
fetchUser: () => 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"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@expo/vector-icons": "^14.0.0",
|
||||||
"zustand": "^5.0.0",
|
"@react-navigation/bottom-tabs": "^7.3.0",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"react-native-svg": "~16.0.0",
|
"expo": "~53.0.0",
|
||||||
"victory-native": "^41.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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.0",
|
"@babel/core": "^7.25.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user