From de60a001cce9774bf473372aa7b7d4de0d66d32e Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 28 Mar 2026 17:56:55 +0100 Subject: [PATCH] =?UTF-8?q?feat(front):=20inscription=20parent=20=E2=80=94?= =?UTF-8?q?=20soumission=20r=C3=A9elle=20vers=20POST=20/auth/register/pare?= =?UTF-8?q?nt=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ParentRegistrationPayload : validation client et mapping UserRegistrationData → JSON API - AuthService.registerParent - Étape 5 : appel API, chargement, erreurs en dialogue, succès inchangé puis /login Made-with: Cursor --- .../auth/parent_register_step5_screen.dart | 70 ++++-- frontend/lib/services/auth_service.dart | 27 +++ .../utils/parent_registration_payload.dart | 214 ++++++++++++++++++ 3 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 frontend/lib/utils/parent_registration_payload.dart diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 71047e1..62dc5d3 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -13,6 +13,7 @@ import '../../widgets/custom_navigation_button.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; +import '../../services/auth_service.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -22,6 +23,36 @@ class ParentRegisterStep5Screen extends StatefulWidget { } class _ParentRegisterStep5ScreenState extends State { + bool _isSubmitting = false; + + Future _submitRegistration(BuildContext context, UserRegistrationData data) async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + try { + await AuthService.registerParent(data); + if (!context.mounted) return; + _showSuccessModal(context); + } catch (e) { + if (!context.mounted) return; + final msg = e.toString().replaceAll('Exception: ', ''); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Envoi impossible', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + content: Text(msg, style: GoogleFonts.merienda(fontSize: 14)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + @override Widget build(BuildContext context) { final registrationData = Provider.of(context); @@ -102,12 +133,11 @@ class _ParentRegisterStep5ScreenState extends State { Expanded( child: HoverReliefWidget( child: CustomNavigationButton( - text: 'Soumettre', + text: _isSubmitting ? 'Envoi…' : 'Soumettre', style: NavigationButtonStyle.green, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, + onPressed: _isSubmitting + ? () {} + : () => _submitRegistration(context, registrationData), width: double.infinity, height: 50, fontSize: 16, @@ -118,18 +148,20 @@ class _ParentRegisterStep5ScreenState extends State { ), ) else - ImageButton( - bg: 'assets/images/bg_green.png', - text: 'Soumettre ma demande', - textColor: const Color(0xFF2D6A4F), - width: 350, - height: 50, - fontSize: 18, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, - ), + _isSubmitting + ? const Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ) + : ImageButton( + bg: 'assets/images/bg_green.png', + text: 'Soumettre ma demande', + textColor: const Color(0xFF2D6A4F), + width: 350, + height: 50, + fontSize: 18, + onPressed: () => _submitRegistration(context, registrationData), + ), ], ), ), @@ -239,14 +271,14 @@ class _ParentRegisterStep5ScreenState extends State { cardColor: CardColorHorizontal.green, // Changé de pink à green textFieldHint: '', initialText: data.motivationText, - initialCguAccepted: true, // Toujours true ici car déjà passé + initialCguAccepted: data.cguAccepted, previousRoute: '', onSubmit: (t, c) {}, onEdit: () => context.go('/parent-register-step4'), ); } - void _showConfirmationModal(BuildContext context) { + void _showSuccessModal(BuildContext context) { showDialog( context: context, barrierDismissible: false, diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index c995b9f..ab57adc 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; import '../utils/nir_utils.dart'; @@ -188,6 +190,31 @@ class AuthService { throw Exception(message); } + /// Inscription parent complète (POST /auth/register/parent). + /// Succès : 201, pas de session — rediriger vers le login. + static Future registerParent(UserRegistrationData data) async { + final validationError = ParentRegistrationPayload.validateForApi(data); + if (validationError != null) { + throw Exception(validationError); + } + + final body = ParentRegistrationPayload.toJson(data); + + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'), + headers: ApiConfig.headers, + body: jsonEncode(body), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + + final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null; + final message = _extractErrorMessage(decoded, response.statusCode); + throw Exception(message); + } + /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). static String _extractErrorMessage(dynamic decoded, int statusCode) { const fallback = 'Erreur lors de l\'inscription'; diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart new file mode 100644 index 0000000..7651e09 --- /dev/null +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; + +import '../models/user_registration_data.dart'; + +/// Construction du body `POST /auth/register/parent` à partir du state d'inscription. +/// Aligné sur [RegisterParentCompletDto] (backend). +class ParentRegistrationPayload { + ParentRegistrationPayload._(); + + static final RegExp _phoneFr = RegExp(r'^(\+33|0)[1-9](\d{2}){4}$'); + + /// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent. + static String? validateForApi(UserRegistrationData d) { + final p1 = d.parent1; + if (p1.email.trim().isEmpty) { + return 'L’email du parent principal est requis.'; + } + if (p1.firstName.trim().length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + if (p1.lastName.trim().length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + final tel = _normalizePhone(p1.phone); + if (!_phoneFr.hasMatch(tel)) { + return 'Le numéro de téléphone du parent principal n’est pas valide (ex. 0612345678).'; + } + if (!d.cguAccepted) { + return 'Vous devez accepter les CGU et la politique de confidentialité.'; + } + if (d.children.isEmpty) { + return 'Au moins un enfant est requis.'; + } + + final p2 = d.parent2; + if (p2 != null) { + final any = p2.email.trim().isNotEmpty || + p2.firstName.trim().isNotEmpty || + p2.lastName.trim().isNotEmpty || + p2.phone.trim().isNotEmpty; + if (any) { + if (p2.email.trim().isEmpty || + p2.firstName.trim().length < 2 || + p2.lastName.trim().length < 2) { + return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).'; + } + if (p2.email.trim().toLowerCase() == p1.email.trim().toLowerCase()) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + final tel2 = _normalizePhone(p2.phone); + if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) { + return 'Le numéro de téléphone du co-parent n’est pas valide.'; + } + } + } + + for (var i = 0; i < d.children.length; i++) { + final c = d.children[i]; + final label = 'Enfant ${i + 1}'; + if (c.isUnbornChild) { + final due = _ddMmYyyyToIso(c.dob); + if (due == null) { + return '$label : indiquez une date de naissance prévisionnelle.'; + } + } else { + if (c.firstName.trim().length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + final birth = _ddMmYyyyToIso(c.dob); + if (birth == null) { + return '$label : indiquez une date de naissance valide.'; + } + } + } + + return null; + } + + static Map toJson(UserRegistrationData d) { + final p1 = d.parent1; + final tel = _normalizePhone(p1.phone); + + final body = { + 'email': p1.email.trim(), + 'prenom': p1.firstName.trim(), + 'nom': p1.lastName.trim(), + 'telephone': tel, + 'acceptation_cgu': d.cguAccepted, + 'acceptation_privacy': d.cguAccepted, + }; + + _putIfNonEmpty(body, 'adresse', p1.address.trim()); + _putIfNonEmpty(body, 'code_postal', p1.postalCode.trim()); + _putIfNonEmpty(body, 'ville', p1.city.trim()); + + final p2 = d.parent2; + if (p2 != null && + p2.email.trim().isNotEmpty && + p2.firstName.trim().length >= 2 && + p2.lastName.trim().length >= 2) { + final tel2 = _normalizePhone(p2.phone); + final sameAddr = p2.address.trim() == p1.address.trim() && + p2.postalCode.trim() == p1.postalCode.trim() && + p2.city.trim() == p1.city.trim(); + + body['co_parent_email'] = p2.email.trim(); + body['co_parent_prenom'] = p2.firstName.trim(); + body['co_parent_nom'] = p2.lastName.trim(); + body['co_parent_meme_adresse'] = sameAddr; + if (tel2.isNotEmpty) { + body['co_parent_telephone'] = tel2; + } + if (!sameAddr) { + _putIfNonEmpty(body, 'co_parent_adresse', p2.address.trim()); + _putIfNonEmpty(body, 'co_parent_code_postal', p2.postalCode.trim()); + _putIfNonEmpty(body, 'co_parent_ville', p2.city.trim()); + } + } + + if (d.motivationText.trim().isNotEmpty) { + body['presentation_dossier'] = d.motivationText.trim(); + } + + body['enfants'] = d.children.asMap().entries.map((e) { + return _childToJson(e.value, e.key, d.parent1.lastName.trim()); + }).toList(); + + return body; + } + + static Map _childToJson(ChildData c, int index, String parentNom) { + final map = { + 'genre': 'F', + 'grossesse_multiple': c.multipleBirth, + }; + + final prenom = c.firstName.trim(); + if (prenom.length >= 2) { + map['prenom'] = prenom; + } + + final nom = c.lastName.trim(); + if (nom.length >= 2) { + map['nom'] = nom; + } else if (parentNom.length >= 2) { + map['nom'] = parentNom; + } + + if (c.isUnbornChild) { + final due = _ddMmYyyyToIso(c.dob); + if (due != null) { + map['date_previsionnelle_naissance'] = due; + } + } else { + final birth = _ddMmYyyyToIso(c.dob); + if (birth != null) { + map['date_naissance'] = birth; + } + } + + final photo = _childPhotoBase64(c, index, prenom); + if (photo != null) { + map['photo_base64'] = photo.$1; + map['photo_filename'] = photo.$2; + } + + return map; + } + + /// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible. + static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) { + final file = c.imageFile; + if (file == null) return null; + try { + if (!file.existsSync()) return null; + final bytes = file.readAsBytesSync(); + if (bytes.isEmpty) return null; + final b64 = base64Encode(bytes); + final safeName = prenom.isNotEmpty + ? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.jpg' + : 'enfant_${index + 1}.jpg'; + return ('data:image/jpeg;base64,$b64', safeName); + } catch (_) { + return null; + } + } + + static void _putIfNonEmpty(Map m, String key, String value) { + if (value.isNotEmpty) { + m[key] = value; + } + } + + static String _normalizePhone(String raw) { + var s = raw.replaceAll(RegExp(r'\s'), ''); + if (s.startsWith('0033')) { + s = '+33${s.substring(4)}'; + } + return s; + } + + /// `jj/mm/aaaa` → `aaaa-mm-jj`, ou `null` si invalide. + static String? _ddMmYyyyToIso(String ddMmYyyy) { + if (ddMmYyyy.isEmpty) return null; + final parts = ddMmYyyy.split('/'); + if (parts.length != 3) return null; + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day == null || month == null || year == null) return null; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + } +}